diff --git a/Cargo.lock b/Cargo.lock index 284cce66..88ad8f24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index c29fc8e8..dfd4c250 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -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 { - 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 { 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 { + 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 { + 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 = si.iter().zip(&sp).map(|(a, b)| a * b).collect(); + let ii: Vec = 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 = (0..sw * sh).map(|k| (mip[k] - mi[k] * mp[k]) / (mii[k] - mi[k] * mi[k] + eps)).collect(); + let b: Vec = (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 { 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 = 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> = (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; diff --git a/crates/algo/src/hdr.rs b/crates/algo/src/hdr.rs index 25600f9c..b05e58ce 100644 --- a/crates/algo/src/hdr.rs +++ b/crates/algo/src/hdr.rs @@ -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 = (0..w * h) - .map(|i| { + let score: Vec = 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 = (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 = px.iter().filter(|q| q[3] > 0.0).map(|q| luma(*q).max(1e-9)).collect(); + let mut l: Vec = 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 } diff --git a/crates/algo/src/render2.rs b/crates/algo/src/render2.rs index 9e906c9f..65371f09 100644 --- a/crates/algo/src/render2.rs +++ b/crates/algo/src/render2.rs @@ -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, 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 { 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) }; diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 6946c16e..a7def8c3 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -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 = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect(); let mut bins: Vec> = 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)]; diff --git a/crates/algo/src/wideangle.rs b/crates/algo/src/wideangle.rs index a15b9341..10e36da9 100644 --- a/crates/algo/src/wideangle.rs +++ b/crates/algo/src/wideangle.rs @@ -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()); + } + } +} diff --git a/crates/engine/src/automate_cmds.rs b/crates/engine/src/automate_cmds.rs index 7fe62fda..00af2646 100644 --- a/crates/engine/src/automate_cmds.rs +++ b/crates/engine/src/automate_cmds.rs @@ -169,7 +169,7 @@ fn script_events(s: &mut Session, p: &Value) -> Result { 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 { 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); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 5e49ac3a..1107e8f8 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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); diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 846876a3..32d43e0a 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -137,7 +137,8 @@ pub fn print_pdf(page: &PrintPage) -> Vec { 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 { } }; 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 = 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()]); diff --git a/crates/engine/src/slice_cmds.rs b/crates/engine/src/slice_cmds.rs index 5ba6d40c..52c879c8 100644 --- a/crates/engine/src/slice_cmds.rs +++ b/crates/engine/src/slice_cmds.rs @@ -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 { - 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 { fn divide(s: &mut Session, p: &Value) -> Result { 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")); } diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index f95ca4b2..f58f7afe 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -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") { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f0e1ab25..78f2d3aa 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/examples/interactive_bench.rs b/crates/ui-egui/examples/interactive_bench.rs index a4d3044a..9dcc0c41 100644 --- a/crates/ui-egui/examples/interactive_bench.rs +++ b/crates/ui-egui/examples/interactive_bench.rs @@ -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, reps: usize, + /// `--only text`: time only rows whose name contains it (others run once, untimed, for the + /// state later rows need). + only: Option, 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 = (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 = 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; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6891f264..221af085 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index 366a1b3f..ab9563d8 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 56f29a90..bc750595 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 931ae1f3..e04de4d5 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -212,6 +212,8 @@ fn variants(p: &Value) -> Vec { out } +/// The flattened preview image: pixels, width, height, full-size / proxy pixel ratio. +type WebProxy = (Arc>, u32, u32, f64); type WebCache = (String, Arc, Option<(usize, Option, &'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>, u32, u32, f64)> = ui.data(|d| d.get_temp(wkey)); + let proxy: Option = 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 { @@ -498,12 +501,15 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map= 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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6341c6d6..004b6def 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a4d887d5..a579da09 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 677f88e4..a2dae000 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -64,6 +64,7 @@ pub fn pasteboard_color(app: &PhotocraftApp) -> Option { 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, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index af052998..5fc32317 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, } } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d7eadb5c..06390fdd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 { 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); } diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index d5f5693a..f80ed875 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -172,7 +172,7 @@ fn snap_slot<'a>(s: &'a mut SnapTo, key: &str) -> Option<&'a mut bool> { /// Window › : (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),