Photomerge, Merge to HDR Pro, Lens Correction, Adaptive Wide Angle, Camera Raw, Vanishing Point

Harris+BRIEF/RANSAC panorama with bundle adjustment, seams and multi-band
blending (Auto-Align/Auto-Blend share it); Debevec-Malik HDR merge with
ghost removal and tone mapping; Brown-Conrady lens correction (filter,
batch, smart filter); wide-angle constraint mesh warp; Crop and
Straighten Photos; Camera Raw Filter (Basic, curves, mixer, grading,
detail, effects) with dialog; Vanishing Point planes (engine only).
Fixes warp_triangles tile binning.

Checkpoint also includes compiling in-progress work of the remaining agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 23:23:49 -04:00
parent ceb2667ac4
commit 6f5aba08d3
23 changed files with 344 additions and 104 deletions

2
Cargo.lock generated
View File

@ -3519,10 +3519,12 @@ dependencies = [
"egui_kittest",
"log",
"photocraft-algo",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",
"photocraft-doc",
"photocraft-engine",
"photocraft-format",
"photocraft-geom",
"photocraft-gpu",
"photocraft-io",

View File

@ -184,46 +184,55 @@ fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
/// Box mean of a single-channel buffer (radius `r`, edge clamped) — parallel over rows/columns.
/// Box mean of a single-channel buffer (radius `r`, edge clamped): rows in parallel, then
/// columns in parallel bands with running column sums (cache-friendly, no transposes).
fn box_mean(v: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
if r == 0 {
if r == 0 || w == 0 || h == 0 {
return v.to_vec();
}
let n = (2 * r + 1) as f64;
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, 1, |y, row| {
let src = &v[y * w..(y + 1) * w];
let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize] as f64;
let mut acc: f64 = (-(r as i64)..=r as i64).map(at).sum();
let n = (2 * r + 1) as f64;
for (x, o) in row.iter_mut().enumerate() {
*o = (acc / n) as f32;
acc += at(x as i64 + r as i64 + 1) - at(x as i64 - r as i64);
}
});
// Columns via a transpose so rows stay contiguous.
let mut t = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
t[x * h + y] = tmp[y * w + x];
const BAND: usize = 256;
let bands = crate::photo_util::par_map(w.div_ceil(BAND), |b| {
let (x0, x1) = (b * BAND, ((b + 1) * BAND).min(w));
let bw = x1 - x0;
let row = |y: i64| &tmp[y.clamp(0, h as i64 - 1) as usize * w + x0..y.clamp(0, h as i64 - 1) as usize * w + x1];
let mut acc = vec![0.0f64; bw];
for y in -(r as i64)..=r as i64 {
for (a, v) in acc.iter_mut().zip(row(y)) {
*a += *v as f64;
}
}
}
let mut t2 = vec![0.0f32; w * h];
par_rows(&mut t2, h, 1, |x, row| {
let src = &t[x * h..(x + 1) * h];
let at = |k: i64| src[k.clamp(0, h as i64 - 1) as usize] as f64;
let mut acc: f64 = (-(r as i64)..=r as i64).map(at).sum();
let n = (2 * r + 1) as f64;
for (y, o) in row.iter_mut().enumerate() {
*o = (acc / n) as f32;
acc += at(y as i64 + r as i64 + 1) - at(y as i64 - r as i64);
let mut out = vec![0.0f32; bw * h];
for y in 0..h {
for (o, a) in out[y * bw..(y + 1) * bw].iter_mut().zip(&acc) {
*o = (*a / n) as f32;
}
let (add, sub) = (row(y as i64 + r as i64 + 1), row(y as i64 - r as i64));
for ((a, p), m) in acc.iter_mut().zip(add).zip(sub) {
*a += (*p - *m) as f64;
}
}
out
});
for y in 0..h {
for x in 0..w {
tmp[y * w + x] = t2[x * h + y];
let mut out = vec![0.0f32; w * h];
for (b, band) in bands.iter().enumerate() {
let (x0, x1) = (b * BAND, ((b + 1) * BAND).min(w));
let bw = x1 - x0;
for y in 0..h {
out[y * w + x0..y * w + x1].copy_from_slice(&band[y * bw..(y + 1) * bw]);
}
}
tmp
out
}
/// Approximate Gaussian (three box passes).
@ -238,6 +247,52 @@ fn gauss(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
out
}
/// Fast guided filter (K. He, J. Sun, *Fast Guided Filter*, arXiv 2015): the linear
/// coefficients are computed on a `s×` subsampled copy and upsampled bilinearly, which is
/// visually equivalent for the large radii used by tone controls at a fraction of the cost.
fn guided_fast(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let s = (r / 8).clamp(1, 8);
if s == 1 {
return guided(i, p, w, h, r, eps);
}
let (sw, sh) = (w.div_ceil(s), h.div_ceil(s));
let down = |v: &[f32]| -> Vec<f32> {
let mut o = vec![0.0f32; sw * sh];
let mut c = vec![0.0f32; sw * sh];
for y in 0..h {
for x in 0..w {
let k = (y / s) * sw + x / s;
o[k] += v[y * w + x];
c[k] += 1.0;
}
}
o.iter().zip(&c).map(|(a, b)| a / b.max(1.0)).collect()
};
let (si, sp) = (down(i), down(p));
let rs = (r / s).max(1);
let mi = box_mean(&si, sw, sh, rs);
let mp = box_mean(&sp, sw, sh, rs);
let ip: Vec<f32> = si.iter().zip(&sp).map(|(a, b)| a * b).collect();
let ii: Vec<f32> = si.iter().map(|a| a * a).collect();
let mip = box_mean(&ip, sw, sh, rs);
let mii = box_mean(&ii, sw, sh, rs);
let a: Vec<f32> = (0..sw * sh).map(|k| (mip[k] - mi[k] * mp[k]) / (mii[k] - mi[k] * mi[k] + eps)).collect();
let b: Vec<f32> = (0..sw * sh).map(|k| mp[k] - a[k] * mi[k]).collect();
let (ma, mb) = (box_mean(&a, sw, sh, rs), box_mean(&b, sw, sh, rs));
let mut out = vec![0.0f32; w * h];
let sf = s as f32;
par_rows(&mut out, w, 1, |y, row| {
let fy = (y as f32 + 0.5) / sf - 0.5;
for (x, o) in row.iter_mut().enumerate() {
let fx = (x as f32 + 0.5) / sf - 0.5;
let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, fx, fy);
let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, fx, fy);
*o = av * i[y * w + x] + bv;
}
});
out
}
/// Guided filter of `p` guided by `i` (He et al. 2010).
fn guided(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
let mi = box_mean(i, w, h, r);
@ -453,11 +508,11 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo
let lc: Vec<f32> = l.iter().map(|v| v.min(1.0)).collect();
let base = if p.highlights != 0.0 || p.shadows != 0.0 {
let r = ((long * 0.015) as usize).max(2);
Some(guided(&lc, &lc, w, h, r, 0.02))
Some(guided_fast(&lc, &lc, w, h, r, 0.02))
} else {
None
};
let clar = (p.clarity != 0.0).then(|| guided(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005));
let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps)));
let k_con = p.contrast / 100.0 * 0.6;
let tau = std::f32::consts::TAU;
@ -723,14 +778,12 @@ fn detail(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, ps: f32) {
let mask: Option<Vec<f32>> = (p.sharpen_masking > 0.0).then(|| {
let s = gauss(&y, w, h, 1.0 * ps.max(0.5));
let t = p.sharpen_masking / 100.0 * 0.08;
(0..w * h)
.map(|i| {
crate::photo_util::par_map(w * h, |i| {
let (x, yy) = (i % w, i / w);
let gx = s[yy * w + (x + 1).min(w - 1)] - s[yy * w + x.saturating_sub(1)];
let gy = s[(yy + 1).min(h - 1) * w + x] - s[yy.saturating_sub(1) * w + x];
smoothstep(t * 0.5, t + 1e-4, gx.hypot(gy))
})
.collect()
});
let amt = p.sharpen_amount / 100.0 * 1.2;
let det = p.sharpen_detail / 100.0;

View File

@ -276,8 +276,7 @@ pub fn merge(w: usize, h: usize, imgs: &[&[[f32; 4]]], opts: &MergeOptions) -> M
let mut ghost_fraction = 0.0;
if opts.remove_ghosts && p > 1 {
// Weighted variance of per-exposure log radiance (luminance).
let score: Vec<f32> = (0..w * h)
.map(|i| {
let score: Vec<f32> = crate::photo_util::par_map(w * h, |i| {
let (mut s, mut s2, mut sw) = (0.0f64, 0.0f64, 0.0f64);
for j in 0..p {
let q = imgs[j][i];
@ -296,8 +295,7 @@ pub fn merge(w: usize, h: usize, imgs: &[&[[f32; 4]]], opts: &MergeOptions) -> M
}
let m = s / sw;
((s2 / sw - m * m).max(0.0)).sqrt() as f32
})
.collect();
});
for i in 0..w * h {
ghost[i] = if score[i] > 0.35 { 1.0 } else { 0.0 };
}
@ -323,7 +321,7 @@ pub fn merge(w: usize, h: usize, imgs: &[&[[f32; 4]]], opts: &MergeOptions) -> M
// Scale per channel so the base exposure's well-exposed pixels map to their linear values.
for c in 0..3 {
let mut r: Vec<f64> = (0..w * h)
.step_by(5)
.step_by(37)
.filter_map(|i| {
let v = imgs[base][i][c];
((0.15..0.85).contains(&v) && px[i][c] > 0.0).then(|| srgb_to_linear(v) as f64 / (px[i][c] as f64 * opts.exposures[base]))
@ -335,7 +333,7 @@ pub fn merge(w: usize, h: usize, imgs: &[&[[f32; 4]]], opts: &MergeOptions) -> M
q[c] = (q[c] as f64 * k) as f32;
}
}
let mut l: Vec<f32> = px.iter().filter(|q| q[3] > 0.0).map(|q| luma(*q).max(1e-9)).collect();
let mut l: Vec<f32> = px.iter().step_by(13).filter(|q| q[3] > 0.0).map(|q| luma(*q).max(1e-9)).collect();
l.sort_by(f32::total_cmp);
let stops = if l.is_empty() { 0.0 } else { (l[(l.len() as f64 * 0.999) as usize % l.len()] as f64 / l[l.len() / 100] as f64).log2() };
Merged { px, response, ghost_base: base, ghost_fraction, stops }

View File

@ -36,16 +36,27 @@ pub struct Prim {
pub c1: [f32; 4],
/// Edge softness in pixels (≥ 1 for anti-aliasing; larger for glows).
pub soft: f32,
/// Additive (glow) instead of normal blending.
pub add: bool,
pub blend: Blend,
}
/// How a primitive combines with what is under it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Blend {
/// Source over.
#[default]
Normal,
/// Additive light (glows).
Add,
/// Per-channel maximum of the premultiplied values (overlapping strands don't build up).
Lighten,
}
impl Prim {
pub fn capsule(a: (f32, f32), b: (f32, f32), ra: f32, rb: f32, c0: [f32; 4], c1: [f32; 4]) -> Prim {
Prim { shape: Shape::Capsule { a, b, ra, rb }, c0, c1, soft: 1.0, add: false }
Prim { shape: Shape::Capsule { a, b, ra, rb }, c0, c1, soft: 1.0, blend: Blend::Normal }
}
pub fn ellipse(c: (f32, f32), rx: f32, ry: f32, angle: f32, c0: [f32; 4], c1: [f32; 4]) -> Prim {
Prim { shape: Shape::Ellipse { c, rx, ry, angle }, c0, c1, soft: 1.0, add: false }
Prim { shape: Shape::Ellipse { c, rx, ry, angle }, c0, c1, soft: 1.0, blend: Blend::Normal }
}
pub fn disc(c: (f32, f32), r: f32, col: [f32; 4]) -> Prim {
Prim::ellipse(c, r, r, 0.0, col, col)
@ -55,7 +66,11 @@ impl Prim {
self
}
fn additive(mut self) -> Prim {
self.add = true;
self.blend = Blend::Add;
self
}
fn lighten(mut self) -> Prim {
self.blend = Blend::Lighten;
self
}
@ -139,17 +154,26 @@ fn raster_tile(prims: &[Prim], idx: &[u32], t: Rect) -> Vec<[f32; 4]> {
continue;
}
let d = &mut buf[row + (x - t.x0) as usize];
if p.add {
d[0] += c[0] * a;
d[1] += c[1] * a;
d[2] += c[2] * a;
d[3] += a * (1.0 - d[3]);
} else {
let k = 1.0 - a;
d[0] = c[0] * a + d[0] * k;
d[1] = c[1] * a + d[1] * k;
d[2] = c[2] * a + d[2] * k;
d[3] = a + d[3] * k;
match p.blend {
Blend::Add => {
d[0] += c[0] * a;
d[1] += c[1] * a;
d[2] += c[2] * a;
d[3] += a * (1.0 - d[3]);
}
Blend::Lighten => {
d[0] = d[0].max(c[0] * a);
d[1] = d[1].max(c[1] * a);
d[2] = d[2].max(c[2] * a);
d[3] = d[3].max(a);
}
Blend::Normal => {
let k = 1.0 - a;
d[0] = c[0] * a + d[0] * k;
d[1] = c[1] * a + d[1] * k;
d[2] = c[2] * a + d[2] * k;
d[3] = a + d[3] * k;
}
}
}
}
@ -506,42 +530,51 @@ fn draw_flame(out: &mut Vec<Prim>, spine: &Spine, width: f32, spec: &FlameSpec,
let opacity = spec.opacity.clamp(0.0, 100.0) / 100.0;
let half = width.max(1.0) / 2.0;
let seed = rng.next_u64();
// A soft additive glow behind the lines.
let ((mx, my), (tx, ty)) = spine.at(len * 0.4);
let glow_c = { let c = flame_colour(0.35, spec.color); [c[0], c[1], c[2], 0.35 * opacity] };
out.push(Prim::ellipse((mx, my), len * 0.55, half * 1.4 + 2.0, ty.atan2(tx), glow_c, [glow_c[0], glow_c[1] * 0.5, glow_c[2] * 0.3, 0.0]).with_soft((half * 0.8).max(2.0)).additive());
let line_r = (half / lines as f32 * 1.6).clamp(0.6, 6.0);
// The whole flame sways; each line adds its own flicker on top.
let sway = |u: f32| turb * half * 1.6 * fbm1(u * 1.7, seed ^ 0x5A5A) * u.powf(1.4);
// A soft additive body glow along the spine.
for k in 0..6 {
let u = k as f32 / 6.0 * 0.75 + 0.05;
let ((px, py), (dx, dy)) = spine.at(u * len);
let o = sway(u);
let c = flame_colour(u, spec.color);
let r = half * (1.25 - u) + 2.0;
out.push(Prim::ellipse((px - dy * o, py + dx * o), r, r * 1.2, dy.atan2(dx), [c[0], c[1], c[2], 0.16 * opacity], [c[0], c[1] * 0.6, c[2] * 0.3, 0.0]).with_soft(r.max(2.0)).additive());
}
let line_r = (half / lines as f32 * 2.2).clamp(0.8, 10.0);
for li in 0..lines {
let lane = if lines == 1 { 0.0 } else { li as f32 / (lines - 1) as f32 * 2.0 - 1.0 };
let lseed = seed ^ (li as u64).wrapping_mul(0x51_7CC1_B727_220A);
let start = (spec.flame_bottom_alignment.clamp(0.0, 100.0) / 100.0) * rng.f() * 0.4;
// Outer lines are shorter, giving the flame its tongue shape.
let reach = (1.0 - 0.45 * lane.abs().powi(2)) * rng.range(0.75, 1.0);
let reach = (1.0 - 0.55 * lane.abs().powf(1.5)) * rng.range(0.7, 1.0);
let end = (start + reach * (1.0 - start)).clamp(start + 0.05, 1.0);
let mut prev: Option<((f32, f32), f32)> = None;
let lr = line_r * rng.range(0.6, 1.3);
let mut prev: Option<((f32, f32), f32, f32)> = None;
for k in 0..=segs {
let u = start + (end - start) * k as f32 / segs as f32;
let s = u * len;
let ((px, py), (dx, dy)) = spine.at(s);
let (nx, ny) = (-dy, dx);
let spread = match shape {
FlameShape::Parallel => 1.0,
FlameShape::Parallel => 1.0 - 0.6 * u,
FlameShape::ToCenter => 1.0 - u,
FlameShape::Spread => 1.0 + u,
FlameShape::Oval => (std::f32::consts::PI * (0.15 + 0.85 * u)).sin() * 1.3,
FlameShape::Pointed => (1.0 - u).powi(2),
};
let wob = turb * half * 1.2 * fbm1(s / (len * 0.25 + 8.0) + li as f32 * 0.37, lseed) * u.sqrt() + jag * half * 0.35 * noise1(s / 6.0, lseed ^ 0x77) * u;
let off = lane * half * spread + wob;
let flick = turb * half * 0.5 * fbm1(s / (len * 0.2 + 6.0) + li as f32 * 0.37, lseed) * u + jag * half * 0.25 * noise1(s / 5.0, lseed ^ 0x77) * u;
let off = lane * half * spread + sway(u) + flick;
let p = (px + nx * off, py + ny * off);
let r = line_r * (1.0 - u * 0.8);
if let Some((q, rq)) = prev {
let c0 = flame_colour((u - (end - start) / segs as f32).max(0.0), spec.color);
let c1 = flame_colour(u, spec.color);
let fade = |c: [f32; 4]| [c[0], c[1], c[2], c[3] * opacity * 0.9];
out.push(Prim::capsule(q, p, rq, r, fade(c0), fade(c1)).with_soft(1.0 + line_r * 0.6).additive());
let r = lr * (1.0 - u * 0.75);
// Lines fade out towards their own tip.
let fade_t = ((u - start) / (end - start).max(1e-3)).clamp(0.0, 1.0);
if let Some((q, rq, uq)) = prev {
// Fade in over the first tenth (a soft base) and out towards the line's tip.
let f = |c: [f32; 4], uu: f32, t: f32| [c[0], c[1], c[2], c[3] * opacity * (1.0 - t.powi(3)) * ((uu - start) / 0.08).clamp(0.0, 1.0).sqrt()];
out.push(Prim::capsule(q, p, rq, r, f(flame_colour(uq, spec.color), uq, fade_t), f(flame_colour(u, spec.color), u, fade_t)).with_soft(1.0 + lr * 0.6).lighten());
}
prev = Some((p, r));
prev = Some((p, r, u));
}
}
}
@ -813,9 +846,16 @@ impl TreeCtx<'_> {
Leaf::Long => Prim::ellipse(p, s * 1.6, s * 0.28, ang, shade(col, k * 1.1), shade(col, k * 0.8)),
Leaf::Heart => Prim::ellipse(p, s * 0.9, s * 1.1, ang, shade(col, k * 1.1), shade(col, k * 0.75)),
Leaf::Needle => {
let (sa, ca) = ang.sin_cos();
let q = (p.0 + ca * s * 1.4, p.1 + sa * s * 1.4);
Prim::capsule(p, q, (s * 0.12).max(0.5), (s * 0.06).max(0.3), shade(col, k * 0.9), shade(col, k * 1.1))
// A tuft of needles fanning out from the twig.
let n = 7;
for j in 0..n {
let a = ang + (j as f32 / (n - 1) as f32 - 0.5) * 2.4 + self.rng.sym() * 0.15;
let l = s * self.rng.range(1.1, 1.7);
let q = (p.0 + a.cos() * l, p.1 + a.sin() * l);
let kk = k + 0.1 * self.rng.sym();
self.leaves.push((kk, Prim::capsule(p, q, (s * 0.1).max(0.45), (s * 0.05).max(0.3), shade(col, kk * 0.85), shade(col, kk * 1.1))));
}
return;
}
Leaf::Blossom => Prim::ellipse(p, s * 0.75, s * 0.75, 0.0, shade(col, k * 1.08), shade(col, k * 0.85)),
Leaf::Frond => Prim::ellipse(p, s * 1.6, s * 0.3, ang, shade(col, k), shade(col, k * 0.8)),
@ -1158,7 +1198,7 @@ pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
let short = (canvas.width().min(canvas.height()) as f32).max(8.0);
let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0;
let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05);
let thick = (unit * 0.08 + short * 0.0006 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
let inset = margin + unit;
let b = Border { x0: canvas.x0 as f32 + inset, y0: canvas.y0 as f32 + inset, w: (canvas.width() as f32 - 2.0 * inset).max(1.0), h: (canvas.height() as f32 - 2.0 * inset).max(1.0) };

View File

@ -92,9 +92,10 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2
// Triangles: (vertex indices), binned per destination tile.
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
let mut bins: Vec<Vec<[usize; 3]>> = vec![Vec::new(); tiles.len()];
let tx0 = tiles.iter().map(|t| t.x0).min().unwrap_or(0);
let ty0 = tiles.iter().map(|t| t.y0).min().unwrap_or(0);
let ts = photocraft_geom::TILE_SIZE;
// Bin origin on the tile grid (the first tile is clipped to `dst`, so its x0/y0 may not be).
let tx0 = tiles.iter().map(|t| t.x0).min().unwrap_or(0).div_euclid(ts) * ts;
let ty0 = tiles.iter().map(|t| t.y0).min().unwrap_or(0).div_euclid(ts) * ts;
let cols = tiles.iter().map(|t| (t.x0 - tx0) / ts).max().unwrap_or(0) as usize + 1;
let grid_of = |t: &Rect| ((t.y0 - ty0) / ts) as usize * cols + ((t.x0 - tx0) / ts) as usize;
let mut lookup = vec![usize::MAX; cols * (tiles.iter().map(|t| (t.y0 - ty0) / ts).max().unwrap_or(0) as usize + 1)];

View File

@ -442,3 +442,22 @@ mod tests {
assert!(out.pixel(32, 24)[3] > 0.99 && out.pixel(1, 1)[3] < 0.01);
}
}
#[cfg(test)]
mod tile_tests {
use super::*;
use photocraft_color::PixelFormat;
#[test]
fn no_gaps_across_tiles() {
let frame = Rect::new(0, 0, 772, 517);
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(frame, &[0.2, 0.4, 0.6, 1.0]);
for p in [WideAngle::default(), WideAngle { model: WideModel::Fisheye, focal_length: 12.0, constraints: vec![Constraint { a: [20.0, 30.0], b: [700.0, 35.0], orientation: Orientation::Horizontal }], ..Default::default() }] {
let out = apply(&s, frame, &p, Interp::Bilinear);
let holes: Vec<(i32, i32)> = (80..440).flat_map(|y| (80..690).map(move |x| (x, y))).filter(|&(x, y)| out.rgba(x, y)[3] < 0.5).collect();
let m = solve(&p, frame);
assert!(holes.is_empty(), "{} holes, first {:?} last {:?}; mesh {}x{} v0 {:?} vlast {:?} bounds {:?}", holes.len(), holes.first(), holes.last(), m.nx, m.ny, m.verts[0], m.verts.last(), out.content_bounds());
}
}
}

View File

@ -169,7 +169,7 @@ fn script_events(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("removeAll").and_then(Value::as_bool) == Some(true) {
ev.bindings.clear();
}
if let Some(i) = p.get("remove").and_then(Value::as_u64) {
if let Some(i) = crate::commands::int(p, "remove").filter(|v| *v >= 0) {
if (i as usize) >= ev.bindings.len() {
return Err(bad(cmd, format!("no binding {i}")));
}
@ -412,7 +412,7 @@ fn contact_sheet(s: &mut Session, p: &Value) -> Result<Value> {
let font_pt = f("fontSize", 12.0);
let flatten = p.get("flatten").and_then(Value::as_bool).unwrap_or(false);
let mode = p.get("mode").and_then(Value::as_str).unwrap_or("rgb").to_string();
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8);
let depth = crate::commands::int(p, "depth").unwrap_or(8);
let caption_px = if caption { font_pt * res / 72.0 * 1.5 } else { 0.0 };
let cell_w = ((f64::from(pw) - f64::from(cols + 1) * hs) / f64::from(cols)).max(1.0);
let cell_h = ((f64::from(ph) - f64::from(rows + 1) * vs) / f64::from(rows)).max(1.0);

View File

@ -55,7 +55,7 @@ choice!(TypeUnit { Points = "points", Pixels = "pixels", Millimeters = "mm" } de
choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } default PostScript);
choice!(Interpolation { BicubicAutomatic = "bicubicAutomatic", Nearest = "nearestNeighbor", Bilinear = "bilinear", Bicubic = "bicubic", BicubicSmoother = "bicubicSmoother", BicubicSharper = "bicubicSharper", PreserveDetails = "preserveDetails" } default BicubicAutomatic);
choice!(ColorPicker { Adobe = "adobe", System = "system" } default Adobe);
choice!(Theme { Pro = "pro", Studio = "studio", StudioLight = "studioLight", Classic = "classic" } default Pro);
choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioLight = "studioLight", Classic = "classic" } default Pro);
choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default);
choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow);
choice!(UiScale { Auto = "auto", P100 = "100", P200 = "200" } default Auto);

View File

@ -137,7 +137,8 @@ pub fn print_pdf(page: &PrintPage) -> Vec<u8> {
obj(&mut out, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
obj(&mut out, format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im0 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>").as_bytes());
let mut content = format!("q {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
// An explicit white page, so every viewer (and rasteriser) shows paper, not transparency.
let mut content = format!("q 1 g 0 0 {pw:.2} {ph:.2} re f Q\nq {w:.3} 0 0 {h:.3} {x:.3} {y:.3} cm /Im0 Do Q\n");
content.push_str(&marks_ops(page.marks, page.rect));
let any_marks = page.marks.corner_crop || page.marks.center_crop || page.marks.registration;
let pad = if any_marks { 30.0 } else { 6.0 };
@ -302,7 +303,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
};
write_file(&pdf_path, &pdf)?;
let copies = p.get("copies").and_then(Value::as_u64).unwrap_or(1).clamp(1, 999);
let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999);
let mut argv: Vec<String> = vec!["lp".into()];
if let Some(pr) = p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()) {
argv.extend(["-d".into(), pr.to_string()]);

View File

@ -152,7 +152,7 @@ fn apply_options(sl: &mut Slice, p: &Value, cmd: &str) -> Result<()> {
sl.cell_text_is_html = b;
}
for (k, slot) in [("horizontalAlign", &mut sl.horizontal_align), ("verticalAlign", &mut sl.vertical_align)] {
if let Some(v) = p.get(k).and_then(Value::as_u64) {
if let Some(v) = crate::commands::int(p, k).filter(|v| *v >= 0).map(|v| v as u64) {
*slot = v.min(4) as u32;
}
}
@ -180,11 +180,11 @@ fn apply_options(sl: &mut Slice, p: &Value, cmd: &str) -> Result<()> {
/// The stored slice addressed by `"slice"` (id) or `"number"`; an auto slice's number promotes
/// it to a user slice first when `promote` is set.
fn target(s: &mut Session, p: &Value, cmd: &str, promote: bool) -> Result<u32> {
if let Some(id) = p.get("slice").and_then(Value::as_u64) {
if let Some(id) = crate::commands::int(p, "slice").filter(|v| *v >= 0).map(|v| v as u64) {
let d = s.active().ok_or(EngineError::NoDocument)?;
return d.doc.slices.get(id as u32).map(|sl| sl.id).ok_or_else(|| bad(cmd, format!("no slice with id {id}")));
}
let n = p.get("number").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "give \"slice\" (id) or \"number\""))? as usize;
let n = crate::commands::int(p, "number").filter(|v| *v > 0).ok_or_else(|| bad(cmd, "give \"slice\" (id) or \"number\""))? as usize;
let d = s.active().ok_or(EngineError::NoDocument)?;
let r = slices::resolve(&d.doc).into_iter().find(|r| r.number == n).ok_or_else(|| bad(cmd, format!("no slice number {n}")))?;
match r.id {
@ -344,8 +344,8 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
fn divide(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "slice.divide";
let id = target(s, p, cmd, true)?;
let down = p.get("horizontal").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as i32;
let across = p.get("vertical").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as i32;
let down = crate::commands::int(p, "horizontal").unwrap_or(1).clamp(1, 1000) as i32;
let across = crate::commands::int(p, "vertical").unwrap_or(1).clamp(1, 1000) as i32;
if down == 1 && across == 1 {
return Err(bad(cmd, "give \"horizontal\" (slices down) and/or \"vertical\" (slices across) > 1"));
}

View File

@ -145,7 +145,7 @@ impl WebSettings {
k => PaletteKind::from_id(k).ok_or_else(|| bad(cmd, format!("unknown palette `{k}` (perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform)")))?,
};
}
if let Some(n) = p.get("colors").and_then(Value::as_u64) {
if let Some(n) = crate::commands::int(p, "colors").filter(|v| *v > 0).map(|v| v as u64) {
st.colors = (n as usize).clamp(2, 256);
}
if let Some(d) = s("dither") {

View File

@ -31,3 +31,6 @@ rayon = { workspace = true }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] }
# Only for examples/snapshot.rs (offscreen UI renders of real files).
photocraft-io = { workspace = true }
# Only for examples/interactive_bench.rs (JPEG encode, .pcraft saves).
photocraft-codecs = { workspace = true }
photocraft-format = { workspace = true }

View File

@ -3,7 +3,7 @@
//! save .pcraft / .psd, export PNG / JPEG, plus the canvas refresh each edit triggers.
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example interactive_bench -- [--size 7360x4912] [--reps 5] [--json out.json]
//! cargo run --release -p photocraft-ui-egui --example interactive_bench -- [--size 7360x4912] [--reps 5] [--json out.json] [--only text] [--cpu]
//! ```
//!
//! Every edit goes through `Session::execute` (the command path the UI, CLI and MCP share:
@ -76,6 +76,9 @@ fn photo(w: u32, h: u32) -> photocraft_codecs::Image {
struct Bench {
gpu: Option<Gpu>,
reps: usize,
/// `--only text`: time only rows whose name contains it (others run once, untimed, for the
/// state later rows need).
only: Option<String>,
rows: Vec<(String, f64, f64, f64, f64)>,
}
@ -85,7 +88,7 @@ impl Bench {
fn refresh(&mut self, s: &Session, full: bool) -> f64 {
let st = s.active().expect("document");
let doc = st.doc.clone();
let region = if full { doc.bounds() } else { st.last_damage.map_or(doc.bounds(), |r| r.inflate(photocraft_compose::effects::margin_all(&doc.layers)).intersect(&doc.bounds())) };
let region = if full { doc.bounds() } else { st.last_damage.map_or(doc.bounds(), |r| r.inflate(reach(&doc.layers)).intersect(&doc.bounds())) };
if region.is_empty() {
return 0.0;
}
@ -106,6 +109,10 @@ impl Bench {
/// Times `f` (`reps` runs after a cold one); `f` returns its own breakdown-free total in ms.
fn time(&mut self, name: &str, mut f: impl FnMut(&mut Self) -> f64) {
if self.only.as_ref().is_some_and(|o| !name.contains(o.as_str())) {
f(self);
return;
}
let cold = f(self);
let mut v: Vec<f64> = (0..self.reps).map(|_| f(self)).collect();
v.sort_by(f64::total_cmp);
@ -115,6 +122,27 @@ impl Bench {
}
}
/// How far an edit's composite change reaches beyond its damage (layer effects).
fn reach(layers: &[photocraft_doc::Layer]) -> i32 {
layers
.iter()
.map(|l| {
let own = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 };
let kids = match &l.content {
photocraft_doc::LayerContent::Group(g) => reach(&g.children),
_ => 0,
};
own + kids
})
.max()
.unwrap_or(0)
}
fn select(s: &mut Session, index: usize) {
let id = s.active().expect("doc").doc.layers[index].id;
exec(s, "layer.select", json!({"layer": id.0}));
}
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
@ -129,7 +157,7 @@ fn main() {
let args: Vec<String> = std::env::args().collect();
let (w, h) = arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912));
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1);
let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, rows: Vec::new() };
let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, only: arg(&args, "--only"), rows: Vec::new() };
println!("document {w}×{h} ({:.1} MP), 8-bit RGB, {} reps, {}", (w * h) as f64 / 1e6, reps, if b.gpu.is_some() { "GPU canvas" } else { "CPU canvas" });
// ---- open / save / export ----------------------------------------------------------------
@ -185,7 +213,7 @@ fn main() {
});
// ---- filters / adjustments / layers ------------------------------------------------------
exec(&mut s, "layer.select", json!({"index": 0})).to_owned();
select(&mut s, 0);
for radius in [4.0, 40.0] {
b.time(&format!("Gaussian Blur r {radius} (photo layer) + refresh"), |b| {
let t = Instant::now();
@ -204,7 +232,7 @@ fn main() {
exec(&mut s, "layer.setAdjustment", json!({"gamma": g}));
ms(t) + b.refresh(&s, false)
});
exec(&mut s, "layer.select", json!({"index": 1}));
select(&mut s, 1);
let mut d = 1;
b.time("move paint layer 10 px + refresh", |b| {
d = -d;

View File

@ -1118,6 +1118,9 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
let o = &app.ui.tool_options;
last = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, false, d.start, last);
}
if d.tool == Tool::Crop {
last = crop_end(app, d.start, last);
}
match d.tool {
Tool::Brush | Tool::Eraser => {
let c = if d.tool == Tool::Eraser { [1.0, 1.0, 1.0, 0.6] } else { app.session.tools.foreground };
@ -1376,6 +1379,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
}
}
Tool::Crop => {
let end = crop_end(app, d.start, [end[0], end[1]]);
let r = [d.start[0].min(end[0]), d.start[1].min(end[1]), d.start[0].max(end[0]), d.start[1].max(end[1])];
if r[2] - r[0] >= 2.0 && r[3] - r[1] >= 2.0 {
app.ui.crop_rect = Some(r);
@ -1443,11 +1447,21 @@ pub fn commit_polygon(app: &mut PhotocraftApp, mods: egui::Modifiers) {
}
/// Apply the crop tool's rectangle.
/// Crop drag end under the options-bar aspect ratio.
fn crop_end(app: &PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> [f64; 2] {
let size = app.session.active().map_or((1.0, 1.0), |s| (s.doc.size.width as f64, s.doc.size.height as f64));
match crate::chrome_ui::crop_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1) {
Some((w, h)) => crate::chrome_ui::marquee_end("fixedRatio", w, h, false, start, end),
None => end,
}
}
pub fn commit_crop(app: &mut PhotocraftApp) {
let Some(r) = app.ui.crop_rect.take() else { return };
let (x, y) = (r[0].round(), r[1].round());
let (w, h) = ((r[2] - r[0]).round().max(1.0), (r[3] - r[1]).round().max(1.0));
if app.run("image.crop", json!({"x": x, "y": y, "width": w, "height": h})).is_ok()
let delete = app.ui.tool_options.crop_delete;
if app.run("image.crop", json!({"x": x, "y": y, "width": w, "height": h, "deleteCroppedPixels": delete})).is_ok()
&& let Some(i) = app.session.active_index()
{
app.ui.views[i].fit_pending = true;

View File

@ -172,6 +172,18 @@ pub fn marquee_end(style: &str, w: f64, h: f64, shift: bool, start: [f64; 2], en
}
}
/// Crop options bar ratio presets: (key, label).
pub const CROP_RATIOS: &[(&str, &str)] = &[("", "Ratio"), ("original", "Original Ratio"), ("1:1", "1 : 1 (Square)"), ("4:5", "4 : 5 (8 : 10)"), ("5:7", "5 : 7"), ("2:3", "2 : 3 (4 : 6)"), ("16:9", "16 : 9")];
/// Width/height of a crop ratio key (`original` uses the document size).
pub fn crop_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option<(f64, f64)> {
if key == "original" {
return Some((doc_w, doc_h));
}
let (a, b) = key.split_once(':')?;
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
}
#[cfg(test)]
mod tests {
use super::*;
@ -219,6 +231,15 @@ mod tests {
assert_eq!(marquee_end("fixedRatio", 2.0, 1.0, false, [0.0, 0.0], [10.0, 30.0]), [60.0, 30.0]);
}
#[test]
fn crop_ratios_parse() {
assert_eq!(crop_ratio("16:9", 1.0, 1.0), Some((16.0, 9.0)));
assert_eq!(crop_ratio("original", 2400.0, 1500.0), Some((2400.0, 1500.0)));
assert_eq!(crop_ratio("", 1.0, 1.0), None);
let (w, h) = crop_ratio("1:1", 0.0, 0.0).unwrap();
assert_eq!(marquee_end("fixedRatio", w, h, false, [0.0, 0.0], [50.0, 20.0]), [50.0, 50.0]);
}
#[test]
fn every_status_key_has_text() {
let d = doc();

View File

@ -76,10 +76,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
"Create"
} else if d.fields.contains_key("__export") {
"Export"
} else if let Some(l) = crate::file_ui::ok_label(&d.fields) {
l
} else {
"OK"
crate::file_ui::ok_label(&d.fields).unwrap_or("OK")
};
if crate::widgets::primary_button(ui, ok_label, 84.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
outcome = Some(true);

View File

@ -212,6 +212,8 @@ fn variants(p: &Value) -> Vec<Value> {
out
}
/// The flattened preview image: pixels, width, height, full-size / proxy pixel ratio.
type WebProxy = (Arc<Vec<[f32; 4]>>, u32, u32, f64);
type WebCache = (String, Arc<egui::TextureHandle>, Option<(usize, Option<usize>, &'static str)>);
/// Renders one preview pane: the original (`None`) or an optimised variant.
@ -224,7 +226,7 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
let base = p.cloned().unwrap_or_else(|| json!({}));
let size_sig = format!("{}|{}|{}", base["percent"], base["width"], base["height"]);
let wkey = egui::Id::new(("web-proxy", doc.id.0, rev, size_sig.clone()));
let proxy: Option<(Arc<Vec<[f32; 4]>>, u32, u32, f64)> = ui.data(|d| d.get_temp(wkey));
let proxy: Option<WebProxy> = ui.data(|d| d.get_temp(wkey));
let (px, w, h, ratio) = match proxy {
Some(v) => v,
None => {
@ -464,7 +466,8 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
let p = params(f);
ui.horizontal_top(|ui| {
// Page preview.
let (area, _) = ui.allocate_exact_size(egui::vec2(330.0, 420.0), egui::Sense::hover());
ui.vertical(|ui| {
let (area, _) = ui.allocate_exact_size(egui::vec2(330.0, 400.0), egui::Sense::hover());
ui.painter().rect_filled(area, 0.0, t.canvas);
match photocraft_engine::print_cmds::layout(&doc, &p, "file.print") {
Ok(l) => {
@ -498,12 +501,15 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Va
clip.circle_stroke(c, 3.5, mark);
}
}
ui.painter().text(area.left_bottom() + egui::vec2(6.0, -6.0), egui::Align2::LEFT_BOTTOM, format!("Scale {:.1}%", l.scale * 100.0), egui::FontId::proportional(11.0), t.text_dim);
let fits = x >= 0.0 && y >= 0.0 && x + w <= pw && y + h <= ph;
let note = if fits { String::new() } else { " · larger than the paper".to_string() };
ui.label(egui::RichText::new(format!("Scale {:.1}% · {:.2} × {:.2} in{note}", l.scale * 100.0, w / 72.0, h / 72.0)).color(if fits { t.text_dim } else { t.warning }).size(11.0));
}
Err(e) => {
ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, e.to_string(), egui::FontId::proportional(12.0), t.text_dim);
}
}
});
ui.add_space(12.0);
ui.vertical(|ui| {
ui.set_width(340.0);

View File

@ -45,6 +45,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
("window.toggle.options", "Options", &["Window"], None),
("window.theme.toggle", "Next Theme", &["Window"], None),
("window.theme.pro", "Pro Theme", &["Window", "Theme"], None),
("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None),
("window.theme.studio", "Studio Theme", &["Window", "Theme"], None),
("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None),
("window.theme.classic", "Classic Theme", &["Window", "Theme"], None),
@ -218,7 +219,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va
app.set_theme(ctx, next);
Ok(Value::Null)
}
"window.theme.pro" | "window.theme.studio" | "window.theme.studioLight" | "window.theme.classic" => {
"window.theme.pro" | "window.theme.proMedium" | "window.theme.studio" | "window.theme.studioLight" | "window.theme.classic" => {
let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default();
app.set_theme(ctx, k);
Ok(Value::Null)

View File

@ -453,13 +453,26 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
widgets::checkbox(ui, &mut dither, "Dither");
}
Tool::Crop if t.pro => {
let mut ratio = 0u8;
widgets::dropdown(ui, "crop-ratio", &mut ratio, &[(0u8, "Ratio"), (1, "1 : 1 (Square)"), (2, "4 : 5 (8 : 10)"), (3, "16 : 9"), (4, "Original Ratio")], 120.0);
let o = &mut app.ui.tool_options;
let ratios: Vec<(String, &str)> = crate::chrome_ui::CROP_RATIOS.iter().map(|(k, l)| (k.to_string(), *l)).collect();
widgets::dropdown(ui, "crop-ratio", &mut o.crop_ratio, &ratios, 120.0);
// Custom ratio fields (Photoshop shows the preset's numbers here).
let (mut rw, mut rh) = crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0).map_or((0.0, 0.0), |(w, h)| (w as f32, h as f32));
let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed();
let swap = icons::button(ui, "arrow-left-right", 22.0, false, "Swaps height and width").clicked();
let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed();
if swap {
std::mem::swap(&mut rw, &mut rh);
}
if (cw || ch || swap) && rw > 0.0 && rh > 0.0 {
o.crop_ratio = format!("{}:{}", widgets::fmt_num(rw as f64), widgets::fmt_num(rh as f64));
}
widgets::vline(ui, 22.0);
let _ = widgets::secondary_button(ui, "Clear", 0.0);
if widgets::secondary_button(ui, "Clear", 0.0).clicked() {
o.crop_ratio.clear();
}
let _ = icons::button(ui, "grid-3x3", 24.0, true, "Overlay: Rule of Thirds");
let mut del = true;
widgets::checkbox(ui, &mut del, "Delete Cropped Pixels");
widgets::checkbox(ui, &mut o.crop_delete, "Delete Cropped Pixels");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if icons::button(ui, "check", 26.0, false, "Commit current crop operation (↵)").clicked() {
crate::canvas::commit_crop(app);

View File

@ -64,6 +64,7 @@ pub fn pasteboard_color(app: &PhotocraftApp) -> Option<Color32> {
fn theme_kind(t: Theme) -> ThemeKind {
match t {
Theme::Pro => ThemeKind::Pro,
Theme::ProMedium => ThemeKind::ProMedium,
Theme::Studio => ThemeKind::Studio,
Theme::StudioLight => ThemeKind::StudioLight,
Theme::Classic => ThemeKind::Classic,
@ -73,6 +74,7 @@ fn theme_kind(t: Theme) -> ThemeKind {
fn theme_pref(k: ThemeKind) -> Theme {
match k {
ThemeKind::Pro => Theme::Pro,
ThemeKind::ProMedium => Theme::ProMedium,
ThemeKind::Studio => Theme::Studio,
ThemeKind::StudioLight => Theme::StudioLight,
ThemeKind::Classic => Theme::Classic,

View File

@ -324,6 +324,16 @@ pub struct ToolOptions {
pub move_target: String,
#[serde(default)]
pub move_show_transform: bool,
/// Crop options bar: aspect ratio ("" = Ratio/unconstrained, "w:h", or "original") and
/// Delete Cropped Pixels.
#[serde(default)]
pub crop_ratio: String,
#[serde(default = "yes")]
pub crop_delete: bool,
}
fn yes() -> bool {
true
}
fn default_move_target() -> String {
@ -378,6 +388,8 @@ impl Default for ToolOptions {
move_auto_select: false,
move_target: default_move_target(),
move_show_transform: false,
crop_ratio: String::new(),
crop_delete: true,
}
}
}

View File

@ -17,16 +17,19 @@ pub enum ThemeKind {
/// Photoshop-style Spectrum dark: flat charcoal panels, tab strips, blue accents (default).
#[default]
Pro,
/// Photoshop's default (second) interface brightness: #535353 panels, #282828 canvas.
ProMedium,
Studio,
StudioLight,
Classic,
}
impl ThemeKind {
pub const ALL: [ThemeKind; 4] = [ThemeKind::Pro, ThemeKind::Studio, ThemeKind::StudioLight, ThemeKind::Classic];
pub const ALL: [ThemeKind; 5] = [ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::Studio, ThemeKind::StudioLight, ThemeKind::Classic];
pub fn label(self) -> &'static str {
match self {
ThemeKind::Pro => "Pro (Dark)",
ThemeKind::ProMedium => "Pro (Medium Gray)",
ThemeKind::Studio => "Studio (Dark)",
ThemeKind::StudioLight => "Studio (Light)",
ThemeKind::Classic => "Classic",
@ -39,6 +42,7 @@ impl ThemeKind {
pub fn from_name(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().replace([' ', '_', '-', '(', ')'], "").as_str() {
"pro" | "prodark" | "photoshop" | "dark" => Some(ThemeKind::Pro),
"promedium" | "promediumgray" | "medium" | "mediumgray" => Some(ThemeKind::ProMedium),
"studio" | "studiodark" => Some(ThemeKind::Studio),
"studiolight" | "light" => Some(ThemeKind::StudioLight),
"classic" | "win2000" | "retro" => Some(ThemeKind::Classic),
@ -97,6 +101,27 @@ pub struct Tokens {
impl Tokens {
pub fn for_kind(kind: ThemeKind) -> Self {
match kind {
// Sampled from Photoshop 2026's default brightness (raw display values).
ThemeKind::ProMedium => Tokens {
kind,
chrome: Color32::from_rgb(83, 83, 83),
dock: Color32::from_rgb(66, 66, 66),
card: Color32::from_rgb(83, 83, 83),
card_border: Color32::from_rgb(66, 66, 66),
field: Color32::from_rgb(69, 69, 69),
field_border: Color32::from_rgb(104, 104, 104),
hover: Color32::from_rgb(98, 98, 98),
pressed: Color32::from_rgb(112, 112, 112),
text: Color32::from_rgb(238, 238, 238),
text_dim: Color32::from_rgb(212, 212, 212),
text_faint: Color32::from_rgb(160, 160, 160),
icon: Color32::from_rgb(226, 226, 226),
accent_soft: Color32::from_rgb(110, 110, 110),
separator: Color32::from_rgb(62, 62, 62),
tab_strip: Color32::from_rgb(74, 74, 74),
row_selected: Color32::from_rgb(107, 107, 107),
..Tokens::for_kind(ThemeKind::Pro)
},
ThemeKind::Pro => Tokens {
kind,
chrome: Color32::from_rgb(50, 50, 50),
@ -242,7 +267,7 @@ impl Tokens {
}
pub fn dark(&self) -> bool {
matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro)
matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium)
}
}
@ -357,6 +382,9 @@ mod tests {
assert_eq!(ThemeKind::from_name("studio (light)"), Some(ThemeKind::StudioLight));
assert_eq!(ThemeKind::from_name("dark"), Some(ThemeKind::Pro));
assert_eq!(ThemeKind::from_name("studio"), Some(ThemeKind::Studio));
assert_eq!(ThemeKind::from_name("Pro (Medium Gray)"), Some(ThemeKind::ProMedium));
let m = Tokens::for_kind(ThemeKind::ProMedium);
assert!(m.pro && m.dark() && m.kind == ThemeKind::ProMedium && m.card == Color32::from_rgb(83, 83, 83));
assert_eq!(ThemeKind::from_name("neon"), None);
}

View File

@ -172,7 +172,7 @@ fn snap_slot<'a>(s: &'a mut SnapTo, key: &str) -> Option<&'a mut bool> {
/// Window › <panel>: (panel, dock tab) for panels that live as tabs of a dock card.
fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> {
let pro = app.ui.theme == crate::theme::ThemeKind::Pro;
let pro = matches!(app.ui.theme, crate::theme::ThemeKind::Pro | crate::theme::ThemeKind::ProMedium);
Some(match id {
"window.panel.info" => ("navigator", 2),
"window.panel.histogram" => ("navigator", 1),