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:
parent
ceb2667ac4
commit
6f5aba08d3
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue