* Camera Raw: interactive histogram, clipping warnings and scopes (part of #215) - RGB histogram of the preview proxy (algo::histogram), drawn as RGB ribbons; Before/After, five draggable tonal zones, shadow/highlight clipping warnings (U/O) and Alt-drag channel diagnostics. - Pixel hover readout, up to nine RGB/Lab colour samplers (S), an ICC-managed vectorscope (algo::vectorscope) with skin-tone line, and a floating scope panel. - Settings regrouped as Light, Color, Effects, Curve, Color Mixer, Color Grading and Detail; Camera Raw labels translated through the cameraRaw context. - One point-curve editor (point_curve.rs) shared by Camera Raw, the Curves dialog and adjustment-layer Properties; fixes Camera Raw curve points not dragging. - Double-clicking a Camera Raw smart filter reopens the Camera Raw dialog with its settings and updates that filter, instead of the generic parameter form. - New point curves are validated (2-16 points, increasing inputs); stored Smart Filters keep loading. - camera_raw_bench for P31/P36 (not enforced until a baseline includes them). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Histogram tests: as_chunks for triangle indices (clippy 1.99) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Regenerate docs/scorecard.md after rebasing onto main Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * i18n tests: close the Mixer Brush test before the Camera Raw one (rebase) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
a8afec4455
commit
cc5de414dc
|
|
@ -3726,6 +3726,7 @@ dependencies = [
|
|||
"egui_kittest",
|
||||
"log",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
"photocraft-compose",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,29 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most points a Camera Raw point curve accepts from commands and the control channel.
|
||||
pub const MAX_CURVE_POINTS: usize = 16;
|
||||
|
||||
/// Checks a point curve from untrusted input: empty (linear) or 2..=16 finite points in 0..=255
|
||||
/// with inputs increasing by at least one level. Deserialization stays lenient on purpose:
|
||||
/// earlier editors saved curves this rejects, and [`curve_lut`] already sorts and de-duplicates
|
||||
/// them, so stored Smart Filters keep rendering.
|
||||
pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
|
||||
if points.len() == 1 {
|
||||
return Err("a nonempty curve needs at least two points".into());
|
||||
}
|
||||
if points.len() > MAX_CURVE_POINTS {
|
||||
return Err(format!("at most {MAX_CURVE_POINTS} curve points"));
|
||||
}
|
||||
if !points.iter().flatten().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) {
|
||||
return Err("curve coordinates must be finite and in 0..=255".into());
|
||||
}
|
||||
if points.windows(2).any(|w| w[1][0] - w[0][0] < 1.0) {
|
||||
return Err("curve inputs must increase by at least one level".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
|
||||
|
||||
/// One colour grading wheel.
|
||||
|
|
@ -437,6 +460,33 @@ pub fn band_weights(h: f32) -> [f32; 8] {
|
|||
}
|
||||
|
||||
impl CameraRaw {
|
||||
/// Rejects malformed point curves in new settings (see [`validate_curve`]).
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
for (name, curve) in [
|
||||
("pointCurve", &self.point_curve),
|
||||
("pointCurveRed", &self.point_curve_red),
|
||||
("pointCurveGreen", &self.point_curve_green),
|
||||
("pointCurveBlue", &self.point_curve_blue),
|
||||
] {
|
||||
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// [`validate`](Self::validate) for an edit of `stored` settings: a curve left as stored is
|
||||
/// accepted, so editing another control of an older Smart Filter still works.
|
||||
pub fn validate_changes(&self, stored: &CameraRaw) -> Result<(), String> {
|
||||
for (name, curve, kept) in [
|
||||
("pointCurve", &self.point_curve, &stored.point_curve),
|
||||
("pointCurveRed", &self.point_curve_red, &stored.point_curve_red),
|
||||
("pointCurveGreen", &self.point_curve_green, &stored.point_curve_green),
|
||||
("pointCurveBlue", &self.point_curve_blue, &stored.point_curve_blue),
|
||||
] {
|
||||
if curve != kept {
|
||||
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn wb_neutral(&self) -> bool {
|
||||
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
|
||||
}
|
||||
|
|
@ -869,6 +919,44 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
|
||||
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
|
||||
for points in [
|
||||
serde_json::json!([[128, 128]]),
|
||||
serde_json::json!([[255, 255], [0, 0]]),
|
||||
serde_json::json!([[0, 0], [0, 255]]),
|
||||
serde_json::json!([[0, -1], [255, 255]]),
|
||||
serde_json::json!([[0, 0], [256, 255]]),
|
||||
serde_json::json!([[0, 0], [1e30, 255]]),
|
||||
serde_json::json!((0..17).map(|i| [i * 15, i * 15]).collect::<Vec<_>>()),
|
||||
] {
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
|
||||
assert!(p.validate().is_err(), "{field}: {points}");
|
||||
}
|
||||
for points in
|
||||
[serde_json::json!([]), serde_json::json!([[0, 0], [255, 255]]), serde_json::json!((0..16).map(|i| [i * 17, i * 17]).collect::<Vec<_>>())]
|
||||
{
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
|
||||
assert!(p.validate().is_ok(), "{field}: {points}");
|
||||
}
|
||||
}
|
||||
assert_eq!(serde_json::from_str::<CameraRaw>("{}").unwrap(), CameraRaw::default());
|
||||
// An older editor could stack two points on one input and exceed 16 points; such a
|
||||
// saved Smart Filter must still develop instead of silently disappearing.
|
||||
let mut legacy: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 12.0, i as f32 * 12.0]).collect();
|
||||
legacy.insert(5, [60.0, 200.0]);
|
||||
let p: CameraRaw = serde_json::from_value(serde_json::json!({"pointCurve": legacy})).unwrap();
|
||||
assert!(p.validate().is_err());
|
||||
let edited = CameraRaw { exposure: 1.0, ..p.clone() };
|
||||
assert!(edited.validate_changes(&p).is_ok(), "an unchanged stored curve stays editable");
|
||||
let broken = CameraRaw { point_curve: vec![[10.0, 10.0]], ..p.clone() };
|
||||
assert!(broken.validate_changes(&p).is_err(), "a changed curve is validated");
|
||||
let mut px = img(4, 4);
|
||||
develop(&mut px, 4, 4, &p, false);
|
||||
assert!(px.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
fn img(w: usize, h: usize) -> Vec<[f32; 4]> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
//! Bounded RGB histograms of straight, normalized float RGBA samples.
|
||||
//!
|
||||
//! No colour conversion is performed: bins describe the supplied sample domain, not an
|
||||
//! assumed sRGB/ICC display space. 256 bins are a display resolution, not an input bit depth.
|
||||
pub const BINS: usize = 256;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RgbHistogram {
|
||||
pub channels: [[u64; BINS]; 3],
|
||||
pub samples: u64,
|
||||
pub transparent: u64,
|
||||
pub invalid: u64,
|
||||
/// Finite samples outside 0..=1 are included in the edge bins and counted separately.
|
||||
pub underflow: [u64; 3],
|
||||
pub overflow: [u64; 3],
|
||||
/// Exact endpoints, before display-bin rounding. A near-white value is not clipped.
|
||||
pub shadows: [u64; 3],
|
||||
pub highlights: [u64; 3],
|
||||
}
|
||||
|
||||
impl Default for RgbHistogram {
|
||||
fn default() -> Self {
|
||||
Self { channels: [[0; BINS]; 3], samples: 0, transparent: 0, invalid: 0, underflow: [0; 3], overflow: [0; 3], shadows: [0; 3], highlights: [0; 3] }
|
||||
}
|
||||
}
|
||||
|
||||
impl RgbHistogram {
|
||||
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
|
||||
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
|
||||
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
|
||||
let mut h = Self::default();
|
||||
for pixel in pixels {
|
||||
if !pixel[3].is_finite() {
|
||||
h.invalid = h.invalid.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if pixel[3] <= 0.0 {
|
||||
h.transparent = h.transparent.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
if !pixel.iter().all(|v| v.is_finite()) {
|
||||
h.invalid = h.invalid.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
h.samples = h.samples.saturating_add(1);
|
||||
let (low, high) = clipping(*pixel);
|
||||
for (c, ((((bins, below), above), shadows), highlights)) in
|
||||
h.channels.iter_mut().zip(&mut h.underflow).zip(&mut h.overflow).zip(&mut h.shadows).zip(&mut h.highlights).enumerate()
|
||||
{
|
||||
let Some(value) = pixel.get(c) else { continue };
|
||||
if low & (1 << c) != 0 {
|
||||
*shadows = shadows.saturating_add(1);
|
||||
}
|
||||
if high & (1 << c) != 0 {
|
||||
*highlights = highlights.saturating_add(1);
|
||||
}
|
||||
if *value < 0.0 {
|
||||
*below = below.saturating_add(1);
|
||||
} else if *value > 1.0 {
|
||||
*above = above.saturating_add(1);
|
||||
}
|
||||
// Match the preview encoder's nearest-bin rule, before it loses float precision.
|
||||
let bin = (value.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize;
|
||||
if let Some(count) = bins.get_mut(bin) {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
}
|
||||
|
||||
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
|
||||
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
|
||||
/// Transparent and non-finite pixels clip nothing.
|
||||
pub fn clipping(p: [f32; 4]) -> (u8, u8) {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
return (0, 0);
|
||||
}
|
||||
let mut shadows = 0;
|
||||
let mut highlights = 0;
|
||||
for (c, v) in p.iter().take(3).enumerate() {
|
||||
if *v <= 0.0 {
|
||||
shadows |= 1 << c;
|
||||
}
|
||||
if *v >= 1.0 {
|
||||
highlights |= 1 << c;
|
||||
}
|
||||
}
|
||||
(shadows, highlights)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_bins_and_channel_totals() {
|
||||
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!(h.samples, 256);
|
||||
assert!(h.channels[0].iter().all(|&v| v == 1));
|
||||
assert!(h.channels[1].iter().all(|&v| v == 1));
|
||||
assert_eq!(h.channels[2][128], 256);
|
||||
for channel in h.channels {
|
||||
assert_eq!(channel.iter().sum::<u64>(), h.samples);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_transparent_invalid_and_hdr_are_explicit() {
|
||||
assert_eq!(RgbHistogram::from_rgba(&[]), RgbHistogram::default());
|
||||
let pixels = [[0.7, 0.9, 0.4, 0.0], [f32::NAN, 0.0, 0.0, 1.0], [0.0, f32::INFINITY, 0.0, 1.0], [0.0, 0.0, 0.0, f32::NAN], [-2.0, 4.0, 0.5, 1.0]];
|
||||
let original = pixels;
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!((h.samples, h.transparent, h.invalid), (1, 1, 3));
|
||||
assert_eq!(h.underflow, [1, 0, 0]);
|
||||
assert_eq!(h.overflow, [0, 1, 0]);
|
||||
assert_eq!((h.channels[0][0], h.channels[1][255], h.channels[2][128]), (1, 1, 1));
|
||||
assert_eq!(pixels[4], original[4]);
|
||||
assert!(pixels[1][0].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_indicators_use_exact_samples_not_edge_bin_rounding() {
|
||||
let h = RgbHistogram::from_rgba(&[[0.0001, 0.9999, 0.5, 1.0], [-0.2, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0]]);
|
||||
assert_eq!(h.shadows, [1, 0, 1]);
|
||||
assert_eq!(h.highlights, [0, 1, 0]);
|
||||
assert_eq!(h.channels[0][0], 2);
|
||||
assert_eq!(h.channels[1][255], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_and_gray_at_all_depths_use_the_same_float_contract() {
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
|
||||
for sample in SampleType::ALL {
|
||||
let mut surface = Surface::new(PixelFormat::new(mode, sample, true));
|
||||
for (x, v) in [0.0, 0.25, 0.75, 1.0].into_iter().enumerate() {
|
||||
let px = if mode == ColorMode::Rgb { vec![v, v, v, 1.0] } else { vec![v, 1.0] };
|
||||
surface.write_pixel(x as i32, 0, &px);
|
||||
}
|
||||
let mut pixels = [[0.0; 4]; 4];
|
||||
surface.read_rgba_into(Rect::new(0, 0, 4, 1), &mut pixels);
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert_eq!(h.samples, 4, "{mode:?}/{sample:?}");
|
||||
for c in &h.channels {
|
||||
for bin in [0, 64, 191, 255] {
|
||||
assert_eq!(c[bin], 1, "{mode:?}/{sample:?}, bin {bin}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_clipping_endpoints_not_quantized_neighbours() {
|
||||
assert_eq!(clipping([0.0001, 0.9999, 0.5, 1.0]), (0, 0));
|
||||
assert_eq!(clipping([-0.1, 1.0, 0.0, 1.0]), (5, 2));
|
||||
assert_eq!(clipping([0.0, 1.0, 0.0, 0.0]), (0, 0));
|
||||
assert_eq!(clipping([0.0, f32::INFINITY, 0.0, 1.0]), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposure_of_an_eight_bit_gradient_reproduces_the_quantization_comb() {
|
||||
let mut pixels: Vec<_> = (0..256).map(|i| [i as f32 / 255.0, i as f32 / 255.0, i as f32 / 255.0, 1.0]).collect();
|
||||
assert!(RgbHistogram::from_rgba(&pixels).channels[0].iter().all(|&v| v == 1));
|
||||
crate::camera_raw::develop(&mut pixels, 256, 1, &crate::camera_raw::CameraRaw { exposure: 0.5, ..Default::default() }, true);
|
||||
let h = RgbHistogram::from_rgba(&pixels);
|
||||
assert!(h.channels[0][10..230].iter().filter(|&&v| v == 0).count() > 10);
|
||||
assert_eq!(h.samples, 256);
|
||||
assert!(h.overflow[0] > 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ pub mod features;
|
|||
mod fxutil;
|
||||
mod gallery;
|
||||
pub mod hdr;
|
||||
pub mod histogram;
|
||||
mod image;
|
||||
pub mod inpaint;
|
||||
pub mod lens;
|
||||
|
|
@ -68,6 +69,7 @@ pub mod tone;
|
|||
pub mod transform;
|
||||
pub mod trap;
|
||||
pub mod vanishing;
|
||||
pub mod vectorscope;
|
||||
mod video;
|
||||
pub mod warp;
|
||||
pub mod wideangle;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
//! Bounded hue/saturation distribution. Input colours must already be in the chosen RGB space.
|
||||
//! Display orientation and colour management belong to callers, not to this analysis.
|
||||
pub const HUES: usize = 128;
|
||||
pub const SATURATIONS: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Vectorscope {
|
||||
pub counts: Vec<u32>,
|
||||
pub samples: u64,
|
||||
}
|
||||
|
||||
impl Default for Vectorscope {
|
||||
fn default() -> Self {
|
||||
Self { counts: vec![0; HUES * SATURATIONS], samples: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Vectorscope {
|
||||
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
|
||||
let mut out = Self::default();
|
||||
for p in pixels {
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let rgb = [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0), p[2].clamp(0.0, 1.0)];
|
||||
let (h, s) = hue_saturation(rgb);
|
||||
let hue = ((h * HUES as f32) as usize).min(HUES - 1);
|
||||
let saturation = ((s * (SATURATIONS - 1) as f32).round() as usize).min(SATURATIONS - 1);
|
||||
if let Some(n) = out.counts.get_mut(hue * SATURATIONS + saturation) {
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
out.samples = out.samples.saturating_add(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue as turns and saturation in 0..1; achromatic pixels have zero saturation.
|
||||
pub fn hue_saturation([r, g, b]: [f32; 3]) -> (f32, f32) {
|
||||
let high = r.max(g).max(b);
|
||||
let low = r.min(g).min(b);
|
||||
let delta = high - low;
|
||||
if delta <= f32::EPSILON || high <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let h = if high == r {
|
||||
(g - b) / delta
|
||||
} else if high == g {
|
||||
2.0 + (b - r) / delta
|
||||
} else {
|
||||
4.0 + (r - g) / delta
|
||||
};
|
||||
((h / 6.0).rem_euclid(1.0), delta / high)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rgb_primary_positions_neutrals_and_transparency() {
|
||||
for (rgb, expected) in [([1.0, 0.0, 0.0], 0.0), ([1.0, 1.0, 0.0], 1.0 / 6.0), ([0.0, 1.0, 0.0], 1.0 / 3.0), ([0.0, 0.0, 1.0], 2.0 / 3.0)] {
|
||||
let (h, s) = hue_saturation(rgb);
|
||||
assert!((h - expected).abs() < 1e-6);
|
||||
assert_eq!(s, 1.0);
|
||||
}
|
||||
let v = Vectorscope::from_rgba(&[[0.5, 0.5, 0.5, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]]);
|
||||
assert_eq!(v.samples, 2);
|
||||
assert_eq!(v.counts[0], 1);
|
||||
assert_eq!(v.counts[SATURATIONS - 1], 1);
|
||||
assert_eq!(v.counts.iter().map(|&c| u64::from(c)).sum::<u64>(), v.samples);
|
||||
}
|
||||
}
|
||||
|
|
@ -346,6 +346,8 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cr = raw_params(RAW, p)?;
|
||||
// New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`.
|
||||
cr.validate().map_err(|e| bad(RAW, e))?;
|
||||
let t0 = Stopwatch::start();
|
||||
let id = run_filter(s, RAW, "Camera Raw Filter", p.clone(), false, &|surf, canvas| camera_raw_surface(surf, canvas.union(&surf.content_bounds()), &cr))?;
|
||||
Ok(json!({"layer": id.0, "identity": cr.is_identity(), "ms": t0.ms()}))
|
||||
|
|
@ -529,6 +531,12 @@ mod tests {
|
|||
assert!(active_px(&s, 10, 40)[1] < inside_before[1]);
|
||||
assert_eq!(active_px(&s, 100, 40), outside_before);
|
||||
assert!(s.execute(RAW, json!({"exposure": "bright"})).is_err());
|
||||
let legacy_curve = json!([[0, 0], [60, 40], [60, 200], [255, 255]]);
|
||||
assert!(s.execute(RAW, json!({"pointCurve": legacy_curve})).is_err(), "new curves are validated");
|
||||
// A curve an older editor saved must still re-apply as a Smart Filter.
|
||||
let surf = s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
let stored = apply_to_surface(RAW, &json!({"pointCurve": legacy_curve, "exposure": 1.0}), &surf, Rect::new(0, 0, 120, 80));
|
||||
assert!(stored.is_some(), "a stored legacy curve must not drop the whole filter");
|
||||
// Smart filter.
|
||||
let mut s = session(8);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
|
|
|
|||
|
|
@ -304,6 +304,16 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
|
|||
Ok(Some(apply_smart_filters(&placed, sm, doc.bounds())))
|
||||
}
|
||||
|
||||
/// The smart object's pixels below smart filter `index` (the input that filter edits): the placed
|
||||
/// source and the filters under it, without the filter mask. `Ok(None)` when the source is
|
||||
/// unavailable.
|
||||
pub fn render_below_filter(doc: &Document, sm: &SmartObject, index: usize) -> Result<Option<Surface>> {
|
||||
let mut below = sm.clone();
|
||||
below.smart_filters.truncate(index);
|
||||
below.filter_mask = None;
|
||||
render(doc, &below)
|
||||
}
|
||||
|
||||
/// Re-renders smart layer `l` (which lives in `doc`) in place. Returns false if its source is
|
||||
/// unavailable (the cache is then left alone).
|
||||
pub fn refresh_layer(doc: &Document, l: &mut Layer) -> Result<bool> {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ workspace = true
|
|||
[dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
photocraft-color = { workspace = true }
|
||||
photocraft-cms = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
photocraft-doc = { workspace = true }
|
||||
photocraft-compose = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! Real Camera Raw proxy-update timing on a 36 MP document (CPU preparation, not GPU presentation).
|
||||
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let quick = args.iter().any(|a| a == "--quick");
|
||||
let (width, height, reps) = if quick { (6000, 4000, 12) } else { (7360, 4912, 40) };
|
||||
let ctx = egui::Context::default();
|
||||
PhotocraftApp::setup_context(&ctx, Default::default());
|
||||
let _ = ctx.run_ui(Default::default(), |_| {});
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
// A full-resolution synthetic image with dense hue/saturation coverage and exact clipped
|
||||
// endpoints. Setup/import is outside the interactive measurements.
|
||||
let mut bytes = Vec::with_capacity(width * height * 3);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
bytes.extend([(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8]);
|
||||
}
|
||||
}
|
||||
let image =
|
||||
photocraft_codecs::Image::from_raw(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, bytes)?;
|
||||
let encoded = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default())?;
|
||||
app.session.open_document(photocraft_io::import("scope.png", &encoded)?.document, None);
|
||||
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).ok_or("no dialog entry point")??;
|
||||
let mut samples = Vec::new();
|
||||
let mut histogram_samples = Vec::new();
|
||||
for i in 0..reps + 3 {
|
||||
let r = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": if i % 2 == 0 { 0.25 } else { 0.75 }}}}))
|
||||
.ok_or("no dialog entry point")??;
|
||||
if i >= 3 {
|
||||
samples.push(r.get("renderMs").and_then(Value::as_f64).ok_or("no render timing")?);
|
||||
if let Some(ms) = r.get("histogramMs").and_then(Value::as_f64) {
|
||||
histogram_samples.push(ms);
|
||||
}
|
||||
}
|
||||
// Drain texture deltas just as a real frame does, rather than retaining every upload.
|
||||
let _ = ctx.run_ui(Default::default(), |_| {});
|
||||
}
|
||||
// Measure the complete native dialog CPU path with every SDR scope feature enabled:
|
||||
// filter preparation + ICC vectorscope + clipping texture + nine Lab probes + pixel-hover overlays + UI mesh.
|
||||
// A full-coverage selection exercises the mask blend and selected-region scope without
|
||||
// reducing the amount of analysed colour data. Selection setup is outside the timer.
|
||||
app.run("select.rect", json!({"x":0,"y":0,"width":width,"height":height}))?;
|
||||
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).ok_or("no dialog entry point")??;
|
||||
camera_raw_ui::menu(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"filter.cameraRaw",
|
||||
&json!({"ui":{"scope":{
|
||||
"vectorscope":true,"selectedRegion":true,"lab":true,"shadows":true,"highlights":true,
|
||||
"samplers":(0..9).map(|i| [i as f32/9.0,0.5]).collect::<Vec<_>>()
|
||||
}}}),
|
||||
)
|
||||
.ok_or("no dialog entry point")??;
|
||||
let mut interactive_samples = Vec::new();
|
||||
let mut scope_samples = Vec::new();
|
||||
let mut idle_samples = Vec::new();
|
||||
let input = || egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))),
|
||||
events: vec![egui::Event::PointerMoved(egui::pos2(430.0, 400.0))],
|
||||
..Default::default()
|
||||
};
|
||||
for i in 0..reps + 3 {
|
||||
let start = std::time::Instant::now();
|
||||
let r = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui":{"set":{"exposure":if i%2==0 {0.25}else{0.75}},"scope":{}}}))
|
||||
.ok_or("no dialog entry point")??;
|
||||
let output = ctx.run_ui(input(), |ui| camera_raw_ui::show(&mut app, ui.ctx()));
|
||||
std::hint::black_box(ctx.tessellate(output.shapes, output.pixels_per_point));
|
||||
if i >= 3 {
|
||||
interactive_samples.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
scope_samples.push(r.get("scopeMs").and_then(Value::as_f64).ok_or("no scope timing")?);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let output = ctx.run_ui(input(), |ui| camera_raw_ui::show(&mut app, ui.ctx()));
|
||||
std::hint::black_box(ctx.tessellate(output.shapes, output.pixels_per_point));
|
||||
if i >= 3 {
|
||||
idle_samples.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
if i == 3 {
|
||||
let state = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui":{}})).ok_or("no dialog entry point")??;
|
||||
if state["hoverSample"]["hueSaturation"].is_null() {
|
||||
return Err("benchmark must render the RGB badge and vectorscope pixel target".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dense RGB curves exercise the shared widget, including its outlines and egui CPU
|
||||
// tessellation. This is measured separately from pixel processing and GPU presentation.
|
||||
let pixels: Vec<_> = (0..8192)
|
||||
.map(|i| {
|
||||
let x = (i % 128) as f32;
|
||||
let y = (i / 128) as f32;
|
||||
[0.5 + 0.4 * (x * 0.07).sin(), 0.5 + 0.4 * (y * 0.13).cos(), (x + y) / 192.0, 1.0]
|
||||
})
|
||||
.collect();
|
||||
let histogram = photocraft_algo::histogram::RgbHistogram::from_rgba(&pixels);
|
||||
let mut draw_samples = Vec::new();
|
||||
for i in 0..reps + 3 {
|
||||
let start = std::time::Instant::now();
|
||||
let output = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(330.0, 160.0))), ..Default::default() },
|
||||
|ui| {
|
||||
photocraft_ui_egui::widgets::rgb_histogram(ui, &histogram, 110.0);
|
||||
},
|
||||
);
|
||||
let _meshes = ctx.tessellate(output.shapes, output.pixels_per_point);
|
||||
if i >= 3 {
|
||||
draw_samples.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
}
|
||||
let mut sorted = samples.clone();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
let p50 = sorted.get(sorted.len() / 2).copied().unwrap_or(0.0);
|
||||
let p95 = sorted.get(sorted.len().saturating_sub(1) * 95 / 100).copied().unwrap_or(0.0);
|
||||
let max = sorted.last().copied().unwrap_or(0.0);
|
||||
let report = json!({"width": width, "height": height, "rows": [{"name": "exposure proxy update", "median_ms": p50, "p95_ms": p95, "max_ms": max, "samples_ms": samples}, {"name": "RGB histogram", "samples_ms": histogram_samples}, {"name": "RGB histogram paint + tessellation", "samples_ms": draw_samples}, {"name":"interactive scope update", "samples_ms":interactive_samples}, {"name":"vectorscope ICC analysis", "samples_ms":scope_samples}, {"name":"cached scope frame", "samples_ms":idle_samples}]});
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
if let Some(path) = args.iter().position(|a| a == "--json").and_then(|i| args.get(i + 1)) {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report)?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -168,4 +168,41 @@ mod tests {
|
|||
assert!(std::sync::Arc::ptr_eq(&before, &after));
|
||||
assert!(harness.state().ui.dialogs.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn curves_pointer_drag_previews_until_confirm_and_cancel_preserves_document() {
|
||||
for confirm in [false, true] {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
|
||||
let original = h.state().session.active().unwrap().doc.clone();
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let id = open(h.state_mut(), "image.adjustments.curves").unwrap();
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!([[0, 0], [128, 128], [255, 255]]));
|
||||
h.run_steps(4);
|
||||
let graph = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new(("adjust-dialog", "curves")).with("curves-graph"))).unwrap();
|
||||
let a = egui::pos2(graph.left() + 128.0 / 255.0 * graph.width(), graph.bottom() - 128.0 / 255.0 * graph.height());
|
||||
h.hover_at(a);
|
||||
h.run_steps(1);
|
||||
h.drag_at(a);
|
||||
h.run_steps(1);
|
||||
h.hover_at(a + egui::vec2(40.0, -32.0));
|
||||
h.run_steps(1);
|
||||
h.drop_at(a + egui::vec2(40.0, -32.0));
|
||||
h.run_steps(3);
|
||||
let points = &h.state().ui.dialogs[0].fields["points"];
|
||||
assert_eq!(points.as_array().unwrap().len(), 3);
|
||||
assert!(points[1][0].as_f64().unwrap() > 150.0 && points[1][1].as_f64().unwrap() > 150.0, "{points}");
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps);
|
||||
if confirm {
|
||||
crate::dialogs::confirm(h.state_mut(), id).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
|
||||
} else {
|
||||
h.state_mut().ui.close_dialog(id);
|
||||
h.run_steps(2);
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@
|
|||
//! and `commit` when a gesture ends (the Properties host then runs one `layer.setAdjustment`; a
|
||||
//! dialog commits on OK). Selective Color and Color Lookup keep their editors in `adjust_ui`.
|
||||
|
||||
use crate::point_curve as curve_edit;
|
||||
use crate::state::CurvesEditorState as CurveUi;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Key, Modifiers, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use egui::{Color32, Pos2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use photocraft_doc::adjust::ToneSpace;
|
||||
use photocraft_doc::{Adjustment, LayerId};
|
||||
use photocraft_engine::adjust_params::{self, HUE_RANGES, PHOTO_FILTERS};
|
||||
|
|
@ -301,75 +303,6 @@ fn gradient_bar(p: &egui::Painter, r: Rect, from: Color32, to: Color32, vertical
|
|||
// ---------------------------------------------------------------------------------------------
|
||||
// Curves
|
||||
|
||||
/// Curve point editing as pure functions on `[input, output]` points in 0..=255, sorted by input.
|
||||
pub mod curve_edit {
|
||||
use egui::Pos2;
|
||||
|
||||
/// Most points a curve can hold (Photoshop's limit).
|
||||
pub const MAX_POINTS: usize = 16;
|
||||
/// Points closer than this (input levels) are the same point.
|
||||
pub const MIN_GAP: f32 = 2.0;
|
||||
|
||||
/// Index of the point nearest `pos` (screen) within `radius` pixels.
|
||||
pub fn hit(pts: &[[f32; 2]], to_scr: impl Fn([f32; 2]) -> Pos2, pos: Pos2, radius: f32) -> Option<usize> {
|
||||
pts.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d <= radius).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Adds a point at `v` (kept sorted). A point already at that input is selected instead; a
|
||||
/// full curve takes no more points.
|
||||
pub fn insert(pts: &mut Vec<[f32; 2]>, v: [f32; 2]) -> Option<usize> {
|
||||
let v = [v[0].clamp(0.0, 255.0), v[1].clamp(0.0, 255.0)];
|
||||
if let Some(i) = pts.iter().position(|q| (q[0] - v[0]).abs() < MIN_GAP) {
|
||||
return Some(i);
|
||||
}
|
||||
if pts.len() >= MAX_POINTS {
|
||||
return None;
|
||||
}
|
||||
let i = pts.iter().position(|q| q[0] > v[0]).unwrap_or(pts.len());
|
||||
pts.insert(i, v);
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Endpoints stay, and a curve keeps at least two points.
|
||||
pub fn can_delete(pts: &[[f32; 2]], i: usize) -> bool {
|
||||
pts.len() > 2 && i > 0 && i + 1 < pts.len()
|
||||
}
|
||||
|
||||
pub fn delete(pts: &mut Vec<[f32; 2]>, i: usize) -> bool {
|
||||
if !can_delete(pts, i) {
|
||||
return false;
|
||||
}
|
||||
pts.remove(i);
|
||||
true
|
||||
}
|
||||
|
||||
/// Moves point `i` to `v`, kept strictly between its neighbours' inputs.
|
||||
pub fn move_to(pts: &mut [[f32; 2]], i: usize, v: [f32; 2]) -> bool {
|
||||
let n = pts.len();
|
||||
if i >= n {
|
||||
return false;
|
||||
}
|
||||
let lo = if i > 0 { pts[i - 1][0] + 1.0 } else { 0.0 };
|
||||
let hi = if i + 1 < n { pts[i + 1][0] - 1.0 } else { 255.0 };
|
||||
let new = [v[0].round().clamp(lo, hi.max(lo)), v[1].round().clamp(0.0, 255.0)];
|
||||
let changed = new != pts[i];
|
||||
pts[i] = new;
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct CurveUi {
|
||||
channel: usize,
|
||||
sel: Option<usize>,
|
||||
/// The point being dragged (None while dragged off the graph), the grab offset and whether
|
||||
/// the drag removed it.
|
||||
drag: Option<(Option<usize>, [f32; 2], bool)>,
|
||||
}
|
||||
|
||||
/// Dragging a point this far outside the graph removes it (it comes back if dragged back in).
|
||||
pub const DRAG_OFF: f32 = 12.0;
|
||||
const HIT_RADIUS: f32 = 9.0;
|
||||
const CURVE_HANDLE_SIZE: f32 = 7.0;
|
||||
const CURVE_GRAPH_PADDING: f32 = 5.0;
|
||||
|
||||
|
|
@ -417,91 +350,10 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
|||
let graph = curve_graph(full, side);
|
||||
ui.data_mut(|d| d.insert_temp(cx.mem.with("curves-graph"), graph));
|
||||
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
|
||||
let to_val =
|
||||
|s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)];
|
||||
let mut e = Edit::default();
|
||||
let mut changed = false;
|
||||
|
||||
// Press: hit-test where the button went down (not where a drag is first recognised).
|
||||
let press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed());
|
||||
if press && let Some(pos) = ui.input(|i| i.pointer.press_origin()) {
|
||||
resp.request_focus();
|
||||
let delete = ui.input(|i| i.modifiers.command);
|
||||
match curve_edit::hit(&pts, to_scr, pos, HIT_RADIUS) {
|
||||
Some(i) if delete => {
|
||||
if curve_edit::delete(&mut pts, i) {
|
||||
changed = true;
|
||||
e.commit = true;
|
||||
}
|
||||
st.sel = None;
|
||||
st.drag = None;
|
||||
}
|
||||
Some(i) => {
|
||||
st.sel = Some(i);
|
||||
let g = to_val(pos);
|
||||
st.drag = Some((Some(i), [pts[i][0] - g[0], pts[i][1] - g[1]], false));
|
||||
}
|
||||
None if graph.contains(pos) && !delete => {
|
||||
let before = pts.len();
|
||||
st.sel = curve_edit::insert(&mut pts, to_val(pos));
|
||||
changed = pts.len() != before;
|
||||
st.drag = st.sel.map(|i| (Some(i), [0.0, 0.0], false));
|
||||
}
|
||||
None => st.drag = None,
|
||||
}
|
||||
}
|
||||
// Drag: follow the pointer; off the graph the point is removed (back on, it returns).
|
||||
if resp.is_pointer_button_down_on()
|
||||
&& !press
|
||||
&& let (Some((index, off, removed)), Some(pos)) = (st.drag, ui.input(|i| i.pointer.interact_pos()))
|
||||
{
|
||||
let outside = !graph.expand(DRAG_OFF).contains(pos);
|
||||
let g = to_val(pos);
|
||||
let target = [g[0] + off[0], g[1] + off[1]];
|
||||
match index {
|
||||
Some(i) if outside && curve_edit::can_delete(&pts, i) => {
|
||||
pts.remove(i);
|
||||
st.drag = Some((None, off, true));
|
||||
st.sel = None;
|
||||
changed = true;
|
||||
}
|
||||
Some(i) => changed |= curve_edit::move_to(&mut pts, i, target),
|
||||
None if removed && !outside => {
|
||||
st.sel = curve_edit::insert(&mut pts, target);
|
||||
st.drag = Some((st.sel, off, true));
|
||||
changed |= st.sel.is_some();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if st.drag.is_some() && !resp.is_pointer_button_down_on() {
|
||||
st.drag = None;
|
||||
e.commit = true;
|
||||
}
|
||||
// Keys while the graph has focus: Delete/Backspace removes, arrows nudge (Shift: ×10).
|
||||
if resp.has_focus()
|
||||
&& let Some(i) = st.sel.filter(|i| *i < pts.len())
|
||||
{
|
||||
let del = ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, Key::Delete) || inp.consume_key(Modifiers::NONE, Key::Backspace));
|
||||
if del && curve_edit::delete(&mut pts, i) {
|
||||
st.sel = None;
|
||||
changed = true;
|
||||
e.commit = true;
|
||||
} else {
|
||||
let step = if ui.input(|inp| inp.modifiers.shift) { 10.0 } else { 1.0 };
|
||||
let mut d = [0.0f32; 2];
|
||||
for (k, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, 1.0), (Key::ArrowDown, 0.0, -1.0)] {
|
||||
if ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, k) || inp.consume_key(Modifiers::SHIFT, k)) {
|
||||
d = [d[0] + dx * step, d[1] + dy * step];
|
||||
}
|
||||
}
|
||||
let to = [pts[i][0] + d[0], pts[i][1] + d[1]];
|
||||
if d != [0.0, 0.0] && curve_edit::move_to(&mut pts, i, to) {
|
||||
changed = true;
|
||||
e.commit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let interaction = crate::point_curve::interact(ui, &resp, graph, &mut pts, &mut st.gesture);
|
||||
let mut changed = interaction.changed;
|
||||
e.commit = interaction.commit;
|
||||
|
||||
// Draw: histogram, quarter grid, baseline, gradient bars, other channels, the curve, points.
|
||||
let p = ui.painter_at(full.expand(2.0));
|
||||
|
|
@ -547,7 +399,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
|||
draw_curve(&pts, if ch == 0 { t.text } else { bar_color(&chan, &t) }, 1.5);
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));
|
||||
if Some(i) == st.sel {
|
||||
if Some(i) == st.gesture.selected {
|
||||
p.rect_filled(r, 0.0, t.text);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
|
||||
} else {
|
||||
|
|
@ -559,7 +411,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
|||
// Input / Output of the selected point.
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let i = st.sel.filter(|i| *i < pts.len());
|
||||
let i = st.gesture.selected.filter(|i| *i < pts.len());
|
||||
let (mut vi, mut vo) = i.map_or((0.0, 0.0), |i| (pts[i][0], pts[i][1]));
|
||||
label(ui, tl!("Input:"));
|
||||
let ri = ui.add_enabled_ui(i.is_some(), |ui| widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0)).inner;
|
||||
|
|
@ -1371,57 +1223,8 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::curve_edit::*;
|
||||
use super::*;
|
||||
|
||||
fn line() -> Vec<[f32; 2]> {
|
||||
vec![[0.0, 0.0], [255.0, 255.0]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_keeps_order_dedupes_and_respects_the_limit() {
|
||||
let mut p = line();
|
||||
assert_eq!(insert(&mut p, [128.0, 150.0]), Some(1));
|
||||
assert_eq!(insert(&mut p, [64.0, 40.0]), Some(1));
|
||||
assert_eq!(p, vec![[0.0, 0.0], [64.0, 40.0], [128.0, 150.0], [255.0, 255.0]]);
|
||||
// A click on an existing input selects that point instead of stacking a second one.
|
||||
assert_eq!(insert(&mut p, [129.0, 10.0]), Some(2));
|
||||
assert_eq!(p.len(), 4);
|
||||
let mut full: Vec<[f32; 2]> = (0..MAX_POINTS).map(|i| [i as f32 * 10.0, 0.0]).collect();
|
||||
assert_eq!(insert(&mut full, [255.0, 0.0]), None);
|
||||
assert_eq!(insert(&mut p, [-50.0, 900.0]), Some(0), "clamped onto the black point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoints_and_the_last_two_points_stay() {
|
||||
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [255.0, 255.0]];
|
||||
assert!(!delete(&mut p, 0) && !delete(&mut p, 2) && !delete(&mut p, 7));
|
||||
assert!(delete(&mut p, 1));
|
||||
assert_eq!(p, line());
|
||||
assert!(!delete(&mut p, 1) && !delete(&mut p, 0));
|
||||
assert_eq!(p.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_stay_between_neighbours() {
|
||||
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [200.0, 180.0], [255.0, 255.0]];
|
||||
assert!(move_to(&mut p, 1, [250.0, 300.0]));
|
||||
assert_eq!(p[1], [199.0, 255.0]);
|
||||
move_to(&mut p, 0, [150.0, 10.0]);
|
||||
assert_eq!(p[0], [150.0, 10.0]);
|
||||
move_to(&mut p, 3, [10.0, 5.0]);
|
||||
assert_eq!(p[3], [201.0, 5.0]);
|
||||
assert!(!move_to(&mut p, 9, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_the_nearest_point_in_radius() {
|
||||
let p = vec![[0.0, 0.0], [100.0, 100.0], [110.0, 110.0], [255.0, 255.0]];
|
||||
let to_scr = |q: [f32; 2]| Pos2::new(q[0], 255.0 - q[1]);
|
||||
assert_eq!(hit(&p, to_scr, Pos2::new(108.0, 146.0), 9.0), Some(2));
|
||||
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_endpoint_handles_fit_inside_the_allocated_area() {
|
||||
for side in [120.0, 200.0, 300.0] {
|
||||
|
|
|
|||
|
|
@ -209,3 +209,29 @@ fn curves_points_are_added_selected_and_deleted() {
|
|||
h.run_steps(3);
|
||||
assert_eq!(curve_of(&layer(&h).1).len(), 2, "endpoints can't be deleted");
|
||||
}
|
||||
|
||||
/// Capture and history are shared with Camera Raw, but Properties commits on release.
|
||||
#[test]
|
||||
fn curves_fast_release_commits_one_edit_and_undo_restores_the_curve() {
|
||||
let mut h = app_harness("curves", "rgb", 8);
|
||||
let (id, _) = layer(&h);
|
||||
let graph = curves_graph(&h, id);
|
||||
click(&mut h, graph.center());
|
||||
let (_, before) = layer(&h);
|
||||
let steps = h.state().session.active().unwrap().history.past_len();
|
||||
let at = graph.center() + vec2(4.0, 3.0);
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
// Only the release frame supplies the destination, as a very fast physical gesture can.
|
||||
h.drop_at(at + vec2(40.0, -32.0));
|
||||
h.run_steps(3);
|
||||
let after = curve_of(&layer(&h).1);
|
||||
assert_eq!(after.len(), 3);
|
||||
assert!(after[1][0] > 150.0 && after[1][1] > 150.0, "{after:?}");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1);
|
||||
assert!(h.state().live_adjust.is_none());
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&h).1, before);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,685 @@
|
|||
//! Camera Raw scope interaction. View state is in UiState; filter edits still commit through
|
||||
//! the existing filter.cameraRaw command. Image analysis is cached per completed proxy revision.
|
||||
use crate::{camera_raw_ui::CameraRawDialog, state::CameraRawScopeState, theme::Tokens, widgets};
|
||||
use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, TextureHandle, pos2, vec2};
|
||||
use photocraft_algo::{
|
||||
camera_raw::CameraRaw,
|
||||
histogram::clipping,
|
||||
vectorscope::{HUES, SATURATIONS, Vectorscope},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Warning combinations and Alt diagnostics are separate modes: both warnings must not
|
||||
/// accidentally select the shadow-channel diagnostic (the old numeric value was shared).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ClippingMode {
|
||||
None,
|
||||
Warnings(u8),
|
||||
ShadowChannels,
|
||||
HighlightChannels,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) enum ToneZone {
|
||||
Blacks,
|
||||
Shadows,
|
||||
Exposure,
|
||||
Highlights,
|
||||
Whites,
|
||||
}
|
||||
impl ToneZone {
|
||||
fn at(x: f32) -> Self {
|
||||
if x < 0.1 {
|
||||
Self::Blacks
|
||||
} else if x < 0.25 {
|
||||
Self::Shadows
|
||||
} else if x < 0.75 {
|
||||
Self::Exposure
|
||||
} else if x < 0.9 {
|
||||
Self::Highlights
|
||||
} else {
|
||||
Self::Whites
|
||||
}
|
||||
}
|
||||
/// English source label, shared with the Light slider.
|
||||
pub(crate) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Blacks => "Blacks",
|
||||
Self::Shadows => "Shadows",
|
||||
Self::Exposure => "Exposure",
|
||||
Self::Highlights => "Highlights",
|
||||
Self::Whites => "Whites",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", self.name())
|
||||
}
|
||||
fn range(self) -> [f32; 2] {
|
||||
match self {
|
||||
Self::Blacks => [0.0, 0.1],
|
||||
Self::Shadows => [0.1, 0.25],
|
||||
Self::Exposure => [0.25, 0.75],
|
||||
Self::Highlights => [0.75, 0.9],
|
||||
Self::Whites => [0.9, 1.0],
|
||||
}
|
||||
}
|
||||
fn value(self, p: &mut CameraRaw) -> &mut f32 {
|
||||
match self {
|
||||
Self::Blacks => &mut p.blacks,
|
||||
Self::Shadows => &mut p.shadows,
|
||||
Self::Exposure => &mut p.exposure,
|
||||
Self::Highlights => &mut p.highlights,
|
||||
Self::Whites => &mut p.whites,
|
||||
}
|
||||
}
|
||||
pub(crate) fn limit(self) -> f32 {
|
||||
if self == Self::Exposure { 5.0 } else { 100.0 }
|
||||
}
|
||||
fn set(self, d: &mut CameraRawDialog, value: f32) {
|
||||
let value = value.clamp(-self.limit(), self.limit());
|
||||
let target = self.value(&mut d.params);
|
||||
if *target != value {
|
||||
*target = value;
|
||||
d.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most colour samplers (probes) on the preview.
|
||||
pub(crate) const MAX_SAMPLERS: usize = 9;
|
||||
/// Vectorscope analysis samples at most this many proxy pixels.
|
||||
const SCOPE_SAMPLES: usize = 8192;
|
||||
|
||||
/// Scope caches and gesture state of one open dialog; never filter parameters or history.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ScopeView {
|
||||
/// Vectorscope keyed by (preview revision, Before, selected region).
|
||||
pub(crate) cache: Option<(u64, bool, bool, Vectorscope)>,
|
||||
pub(crate) ms: f64,
|
||||
pub(crate) revision: u64,
|
||||
pub(crate) overlay_tex: Option<TextureHandle>,
|
||||
pub(crate) overlay_key: Option<(u64, bool, ClippingMode, crate::theme::ThemeKind)>,
|
||||
pub(crate) floating_dragged: bool,
|
||||
pub(crate) geometry_requested: bool,
|
||||
pub(crate) sampler_drag: Option<usize>,
|
||||
pub(crate) pointer_sample: Option<[f32; 2]>,
|
||||
/// Histogram drag: zone, pointer start x and the value at the start.
|
||||
pub(crate) tone_drag: Option<(ToneZone, f32, f32)>,
|
||||
pub(crate) hovered_zone: Option<ToneZone>,
|
||||
/// Tone slider being Alt-dragged in the settings panel. The panel draws after the preview,
|
||||
/// so the preview's diagnostics follow it one frame later.
|
||||
pub(crate) alt_tone: Option<ToneZone>,
|
||||
pub(crate) preview_rect: Option<Rect>,
|
||||
pub(crate) rect: Option<Rect>,
|
||||
pub(crate) vectorscope_rect: Option<Rect>,
|
||||
}
|
||||
|
||||
/// One visible proxy pixel, in the histogram RGB domain and ICC-managed vectorscope domain.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct HoverSample {
|
||||
pub(crate) position: [f32; 2],
|
||||
pub(crate) rgb: [f32; 3],
|
||||
pub(crate) hue_saturation: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// Floating panel bounds `[left, top, right, bottom]` in points: finite and 200×150..=4096².
|
||||
fn valid_floating_rect(r: [f32; 4]) -> bool {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
r.iter().all(|x| x.is_finite()) && (200.0..=4096.0).contains(&w) && (150.0..=4096.0).contains(&h)
|
||||
}
|
||||
|
||||
impl CameraRawDialog {
|
||||
fn pixels(&self) -> &[[f32; 4]] {
|
||||
if self.show_before { &self.proxy } else { &self.processed }
|
||||
}
|
||||
fn pixel(&self, p: [f32; 2]) -> Option<[f32; 4]> {
|
||||
if !p.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) || self.pw == 0 || self.ph == 0 {
|
||||
return None;
|
||||
}
|
||||
let x = ((p[0] * self.pw as f32) as usize).min(self.pw - 1);
|
||||
let y = ((p[1] * self.ph as f32) as usize).min(self.ph - 1);
|
||||
let q = *self.pixels().get(y.checked_mul(self.pw)?.checked_add(x)?)?;
|
||||
(q[3] > 0.0 && q.iter().all(|v| v.is_finite())).then_some(q)
|
||||
}
|
||||
pub(crate) fn readout(&self, p: [f32; 2], lab: bool) -> Option<Value> {
|
||||
let q = self.pixel(p)?;
|
||||
let rgb = [q[0], q[1], q[2]];
|
||||
let values = if lab {
|
||||
let mut l = [0.0; 3];
|
||||
self.lab_transform.eval(&rgb, &mut l);
|
||||
[l[0] * 100.0, l[1] * 255.0 - 128.0, l[2] * 255.0 - 128.0]
|
||||
} else {
|
||||
rgb.map(|v| v * 255.0)
|
||||
};
|
||||
Some(json!({"position":p,"space":if lab {"lab"}else{"rgb"},"values":values,"alpha":q[3]}))
|
||||
}
|
||||
pub(crate) fn hover_sample(&self, vectorscope: bool) -> Option<HoverSample> {
|
||||
let position = self.scope.pointer_sample?;
|
||||
let q = self.pixel(position)?;
|
||||
let rgb = [q[0], q[1], q[2]];
|
||||
let hue_saturation = if vectorscope {
|
||||
let mut scope_rgb = [0.0; 3];
|
||||
self.scope_transform.eval(&rgb, &mut scope_rgb);
|
||||
if scope_rgb.iter().all(|x| x.is_finite()) {
|
||||
let (h, s) = photocraft_algo::vectorscope::hue_saturation(scope_rgb.map(|x| x.clamp(0.0, 1.0)));
|
||||
Some([h, s])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(HoverSample { position, rgb, hue_saturation })
|
||||
}
|
||||
pub(crate) fn ensure_scope(&mut self, selected: bool) {
|
||||
if self
|
||||
.scope
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_some_and(|(revision, before, masked, _)| *revision == self.preview_revision && *before == self.show_before && *masked == selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let start = crate::gpu_canvas::now_ms();
|
||||
// A fixed 2-D grid bounds CMS and analysis work independently of the image size. Rows
|
||||
// and columns step separately, so a stride can't alias with the row width into stripes.
|
||||
let step = ((self.pixels().len() as f64 / SCOPE_SAMPLES as f64).sqrt().ceil() as usize).max(1);
|
||||
let (pw, ph) = (self.pw, self.ph);
|
||||
let pixels: Vec<_> = (0..ph)
|
||||
.step_by(step)
|
||||
.flat_map(|y| (0..pw).step_by(step).map(move |x| y * pw + x))
|
||||
.filter_map(|i| self.pixels().get(i).map(|p| (i, p)))
|
||||
.filter(|(i, p)| {
|
||||
p[3] > 0.0 && p.iter().all(|v| v.is_finite()) && (!selected || self.selection.as_ref().and_then(|m| m.get(*i)).is_some_and(|v| *v > 0.0))
|
||||
})
|
||||
.map(|(_, p)| {
|
||||
let mut rgb = [0.0; 3];
|
||||
self.scope_transform.eval(&p[..3], &mut rgb);
|
||||
[rgb[0], rgb[1], rgb[2], p[3]]
|
||||
})
|
||||
.collect();
|
||||
let scope = Vectorscope::from_rgba(&pixels);
|
||||
self.scope.cache = Some((self.preview_revision, self.show_before, selected, scope));
|
||||
self.scope.ms = crate::gpu_canvas::now_ms() - start;
|
||||
self.scope.revision = self.scope.revision.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember dialog presentation through the existing preferences store, after a gesture ends.
|
||||
/// Probe positions are image-specific and never restored onto a different document.
|
||||
pub(crate) fn persist(app: &mut crate::PhotocraftApp, ctx: &egui::Context) {
|
||||
if ctx.input(|i| i.pointer.any_down()) {
|
||||
return;
|
||||
}
|
||||
let mut view = app.ui.camera_raw_scope.clone();
|
||||
view.samplers.clear();
|
||||
view.vectorscope = false;
|
||||
let Ok(value) = serde_json::to_value(view) else {
|
||||
return;
|
||||
};
|
||||
const KEY: &str = "filter.cameraRaw.scope";
|
||||
if app.session.prefs().dialogs.get(KEY) != Some(&value) {
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(KEY.into(), value));
|
||||
}
|
||||
}
|
||||
|
||||
fn point(v: &Value) -> Result<[f32; 2], String> {
|
||||
let p: [f32; 2] = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
|
||||
if !p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)) {
|
||||
return Err("sample coordinates must be normalized to 0..1".into());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// The same controller operations drive real gestures and automation. Validate the entire request
|
||||
/// before changing state; invalid coordinates, huge vectors, and unknown properties are errors.
|
||||
pub(crate) struct ScopeUpdate {
|
||||
view: CameraRawScopeState,
|
||||
sample: Option<Option<[f32; 2]>>,
|
||||
tone: Option<(ToneZone, f32)>,
|
||||
geometry_requested: bool,
|
||||
}
|
||||
|
||||
impl ScopeUpdate {
|
||||
pub(crate) fn apply(self, d: &mut CameraRawDialog, view: &mut CameraRawScopeState) {
|
||||
d.scope.geometry_requested |= self.geometry_requested;
|
||||
*view = self.view;
|
||||
if let Some(s) = self.sample {
|
||||
d.scope.pointer_sample = s;
|
||||
}
|
||||
if let Some((z, v)) = self.tone {
|
||||
z.set(d, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_control(params: &CameraRaw, has_selection: bool, view: &CameraRawScopeState, value: &Value) -> Result<ScopeUpdate, String> {
|
||||
let obj = value.as_object().ok_or("scope must be an object")?;
|
||||
if obj.get("samplers").is_some_and(|v| v.as_array().is_none_or(|a| a.len() > MAX_SAMPLERS)) {
|
||||
return Err(format!("at most {MAX_SAMPLERS} samplers"));
|
||||
}
|
||||
if obj.contains_key("samplers") && ["addSampler", "removeSampler", "clearSamplers"].iter().any(|k| obj.contains_key(*k)) {
|
||||
return Err("samplers replaces the list; don't combine it with addSampler, removeSampler or clearSamplers".into());
|
||||
}
|
||||
// Plain view fields first, then the sampler operations on the result (clear → remove → add).
|
||||
let mut base = serde_json::to_value(view).map_err(|e| e.to_string())?;
|
||||
let mut sample = None;
|
||||
let mut tone = None;
|
||||
for (key, v) in obj {
|
||||
match key.as_str() {
|
||||
"sample" => sample = Some(if v.is_null() { None } else { Some(point(v)?) }),
|
||||
"addSampler" | "removeSampler" | "clearSamplers" => {}
|
||||
"tone" => {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Change {
|
||||
zone: ToneZone,
|
||||
delta: Option<f32>,
|
||||
reset: Option<bool>,
|
||||
}
|
||||
let c: Change = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
|
||||
if c.delta.is_some_and(|x| !x.is_finite()) || (c.delta.is_none() && c.reset != Some(true)) {
|
||||
return Err("tone requires finite delta or reset=true".into());
|
||||
}
|
||||
let mut params = params.clone();
|
||||
tone = Some((c.zone, if c.reset == Some(true) { 0.0 } else { *c.zone.value(&mut params) + c.delta.unwrap_or(0.0) }));
|
||||
}
|
||||
_ => {
|
||||
if base.get(key).is_none() {
|
||||
return Err(format!("unknown scope property {key}"));
|
||||
}
|
||||
base[key] = v.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next: CameraRawScopeState = serde_json::from_value(base).map_err(|e| e.to_string())?;
|
||||
if let Some(v) = obj.get("clearSamplers") {
|
||||
if v.as_bool() != Some(true) {
|
||||
return Err("clearSamplers must be true".into());
|
||||
}
|
||||
next.samplers.clear();
|
||||
}
|
||||
if let Some(v) = obj.get("removeSampler") {
|
||||
let i = v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or("bad sampler index")?;
|
||||
if i >= next.samplers.len() {
|
||||
return Err("unknown sampler".into());
|
||||
}
|
||||
next.samplers.remove(i);
|
||||
}
|
||||
if let Some(v) = obj.get("addSampler") {
|
||||
if next.samplers.len() >= MAX_SAMPLERS {
|
||||
return Err(format!("at most {MAX_SAMPLERS} samplers"));
|
||||
}
|
||||
next.samplers.push(point(v)?);
|
||||
}
|
||||
for p in &next.samplers {
|
||||
point(&json!(p))?;
|
||||
}
|
||||
if next.floating_rect.is_some_and(|r| !valid_floating_rect(r)) {
|
||||
return Err("invalid floating scope rectangle".into());
|
||||
}
|
||||
if next.selected_region && !has_selection {
|
||||
return Err("no selected region in this document".into());
|
||||
}
|
||||
Ok(ScopeUpdate { view: next, sample, tone, geometry_requested: obj.contains_key("floatingRect") })
|
||||
}
|
||||
|
||||
fn readout_text(value: Option<Value>) -> String {
|
||||
let Some(v) = value else { return "—".into() };
|
||||
let a = v.get("values").and_then(Value::as_array);
|
||||
let get = |i| a.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if v.get("space").and_then(Value::as_str) == Some("lab") {
|
||||
format!("L {:.1} a {:.1} b {:.1}", get(0), get(1), get(2))
|
||||
} else {
|
||||
format!("R {:.0} G {:.0} B {:.0}", get(0), get(1), get(2))
|
||||
}
|
||||
}
|
||||
|
||||
fn warning(ui: &mut egui::Ui, rect: Rect, mask: u8, on: &mut bool, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let response = ui.interact(rect, ui.id().with(label), Sense::click()).on_hover_text(label);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label));
|
||||
let r = rect.shrink(3.0);
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![pos2(r.center().x, r.top()), r.left_bottom(), r.right_bottom()],
|
||||
if mask == 0 { t.histogram_background() } else { t.histogram_color(mask) },
|
||||
Stroke::new(1.0, if *on { t.text } else { t.text_faint }),
|
||||
));
|
||||
if response.clicked() {
|
||||
*on = !*on;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scope display options. The menu stays open while toggling, so options that depend on
|
||||
/// Show Vectorscope appear in place.
|
||||
fn context_menu(response: &egui::Response, has_selection: bool, view: &mut CameraRawScopeState) {
|
||||
egui::Popup::context_menu(response).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| context(ui, has_selection, view));
|
||||
}
|
||||
|
||||
fn context(ui: &mut egui::Ui, has_selection: bool, view: &mut CameraRawScopeState) {
|
||||
widgets::checkbox(ui, &mut view.lab, tl!("Show Lab Color Readouts"));
|
||||
widgets::checkbox(ui, &mut view.vectorscope, tl!("Show Vectorscope"));
|
||||
if view.vectorscope {
|
||||
if has_selection {
|
||||
widgets::checkbox(ui, &mut view.selected_region, tl!("Show Selected Region"));
|
||||
}
|
||||
widgets::checkbox(ui, &mut view.red_right, tl!("Show Red at 3 o'clock"));
|
||||
let mut skin = !view.hide_skin_line;
|
||||
widgets::checkbox(ui, &mut skin, tl!("Show Skin Tone Indicator"));
|
||||
view.hide_skin_line = !skin;
|
||||
}
|
||||
}
|
||||
|
||||
fn graph(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, height: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width().max(1.0), height), Sense::click_and_drag());
|
||||
d.scope.rect = Some(rect);
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), tl!("Tone Histogram")));
|
||||
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
|
||||
let plot = rect.shrink(4.0);
|
||||
crate::rgb_histogram::paint(ui.painter(), plot, d.histogram(), &t);
|
||||
let corner = 18.0;
|
||||
if let Some(sample) = d.hover_sample(false) {
|
||||
crate::rgb_histogram::paint_sample(ui.painter(), plot, sample.rgb, &t);
|
||||
let [r, g, b] = sample.rgb.map(|x| x * 255.0);
|
||||
let text = format!("R {r:.0} G {g:.0} B {b:.0}");
|
||||
let font = crate::theme::mono(11.0);
|
||||
let galley = ui.painter().layout_no_wrap(text, font, t.text);
|
||||
let width = (galley.size().x + 12.0).min((rect.width() - 2.0 * corner - 4.0).max(1.0));
|
||||
let badge = Rect::from_center_size(pos2(rect.center().x, rect.top() + 13.0), vec2(width, 20.0));
|
||||
ui.painter().rect_filled(badge, t.radius_sm, t.card.gamma_multiply(0.65));
|
||||
ui.painter().rect_stroke(badge, t.radius_sm, Stroke::new(0.7, t.field_border.gamma_multiply(0.55)), egui::StrokeKind::Inside);
|
||||
ui.painter().with_clip_rect(badge.shrink(2.0)).galley(badge.center() - galley.size() * 0.5, galley, t.text);
|
||||
}
|
||||
let sh = d.histogram().shadows.iter().enumerate().fold(0u8, |m, (c, n)| m | if *n > 0 { 1 << c } else { 0 });
|
||||
let hi = d.histogram().highlights.iter().enumerate().fold(0u8, |m, (c, n)| m | if *n > 0 { 1 << c } else { 0 });
|
||||
warning(ui, Rect::from_min_size(rect.min, vec2(corner, corner)), sh, &mut view.shadows, tl!("Shadow Clipping Warning (U)"));
|
||||
warning(
|
||||
ui,
|
||||
Rect::from_min_size(pos2(rect.right() - corner, rect.top()), vec2(corner, corner)),
|
||||
hi,
|
||||
&mut view.highlights,
|
||||
tl!("Highlight Clipping Warning (O)"),
|
||||
);
|
||||
let pos = response.interact_pointer_pos().or_else(|| response.hover_pos());
|
||||
d.scope.hovered_zone = pos.filter(|p| p.y > rect.top() + corner).map(|p| ToneZone::at(((p.x - plot.left()) / plot.width().max(1.0)).clamp(0.0, 1.0)));
|
||||
if let Some(z) = d.scope.tone_drag.map(|x| x.0).or(d.scope.hovered_zone) {
|
||||
let [lo, hi] = z.range();
|
||||
let region = Rect::from_min_max(pos2(plot.left() + lo * plot.width(), plot.top()), pos2(plot.left() + hi * plot.width(), plot.bottom()));
|
||||
ui.painter().rect_filled(region, 0.0, t.text.gamma_multiply(0.055));
|
||||
response.clone().on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
response.clone().on_hover_text(format!("{} {:.2}", z.label(), *z.value(&mut d.params)));
|
||||
if response.double_clicked() {
|
||||
z.set(d, 0.0);
|
||||
}
|
||||
if response.drag_started_by(egui::PointerButton::Primary)
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
let delta = response.drag_delta().x;
|
||||
d.scope.tone_drag = Some((z, p.x - delta, *z.value(&mut d.params)));
|
||||
}
|
||||
}
|
||||
if response.dragged_by(egui::PointerButton::Primary)
|
||||
&& let (Some(p), Some((z, start, initial))) = (pos, d.scope.tone_drag)
|
||||
{
|
||||
z.set(d, initial + (p.x - start) / plot.width().max(1.0) * z.limit() * 2.0);
|
||||
}
|
||||
if response.drag_stopped() {
|
||||
d.scope.tone_drag = None;
|
||||
}
|
||||
context_menu(&response, d.selection.is_some(), view);
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// The cloud and pixel target use exactly the same orientation and saturation radius.
|
||||
fn scope_point(rect: Rect, red_right: bool, hue: f32, saturation: f32) -> Pos2 {
|
||||
let radius = (rect.width().min(rect.height()) * 0.5 - 14.0).max(1.0);
|
||||
let offset = if red_right { 0.0 } else { -100.0f32.to_radians() };
|
||||
let angle = offset - hue * std::f32::consts::TAU;
|
||||
rect.center() + vec2(angle.cos(), angle.sin()) * saturation * radius
|
||||
}
|
||||
|
||||
fn vectorscope(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, height: f32) {
|
||||
d.ensure_scope(view.selected_region);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
|
||||
context_menu(&response, d.selection.is_some(), view);
|
||||
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
|
||||
d.scope.vectorscope_rect = Some(rect);
|
||||
let center = rect.center();
|
||||
let radius = (rect.width().min(rect.height()) * 0.5 - 14.0).max(1.0);
|
||||
let point = |h: f32, s: f32| scope_point(rect, view.red_right, h, s);
|
||||
for ring in [0.25, 0.5, 0.75, 1.0] {
|
||||
ui.painter().circle_stroke(center, radius * ring, Stroke::new(0.7, t.text_faint.gamma_multiply(0.45)));
|
||||
}
|
||||
let mut mesh = egui::Mesh::default();
|
||||
if let Some((_, _, _, data)) = &d.scope.cache {
|
||||
let peak = data.counts.iter().copied().max().unwrap_or(1).max(1) as f32;
|
||||
for (i, count) in data.counts.iter().enumerate().filter(|(_, c)| **c > 0) {
|
||||
let h = (i / SATURATIONS) as f32 / HUES as f32;
|
||||
let s = (i % SATURATIONS) as f32 / (SATURATIONS - 1) as f32;
|
||||
let p = point(h, s);
|
||||
let color = t.scope_hue(h, s).gamma_multiply(0.2 + 0.8 * (*count as f32 / peak).sqrt());
|
||||
let dot = (radius / (SATURATIONS - 1) as f32).clamp(0.7, 3.0);
|
||||
mesh.add_colored_rect(Rect::from_center_size(p, vec2(dot * 2.0, dot * 2.0)), color);
|
||||
}
|
||||
}
|
||||
ui.painter().add(mesh);
|
||||
for (i, label) in ["R", "Y", "G", "C", "B", "M"].iter().enumerate() {
|
||||
let h = i as f32 / 6.0;
|
||||
ui.painter().line_segment([center, point(h, 1.0)], Stroke::new(0.6, t.text_faint.gamma_multiply(0.4)));
|
||||
ui.painter().circle_filled(point(h, 1.0), 2.0, t.scope_hue(h, 1.0));
|
||||
ui.painter().text(point(h, 1.11), Align2::CENTER_CENTER, label, FontId::proportional(10.0), t.scope_hue(h, 1.0));
|
||||
}
|
||||
if !view.hide_skin_line {
|
||||
ui.painter().line_segment([center, point(23.0 / 360.0, 1.0)], Stroke::new(1.0, t.text_dim));
|
||||
}
|
||||
if let Some(sample) = d.hover_sample(true)
|
||||
&& let Some([h, saturation]) = sample.hue_saturation
|
||||
{
|
||||
let at = point(h, saturation);
|
||||
let painter = ui.painter().with_clip_rect(rect);
|
||||
for stroke in [Stroke::new(3.0, t.histogram_background()), Stroke::new(1.2, t.histogram_color(7))] {
|
||||
painter.circle_stroke(at, 5.0, stroke);
|
||||
painter.line_segment([at - vec2(9.0, 0.0), at + vec2(9.0, 0.0)], stroke);
|
||||
painter.line_segment([at - vec2(0.0, 9.0), at + vec2(0.0, 9.0)], stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn header(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState) {
|
||||
view.samplers.truncate(MAX_SAMPLERS);
|
||||
view.samplers.retain(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x)));
|
||||
if view.floating_rect.is_some_and(|r| !valid_floating_rect(r)) {
|
||||
view.floating_rect = None;
|
||||
}
|
||||
if view.floating {
|
||||
let (_, response) = ui.allocate_exact_size(vec2(ui.available_width(), 48.0), Sense::click());
|
||||
context_menu(&response, d.selection.is_some(), view);
|
||||
} else {
|
||||
let height = (ui.available_height() * 0.18).clamp(48.0, 110.0);
|
||||
graph(ui, d, view, height);
|
||||
if view.vectorscope {
|
||||
vectorscope(ui, d, view, (ui.available_height() * 0.3).clamp(100.0, 220.0));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
pub(crate) fn floating(ctx: &egui::Context, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, dock: Rect) {
|
||||
if !view.floating {
|
||||
return;
|
||||
}
|
||||
let r = view.floating_rect.unwrap_or([40.0, 60.0, 400.0, 440.0]);
|
||||
let content = ctx.content_rect();
|
||||
let start = pos2(
|
||||
r[0].clamp(content.left(), (content.right() - 200.0).max(content.left())),
|
||||
r[1].clamp(content.top(), (content.bottom() - 150.0).max(content.top())),
|
||||
);
|
||||
let window = egui::Window::new("")
|
||||
.id(egui::Id::new("cr-floating-scope"))
|
||||
.order(egui::Order::Foreground)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_pos(start)
|
||||
.default_size(vec2(r[2] - r[0], r[3] - r[1]))
|
||||
.min_size(vec2(200.0, 150.0));
|
||||
let window = if d.scope.geometry_requested {
|
||||
d.scope.geometry_requested = false;
|
||||
let size = vec2(r[2] - r[0], r[3] - r[1]).min(content.size().max(vec2(1.0, 1.0)));
|
||||
// egui's title-bar drag restores the previous pivot in this frame. Disable moving
|
||||
// only for the explicit controller geometry update, then restore normal dragging.
|
||||
window.movable(false).current_pos(start).min_size(size).max_size(size)
|
||||
} else {
|
||||
window
|
||||
};
|
||||
let response = window.show(ctx, |ui| {
|
||||
let height = if view.vectorscope { (ui.available_height() * 0.35).clamp(48.0, 150.0) } else { (ui.available_height() - 28.0).max(48.0) };
|
||||
graph(ui, d, view, height);
|
||||
if view.vectorscope {
|
||||
vectorscope(ui, d, view, (ui.available_height() - 12.0).max(100.0));
|
||||
}
|
||||
});
|
||||
if let Some(response) = response {
|
||||
let previous = pos2(r[0], r[1]);
|
||||
let r = response.response.rect;
|
||||
view.floating_rect = Some([r.left(), r.top(), r.right(), r.bottom()]);
|
||||
if ctx.input(|i| i.pointer.primary_down()) && r.min.distance(previous) > 0.5 {
|
||||
d.scope.floating_dragged = true;
|
||||
}
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
if d.scope.floating_dragged && ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| dock.contains(p))) {
|
||||
view.floating = false;
|
||||
}
|
||||
d.scope.floating_dragged = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, rect: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = ui.interact(rect, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
|
||||
if let Some(p) = resp.hover_pos() {
|
||||
d.scope.pointer_sample =
|
||||
Some([((p.x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0), ((p.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0)]);
|
||||
} else {
|
||||
d.scope.pointer_sample = None;
|
||||
}
|
||||
let to_screen = |p: [f32; 2]| pos2(rect.left() + p[0] * rect.width(), rect.top() + p[1] * rect.height());
|
||||
if view.sampler_tool {
|
||||
resp.clone().on_hover_cursor(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
&& let Some(p) = d.scope.pointer_sample
|
||||
{
|
||||
let near = view.samplers.iter().position(|q| to_screen(*q).distance(to_screen(p)) < 12.0);
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
if let Some(i) = near {
|
||||
view.samplers.remove(i);
|
||||
}
|
||||
} else if near.is_none() && view.samplers.len() < MAX_SAMPLERS {
|
||||
view.samplers.push(p);
|
||||
}
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let start = resp.interact_pointer_pos().map(|p| p - resp.drag_delta());
|
||||
d.scope.sampler_drag = view.samplers.iter().position(|q| start.is_some_and(|s| to_screen(*q).distance(s) < 16.0));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(p), Some(i)) = (d.scope.pointer_sample, d.scope.sampler_drag)
|
||||
&& let Some(q) = view.samplers.get_mut(i)
|
||||
{
|
||||
*q = p;
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
d.scope.sampler_drag = None;
|
||||
}
|
||||
}
|
||||
let alt_zone = d.scope.tone_drag.map(|d| d.0).or(d.scope.alt_tone);
|
||||
let mode = if ui.input(|i| i.modifiers.alt && i.pointer.primary_down()) && alt_zone.is_some() {
|
||||
if matches!(alt_zone, Some(ToneZone::Blacks | ToneZone::Shadows)) { ClippingMode::ShadowChannels } else { ClippingMode::HighlightChannels }
|
||||
} else if view.shadows || view.highlights {
|
||||
ClippingMode::Warnings(u8::from(view.shadows) | (u8::from(view.highlights) << 1))
|
||||
} else {
|
||||
ClippingMode::None
|
||||
};
|
||||
let key = (d.preview_revision, d.show_before, mode, t.kind);
|
||||
if mode != ClippingMode::None {
|
||||
if d.scope.overlay_key != Some(key) {
|
||||
let colors = d
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (lo, hi) = clipping(*p);
|
||||
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
|
||||
return Color32::TRANSPARENT;
|
||||
}
|
||||
match mode {
|
||||
ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo),
|
||||
ClippingMode::HighlightChannels => {
|
||||
if hi == 0 {
|
||||
t.histogram_background()
|
||||
} else {
|
||||
t.histogram_color(hi)
|
||||
}
|
||||
}
|
||||
ClippingMode::Warnings(warnings) => {
|
||||
if warnings & 2 != 0 && hi != 0 {
|
||||
t.histogram_color(1)
|
||||
} else if warnings & 1 != 0 && lo != 0 {
|
||||
t.histogram_color(4)
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
}
|
||||
}
|
||||
ClippingMode::None => Color32::TRANSPARENT,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let image = egui::ColorImage::new([d.pw, d.ph], colors);
|
||||
match &mut d.scope.overlay_tex {
|
||||
Some(t) => t.set(image, egui::TextureOptions::NEAREST),
|
||||
None => d.scope.overlay_tex = Some(ui.ctx().load_texture("cr-clipping", image, egui::TextureOptions::NEAREST)),
|
||||
}
|
||||
d.scope.overlay_key = Some(key);
|
||||
}
|
||||
if let Some(tex) = &d.scope.overlay_tex {
|
||||
ui.painter().image(tex.id(), rect, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
if !view.samplers.is_empty() {
|
||||
ui.painter().rect_filled(
|
||||
Rect::from_min_size(rect.min, vec2(rect.width().min(330.0), view.samplers.len().min(MAX_SAMPLERS) as f32 * 16.0 + 12.0)),
|
||||
t.radius_sm,
|
||||
t.dock.gamma_multiply(0.92),
|
||||
);
|
||||
}
|
||||
for (i, p) in view.samplers.iter().take(MAX_SAMPLERS).enumerate() {
|
||||
let q = to_screen(*p);
|
||||
ui.painter().circle_stroke(q, 5.0, Stroke::new(1.0, t.text));
|
||||
ui.painter().line_segment([q - vec2(8.0, 0.0), q + vec2(8.0, 0.0)], Stroke::new(1.0, t.text));
|
||||
ui.painter().line_segment([q - vec2(0.0, 8.0), q + vec2(0.0, 8.0)], Stroke::new(1.0, t.text));
|
||||
let text = format!("{} {}", i + 1, readout_text(d.readout(*p, view.lab)));
|
||||
ui.painter().text(pos2(rect.left() + 6.0, rect.top() + 6.0 + i as f32 * 16.0), Align2::LEFT_TOP, text, crate::theme::mono(11.0), t.text);
|
||||
ui.painter().text(q + vec2(7.0, 7.0), Align2::LEFT_TOP, format!("{}", i + 1), crate::theme::mono(11.0), t.text);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shortcuts(ctx: &egui::Context, view: &mut CameraRawScopeState) {
|
||||
if ctx.text_edit_focused() {
|
||||
return;
|
||||
}
|
||||
ctx.input_mut(|i| {
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::U) {
|
||||
view.shadows = !view.shadows;
|
||||
}
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::O) {
|
||||
view.highlights = !view.highlights;
|
||||
}
|
||||
if i.consume_key(egui::Modifiers::NONE, egui::Key::S) {
|
||||
view.sampler_tool = !view.sampler_tool;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
//! Filter › Camera Raw Filter…: a large dialog like Adobe Camera Raw's (preview on the left,
|
||||
//! edit panels on the right: Basic, Curve, Detail, Color Mixer, Color Grading, Effects).
|
||||
//! edit panels on the right: Light, Color, Effects, Curve, Color Mixer, Color Grading, Detail).
|
||||
//!
|
||||
//! The preview runs [`photocraft_algo::camera_raw::develop`] on a CPU proxy of the layer
|
||||
//! (≤ 900 px, `pixel_scale` keeps pixel radii true to the full image), recomputed when a
|
||||
|
|
@ -11,6 +11,9 @@
|
|||
|
||||
use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2};
|
||||
use photocraft_algo::camera_raw::{CameraRaw, Wheel, curve_lut};
|
||||
use photocraft_algo::histogram::RgbHistogram;
|
||||
|
||||
use crate::camera_raw_scope_ui::{MAX_SAMPLERS, ToneZone};
|
||||
use photocraft_doc::LayerId;
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -27,23 +30,71 @@ pub struct CameraRawDialog {
|
|||
pub layer: LayerId,
|
||||
layer_name: String,
|
||||
pub params: CameraRaw,
|
||||
/// Settings when the dialog opened (stored settings of an edited smart filter).
|
||||
original: CameraRaw,
|
||||
target: Target,
|
||||
curve_state: crate::state::PointCurveState,
|
||||
curve_rect: Option<ERect>,
|
||||
/// Proxy pixels (straight RGBA) and size.
|
||||
proxy: Vec<[f32; 4]>,
|
||||
pw: usize,
|
||||
ph: usize,
|
||||
pub(crate) proxy: Vec<[f32; 4]>,
|
||||
pub(crate) selection: Option<Vec<f32>>,
|
||||
pub(crate) pw: usize,
|
||||
pub(crate) ph: usize,
|
||||
full_w: usize,
|
||||
float: bool,
|
||||
pub(crate) float: bool,
|
||||
tex: Option<TextureHandle>,
|
||||
before_tex: Option<TextureHandle>,
|
||||
dirty: bool,
|
||||
pub(crate) before_histogram: RgbHistogram,
|
||||
pub(crate) processed: Vec<[f32; 4]>,
|
||||
pub(crate) lab_transform: std::sync::Arc<photocraft_cms::Transform>,
|
||||
pub(crate) scope_transform: std::sync::Arc<photocraft_cms::Transform>,
|
||||
pub(crate) scope: super::camera_raw_scope_ui::ScopeView,
|
||||
pub(crate) after_histogram: RgbHistogram,
|
||||
pub(crate) preview_revision: u64,
|
||||
histogram_ms: f64,
|
||||
pub(crate) dirty: bool,
|
||||
pub show_before: bool,
|
||||
mixer_tab: usize,
|
||||
pub render_ms: f64,
|
||||
}
|
||||
|
||||
impl CameraRawDialog {
|
||||
pub fn describe(&self) -> Value {
|
||||
json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph], "renderMs": self.render_ms, "before": self.show_before})
|
||||
pub fn describe(&self, view: &crate::state::CameraRawScopeState) -> Value {
|
||||
let h = self.histogram();
|
||||
let mut result = json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph],
|
||||
"renderMs": self.render_ms, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before,
|
||||
"histogram": {"source": if self.show_before { "before" } else { "after" }, "approximate": true, "size": [self.pw, self.ph],
|
||||
"sampleDomain": "preview-rgb", "range": [0, 1], "red": h.channels[0].as_slice(), "green": h.channels[1].as_slice(), "blue": h.channels[2].as_slice(),
|
||||
"samples": h.samples, "transparent": h.transparent, "invalid": h.invalid, "underflow": h.underflow, "overflow": h.overflow, "shadows": h.shadows, "highlights": h.highlights}});
|
||||
result["smartFilter"] = json!(match self.target {
|
||||
Target::SmartFilter(index) => Some(index),
|
||||
Target::NewFilter => None,
|
||||
});
|
||||
result["scope"] = serde_json::to_value(view).unwrap_or(Value::Null);
|
||||
result["hasScopeSelection"] = json!(self.selection.is_some());
|
||||
result["vectorscope"] = json!(
|
||||
self.scope
|
||||
.cache
|
||||
.as_ref()
|
||||
.map(|(_, before, selected, data)| json!({"samples":data.samples,"before":before,"selectedRegion":selected,"bins":data.counts}))
|
||||
);
|
||||
result["scopeMs"] = json!(self.scope.ms);
|
||||
result["scopeRevision"] = json!(self.scope.revision);
|
||||
result["overlayRevision"] = json!(self.scope.overlay_key.map(|k| k.0));
|
||||
result["pointerReadout"] = self.scope.pointer_sample.and_then(|p| self.readout(p, view.lab)).unwrap_or(Value::Null);
|
||||
result["samplerReadouts"] = json!(view.samplers.iter().take(MAX_SAMPLERS).map(|p| self.readout(*p, view.lab)).collect::<Vec<_>>());
|
||||
result["hoveredZone"] = json!(self.scope.hovered_zone);
|
||||
result["previewRect"] = json!(self.scope.preview_rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
|
||||
result["curveState"] = json!(self.curve_state);
|
||||
result["curveRect"] = json!(self.curve_rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
|
||||
result["hoverSample"] = json!(self.hover_sample(view.vectorscope));
|
||||
result["vectorscopeRect"] = json!(self.scope.vectorscope_rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
|
||||
result["scopeRect"] = json!(self.scope.rect.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn histogram(&self) -> &RgbHistogram {
|
||||
if self.show_before { &self.before_histogram } else { &self.after_histogram }
|
||||
}
|
||||
|
||||
/// Params for the engine: only the settings that differ from the defaults.
|
||||
|
|
@ -66,12 +117,22 @@ impl CameraRawDialog {
|
|||
egui::ColorImage::new([w, h], px.iter().map(|q| Color32::from_rgba_unmultiplied(enc(q[0]), enc(q[1]), enc(q[2]), enc(q[3]))).collect())
|
||||
}
|
||||
|
||||
fn render(&mut self, ctx: &egui::Context) {
|
||||
pub(crate) fn render(&mut self, ctx: &egui::Context) {
|
||||
let t0 = crate::gpu_canvas::now_ms();
|
||||
let mut px = self.proxy.clone();
|
||||
let mut p = self.params.clone();
|
||||
p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0);
|
||||
photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float);
|
||||
if let Some(mask) = &self.selection {
|
||||
for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) {
|
||||
for (out, original) in out.iter_mut().zip(original) {
|
||||
*out = *original + (*out - *original) * coverage;
|
||||
}
|
||||
}
|
||||
}
|
||||
let histogram_start = crate::gpu_canvas::now_ms();
|
||||
self.after_histogram = RgbHistogram::from_rgba(&px);
|
||||
self.histogram_ms = crate::gpu_canvas::now_ms() - histogram_start;
|
||||
let img = Self::image(&px, self.pw, self.ph);
|
||||
match &mut self.tex {
|
||||
Some(t) => t.set(img, egui::TextureOptions::LINEAR),
|
||||
|
|
@ -80,15 +141,66 @@ impl CameraRawDialog {
|
|||
if self.before_tex.is_none() {
|
||||
self.before_tex = Some(ctx.load_texture("camera-raw-before", Self::image(&self.proxy, self.pw, self.ph), egui::TextureOptions::LINEAR));
|
||||
}
|
||||
self.processed = px;
|
||||
self.scope.cache = None;
|
||||
self.render_ms = crate::gpu_canvas::now_ms() - t0;
|
||||
self.preview_revision = self.preview_revision.saturating_add(1);
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the dialog on the active layer.
|
||||
/// Where OK writes: a new filter on the layer, or an existing smart filter (by index).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Target {
|
||||
NewFilter,
|
||||
SmartFilter(usize),
|
||||
}
|
||||
|
||||
/// What limits the preview: the document selection (a new filter applies through it, as the
|
||||
/// engine does), a smart object's filter mask, or nothing.
|
||||
enum Coverage {
|
||||
Selection,
|
||||
Mask(photocraft_doc::LayerMask),
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> {
|
||||
photocraft_engine::commands::find("filter.cameraRaw").map(|c| (c.enabled)(&app.session)).unwrap_or(Err("unknown command".into()))?;
|
||||
let (layer, surf, _) = crate::distort_ui::active_pixels(app)?;
|
||||
open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::Selection, Target::NewFilter)
|
||||
}
|
||||
|
||||
/// Opens Camera Raw on smart filter `index` of smart layer `layer`, with its stored settings and
|
||||
/// the pixels below it; OK updates that filter (`layer.smartFilter.setParams`), as double-clicking
|
||||
/// a Camera Raw smart filter does in Photoshop.
|
||||
pub fn open_smart_filter(app: &mut PhotocraftApp, ctx: &egui::Context, layer: LayerId, index: usize) -> Result<(), String> {
|
||||
use photocraft_engine::lens_cmds::{RAW, raw_params};
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let Some(photocraft_doc::LayerContent::Smart(sm)) = st.doc.layer(layer).map(|l| &l.content) else {
|
||||
return Err("the layer is not a smart object".into());
|
||||
};
|
||||
let filter = sm.smart_filters.get(index).ok_or("no such smart filter")?;
|
||||
if filter.command != RAW {
|
||||
return Err("the smart filter is not a Camera Raw filter".into());
|
||||
}
|
||||
let params = raw_params(RAW, &filter.params).map_err(|e| e.to_string())?;
|
||||
let surf = photocraft_engine::smart_cmds::render_below_filter(&st.doc, sm, index)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or("the smart object's contents are unavailable (missing linked file?)")?;
|
||||
let coverage = sm.filter_mask.clone().filter(|m| m.enabled).map_or(Coverage::None, Coverage::Mask);
|
||||
open_pixels(app, ctx, layer, surf, params, coverage, Target::SmartFilter(index))
|
||||
}
|
||||
|
||||
fn open_pixels(
|
||||
app: &mut PhotocraftApp,
|
||||
ctx: &egui::Context,
|
||||
layer: LayerId,
|
||||
surf: photocraft_raster::Surface,
|
||||
params: CameraRaw,
|
||||
coverage: Coverage,
|
||||
target: Target,
|
||||
) -> Result<(), String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let canvas = st.doc.bounds();
|
||||
let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default();
|
||||
|
|
@ -117,12 +229,50 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
|||
proxy[py * pw + x] = [a[0] / a[4], a[1] / a[4], a[2] / a[4], a[3] / a[4]];
|
||||
}
|
||||
}
|
||||
// Coverage at the centre of each proxy block.
|
||||
let sample = |value: &dyn Fn(i32, i32) -> f32| {
|
||||
(0..ph)
|
||||
.flat_map(|y| (0..pw).map(move |x| (area.x0 + (x * k + k / 2).min(w - 1) as i32, area.y0 + (y * k + k / 2).min(h - 1) as i32)))
|
||||
.map(|(x, y)| value(x, y).clamp(0.0, 1.0))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let selection = match &coverage {
|
||||
Coverage::Selection => st.doc.selection.as_ref().map(|mask| sample(&|x, y| mask.sample_channel(x, y, 0))),
|
||||
Coverage::Mask(mask) => Some(sample(&|x, y| mask.value(x, y))),
|
||||
Coverage::None => None,
|
||||
};
|
||||
let source = photocraft_engine::color_cmds::composite_profile(&st.doc);
|
||||
let lab_transform = photocraft_cms::cached(&source, photocraft_cms::Builtin::LabD50.profile(), Default::default()).map_err(|e| e.to_string())?;
|
||||
let scope_transform = photocraft_cms::cached(&source, photocraft_cms::Builtin::Srgb.profile(), Default::default()).map_err(|e| e.to_string())?;
|
||||
if let Some(saved) = app.session.prefs().dialogs.get("filter.cameraRaw.scope")
|
||||
&& saved.get("samplers").and_then(Value::as_array).is_none_or(|a| a.len() <= MAX_SAMPLERS)
|
||||
&& let Ok(view) = serde_json::from_value(saved.clone())
|
||||
{
|
||||
app.ui.camera_raw_scope = view;
|
||||
}
|
||||
app.ui.camera_raw_scope.samplers.clear();
|
||||
// Keep Camera Raw compact on open; vectorscope is an opt-in context-menu view.
|
||||
app.ui.camera_raw_scope.vectorscope = false;
|
||||
if selection.is_none() {
|
||||
app.ui.camera_raw_scope.selected_region = false;
|
||||
}
|
||||
let float = surf.format().sample == photocraft_color::SampleType::F32;
|
||||
let mut d = CameraRawDialog {
|
||||
layer,
|
||||
layer_name: name,
|
||||
params: CameraRaw::default(),
|
||||
original: params.clone(),
|
||||
params,
|
||||
target,
|
||||
before_histogram: RgbHistogram::from_rgba(&proxy),
|
||||
processed: Vec::new(),
|
||||
lab_transform,
|
||||
scope_transform,
|
||||
scope: Default::default(),
|
||||
after_histogram: RgbHistogram::default(),
|
||||
preview_revision: 0,
|
||||
histogram_ms: 0.0,
|
||||
proxy,
|
||||
selection,
|
||||
pw,
|
||||
ph,
|
||||
full_w: w,
|
||||
|
|
@ -132,6 +282,8 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String>
|
|||
dirty: true,
|
||||
show_before: false,
|
||||
mixer_tab: 1,
|
||||
curve_state: Default::default(),
|
||||
curve_rect: None,
|
||||
render_ms: 0.0,
|
||||
};
|
||||
d.render(ctx);
|
||||
|
|
@ -144,37 +296,106 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
if id != "filter.cameraRaw" {
|
||||
return None;
|
||||
}
|
||||
let empty = params.as_object().is_none_or(|o| o.is_empty());
|
||||
if empty {
|
||||
return Some(open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe()).unwrap_or(Value::Null)));
|
||||
let Some(fields) = params.as_object() else {
|
||||
return Some(Err("Camera Raw params must be an object".into()));
|
||||
};
|
||||
if fields.is_empty() {
|
||||
return Some(open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)));
|
||||
}
|
||||
if let Some(sf) = params.get("smartFilter") {
|
||||
let opened = (|| {
|
||||
if fields.len() > 1 {
|
||||
return Err("smartFilter opens the dialog; send ui requests separately".to_string());
|
||||
}
|
||||
let layer = sf.get("layer").and_then(Value::as_u64).ok_or("smartFilter.layer must be a layer id")?;
|
||||
let index = sf.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("smartFilter.index must be a filter index")?;
|
||||
open_smart_filter(app, ctx, LayerId(layer), index)?;
|
||||
Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null))
|
||||
})();
|
||||
return Some(opened);
|
||||
}
|
||||
let ui = params.get("ui")?;
|
||||
if app.camera_raw.is_none()
|
||||
&& let Err(e) = open(app, ctx)
|
||||
{
|
||||
let Some(fields) = ui.as_object() else {
|
||||
return Some(Err("Camera Raw ui must be an object".into()));
|
||||
};
|
||||
for (key, value) in fields {
|
||||
match key.as_str() {
|
||||
"set" | "scope" => {}
|
||||
"before" | "commit" | "cancel" if value.is_boolean() => {}
|
||||
"before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))),
|
||||
_ => return Some(Err(format!("unknown Camera Raw ui property {key}"))),
|
||||
}
|
||||
}
|
||||
if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) {
|
||||
return Some(Err("Camera Raw cannot commit and cancel together".into()));
|
||||
}
|
||||
let was_closed = app.camera_raw.is_none();
|
||||
let previous_view = app.ui.camera_raw_scope.clone();
|
||||
if was_closed && let Err(e) = open(app, ctx) {
|
||||
return Some(Err(e));
|
||||
}
|
||||
let result = menu_update(app, ctx, ui);
|
||||
if was_closed && result.as_ref().is_some_and(Result::is_err) {
|
||||
app.camera_raw = None;
|
||||
app.ui.camera_raw_scope = previous_view;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Option<Result<Value, String>> {
|
||||
// Stage both filter and presentation updates. No texture, preference or dialog mutation
|
||||
// occurs until every part of the request has passed validation.
|
||||
let d = app.camera_raw.as_ref()?;
|
||||
let mut next_params = d.params.clone();
|
||||
if let Some(set) = ui.get("set") {
|
||||
let d = app.camera_raw.as_mut()?;
|
||||
if !set.is_object() {
|
||||
return Some(Err("bad Camera Raw settings: expected an object".into()));
|
||||
}
|
||||
let mut cur = serde_json::to_value(&d.params).unwrap_or(json!({}));
|
||||
if let (Value::Object(c), Value::Object(s)) = (&mut cur, set) {
|
||||
for (k, v) in s {
|
||||
if !c.contains_key(k) {
|
||||
return Some(Err(format!("unknown Camera Raw setting {k}")));
|
||||
}
|
||||
c.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
match serde_json::from_value::<CameraRaw>(cur) {
|
||||
Ok(p) => {
|
||||
d.params = p;
|
||||
d.render(ctx);
|
||||
}
|
||||
next_params = match serde_json::from_value::<CameraRaw>(cur) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Some(Err(format!("bad Camera Raw settings: {e}"))),
|
||||
};
|
||||
if let Err(e) = next_params.validate_changes(&d.original) {
|
||||
return Some(Err(format!("bad Camera Raw settings: {e}")));
|
||||
}
|
||||
}
|
||||
if let Some(b) = ui.get("before").and_then(Value::as_bool)
|
||||
&& let Some(d) = app.camera_raw.as_mut()
|
||||
{
|
||||
let scope_update = match ui.get("scope") {
|
||||
Some(scope) => match super::camera_raw_scope_ui::prepare_control(&next_params, d.selection.is_some(), &app.ui.camera_raw_scope, scope) {
|
||||
Ok(update) => Some(update),
|
||||
Err(e) => return Some(Err(e)),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let d = app.camera_raw.as_mut()?;
|
||||
if d.params.point_curve != next_params.point_curve {
|
||||
d.curve_state = Default::default();
|
||||
}
|
||||
d.dirty |= d.params != next_params;
|
||||
d.params = next_params;
|
||||
if let Some(b) = ui.get("before").and_then(Value::as_bool) {
|
||||
d.show_before = b;
|
||||
}
|
||||
if let Some(update) = scope_update {
|
||||
update.apply(d, &mut app.ui.camera_raw_scope);
|
||||
}
|
||||
if d.dirty {
|
||||
d.render(ctx);
|
||||
}
|
||||
if app.ui.camera_raw_scope.vectorscope
|
||||
&& let Some(d) = app.camera_raw.as_mut()
|
||||
{
|
||||
d.ensure_scope(app.ui.camera_raw_scope.selected_region);
|
||||
}
|
||||
super::camera_raw_scope_ui::persist(app, ctx);
|
||||
if ui.get("cancel").and_then(Value::as_bool) == Some(true) {
|
||||
app.camera_raw = None;
|
||||
return Some(Ok(json!({"cancelled": true})));
|
||||
|
|
@ -182,14 +403,30 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
|||
if ui.get("commit").and_then(Value::as_bool) == Some(true) {
|
||||
return Some(commit(app));
|
||||
}
|
||||
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe()).unwrap_or(Value::Null)))
|
||||
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
fn commit(app: &mut PhotocraftApp) -> Result<Value, String> {
|
||||
let d = app.camera_raw.take().ok_or(tl!("Camera Raw isn't open"))?;
|
||||
let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?;
|
||||
let result = match d.target {
|
||||
Target::NewFilter => {
|
||||
let mut p = d.command_params();
|
||||
p["layer"] = json!(d.layer.0);
|
||||
app.run("filter.cameraRaw", p)
|
||||
app.run("filter.cameraRaw", p)?
|
||||
}
|
||||
Target::SmartFilter(index) => {
|
||||
d.params.validate_changes(&d.original)?;
|
||||
// Every setting, not just the non-defaults: setParams merges into the stored ones,
|
||||
// so a control reset to its default must overwrite the old value.
|
||||
let mut params = serde_json::to_value(&d.params).map_err(|e| e.to_string())?;
|
||||
if let Value::Object(m) = &mut params {
|
||||
m.remove("pixelScale");
|
||||
}
|
||||
app.run("layer.smartFilter.setParams", json!({"layer": d.layer.0, "index": index, "params": params}))?
|
||||
}
|
||||
};
|
||||
app.camera_raw = None;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn temp_stops() -> Vec<Color32> {
|
||||
|
|
@ -201,7 +438,11 @@ fn tint_stops() -> Vec<Color32> {
|
|||
|
||||
/// One labelled slider; marks the dialog dirty when it moves.
|
||||
fn row(ui: &mut egui::Ui, dirty: &mut bool, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, grad: Option<&[Color32]>) {
|
||||
let r = widgets::slider_row(ui, label, v, range, "", grad);
|
||||
let _ = slider(ui, dirty, label, v, range, grad);
|
||||
}
|
||||
|
||||
fn slider(ui: &mut egui::Ui, dirty: &mut bool, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>, grad: Option<&[Color32]>) -> egui::Response {
|
||||
let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", label), v, range, "", grad);
|
||||
if r.changed() {
|
||||
*dirty = true;
|
||||
}
|
||||
|
|
@ -209,79 +450,66 @@ fn row(ui: &mut egui::Ui, dirty: &mut bool, label: &str, v: &mut f32, range: std
|
|||
*v = 0.0;
|
||||
*dirty = true;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A Light tone slider; Alt-dragging it shows that zone's channel clipping on the preview.
|
||||
fn tone_row(ui: &mut egui::Ui, dirty: &mut bool, alt: &mut Option<ToneZone>, zone: ToneZone, v: &mut f32) {
|
||||
let r = slider(ui, dirty, zone.name(), v, -zone.limit()..=zone.limit(), None);
|
||||
if r.dragged() && ui.input(|i| i.modifiers.alt) {
|
||||
*alt = Some(zone);
|
||||
}
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, title: &str, open: bool, body: impl FnOnce(&mut egui::Ui)) {
|
||||
egui::CollapsingHeader::new(egui::RichText::new(tl!(&title)).font(FontId::proportional(13.0))).default_open(open).show(ui, body);
|
||||
egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", title)).font(FontId::proportional(13.0)))
|
||||
.id_salt(("camera-raw-section", title))
|
||||
.default_open(open)
|
||||
.show(ui, body);
|
||||
}
|
||||
|
||||
fn wheel(ui: &mut egui::Ui, dirty: &mut bool, title: &str, w: &mut Wheel) {
|
||||
widgets::section_label(ui, title);
|
||||
widgets::section_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", title));
|
||||
let hs = widgets::hue_stops();
|
||||
row(ui, dirty, tl!("Hue"), &mut w.hue, 0.0..=360.0, Some(&hs));
|
||||
row(ui, dirty, tl!("Saturation"), &mut w.sat, 0.0..=100.0, None);
|
||||
row(ui, dirty, tl!("Luminance"), &mut w.lum, -100.0..=100.0, None);
|
||||
row(ui, dirty, "Hue", &mut w.hue, 0.0..=360.0, Some(&hs));
|
||||
row(ui, dirty, "Saturation", &mut w.sat, 0.0..=100.0, None);
|
||||
row(ui, dirty, "Luminance", &mut w.lum, -100.0..=100.0, None);
|
||||
}
|
||||
|
||||
// The shared Curve editor must never produce a curve `CameraRaw::validate` rejects.
|
||||
const _: () = assert!(crate::point_curve::MAX_POINTS == photocraft_algo::camera_raw::MAX_CURVE_POINTS && crate::point_curve::MIN_GAP >= 1.0);
|
||||
|
||||
/// Small point-curve editor (master channel): click to add, drag to move, right-click to delete.
|
||||
fn curve_editor(ui: &mut egui::Ui, p: &mut CameraRaw, dirty: &mut bool) {
|
||||
fn curve_editor(ui: &mut egui::Ui, p: &mut CameraRaw, dirty: &mut bool, state: &mut crate::state::PointCurveState) -> ERect {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let side = ui.available_width().min(260.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click_and_drag());
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, 2.0, t.field);
|
||||
painter.rect_filled(rect, t.radius_sm, t.field);
|
||||
for i in 1..4 {
|
||||
let f = i as f32 / 4.0;
|
||||
painter.line_segment([pos2(rect.left() + f * side, rect.top()), pos2(rect.left() + f * side, rect.bottom())], Stroke::new(1.0, t.separator));
|
||||
painter.line_segment([pos2(rect.left(), rect.top() + f * side), pos2(rect.right(), rect.top() + f * side)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
let to_screen = |x: f32, y: f32| pos2(rect.left() + x / 255.0 * side, rect.bottom() - y / 255.0 * side);
|
||||
let from_screen = |q: Pos2| [((q.x - rect.left()) / side * 255.0).clamp(0.0, 255.0), ((rect.bottom() - q.y) / side * 255.0).clamp(0.0, 255.0)];
|
||||
if p.point_curve.len() < 2 {
|
||||
p.point_curve = vec![[0.0, 0.0], [255.0, 255.0]];
|
||||
}
|
||||
// Interaction.
|
||||
let drag_id = ui.id().with("cr-curve-drag");
|
||||
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(drag_id));
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
let q = from_screen(pos);
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
let near = p.point_curve.iter().position(|c| (to_screen(c[0], c[1]) - pos).length() < 8.0);
|
||||
if resp.secondary_clicked() {
|
||||
if let Some(i) = near.filter(|i| *i != 0 && *i + 1 != p.point_curve.len()) {
|
||||
p.point_curve.remove(i);
|
||||
// The identity handles are presentation until a gesture actually edits them.
|
||||
let mut points = if p.point_curve.is_empty() { vec![[0.0, 0.0], [255.0, 255.0]] } else { p.point_curve.clone() };
|
||||
if crate::point_curve::interact(ui, &resp, rect, &mut points, state).changed {
|
||||
p.point_curve = points.clone();
|
||||
*dirty = true;
|
||||
}
|
||||
} else {
|
||||
dragging = Some(near.unwrap_or_else(|| {
|
||||
p.point_curve.push(q);
|
||||
p.point_curve.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
*dirty = true;
|
||||
p.point_curve.iter().position(|c| *c == q).unwrap_or(0)
|
||||
}));
|
||||
}
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(i) = dragging
|
||||
{
|
||||
let n = p.point_curve.len();
|
||||
let lo = if i == 0 { 0.0 } else { p.point_curve[i - 1][0] + 1.0 };
|
||||
let hi = if i + 1 == n { 255.0 } else { p.point_curve[i + 1][0] - 1.0 };
|
||||
p.point_curve[i] = [q[0].clamp(lo, hi.max(lo)), q[1]];
|
||||
*dirty = true;
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
dragging = None;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, dragging));
|
||||
// Curve (point curve after the parametric one).
|
||||
let lut = curve_lut(&p.point_curve, 256);
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to_screen(i as f32, lut[i] * 255.0)).collect();
|
||||
let lut = curve_lut(&points, 256);
|
||||
let pts: Vec<Pos2> = lut.iter().enumerate().map(|(i, value)| to_screen(i as f32, value * 255.0)).collect();
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(1.5, t.text)));
|
||||
for c in &p.point_curve {
|
||||
painter.circle_stroke(to_screen(c[0], c[1]), 4.0, Stroke::new(1.5, t.accent));
|
||||
for (i, c) in points.iter().enumerate() {
|
||||
let center = to_screen(c[0], c[1]);
|
||||
if state.selected == Some(i) {
|
||||
painter.circle_filled(center, 3.0, t.accent);
|
||||
}
|
||||
painter.circle_stroke(center, 4.0, Stroke::new(1.5, t.accent));
|
||||
}
|
||||
rect
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
|
|
@ -293,6 +521,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
let mut action: Option<&str> = None;
|
||||
egui::Area::new(egui::Id::new("camera-raw-dialog")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
|
||||
let Some(d) = app.camera_raw.as_mut() else { return };
|
||||
d.curve_rect = None;
|
||||
d.scope.vectorscope_rect = None;
|
||||
if d.dirty {
|
||||
d.render(ctx);
|
||||
}
|
||||
|
|
@ -302,9 +532,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
let title = ERect::from_min_size(full.min, vec2(full.width(), 30.0));
|
||||
painter.rect_filled(title, 0.0, t.dock);
|
||||
painter.line_segment([title.left_bottom(), title.right_bottom()], Stroke::new(1.0, t.separator));
|
||||
painter.text(title.center(), Align2::CENTER_CENTER, format!("Camera Raw Filter ({})", d.layer_name), FontId::proportional(13.0), t.text);
|
||||
let window_title = crate::i18n::fmt(tl!("Camera Raw Filter ({layer})"), &[("layer", &d.layer_name)]);
|
||||
painter.text(title.center(), Align2::CENTER_CENTER, window_title, FontId::proportional(13.0), t.text);
|
||||
let footer_h = 48.0;
|
||||
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), pos2(full.right(), full.bottom() - footer_h));
|
||||
let scope = &mut app.ui.camera_raw_scope;
|
||||
// Preview.
|
||||
let view = ERect::from_min_max(body.min, pos2(body.right() - PANEL_W, body.bottom())).shrink(16.0);
|
||||
painter.rect_filled(view, 0.0, t.canvas);
|
||||
|
|
@ -314,87 +546,43 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
let r = ERect::from_center_size(view.center(), vec2(d.pw as f32 * s, d.ph as f32 * s));
|
||||
widgets::checker(&painter, r, 8.0);
|
||||
painter.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
d.scope.preview_rect = Some(r);
|
||||
super::camera_raw_scope_ui::preview(ui, d, scope, r);
|
||||
}
|
||||
// Panels.
|
||||
let right = ERect::from_min_max(pos2(body.right() - PANEL_W, body.top()), body.max);
|
||||
painter.rect_filled(right, 0.0, t.dock);
|
||||
painter.line_segment([right.left_top(), right.left_bottom()], Stroke::new(1.0, t.separator));
|
||||
let mut props = ui.new_child(egui::UiBuilder::new().max_rect(right.shrink2(vec2(14.0, 10.0))));
|
||||
// This header is outside the settings scroll area and follows the same Before/After
|
||||
// selector as the texture. Both derive from the same completed preview revision.
|
||||
super::camera_raw_scope_ui::header(&mut props, d, scope);
|
||||
d.scope.alt_tone = None;
|
||||
let mut dirty = false;
|
||||
egui::ScrollArea::vertical().id_salt("camera-raw-props").show(&mut props, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
let p = &mut d.params;
|
||||
let alt = &mut d.scope.alt_tone;
|
||||
let (ts, tn) = (temp_stops(), tint_stops());
|
||||
section(ui, tl!("Basic"), true, |ui| {
|
||||
section(ui, "Light", true, |ui| {
|
||||
tone_row(ui, &mut dirty, alt, ToneZone::Exposure, &mut p.exposure);
|
||||
row(ui, &mut dirty, "Contrast", &mut p.contrast, -100.0..=100.0, None);
|
||||
tone_row(ui, &mut dirty, alt, ToneZone::Highlights, &mut p.highlights);
|
||||
tone_row(ui, &mut dirty, alt, ToneZone::Shadows, &mut p.shadows);
|
||||
tone_row(ui, &mut dirty, alt, ToneZone::Whites, &mut p.whites);
|
||||
tone_row(ui, &mut dirty, alt, ToneZone::Blacks, &mut p.blacks);
|
||||
});
|
||||
section(ui, "Color", false, |ui| {
|
||||
widgets::section_label(ui, tl!("White Balance: As Shot"));
|
||||
row(ui, &mut dirty, tl!("Temperature"), &mut p.temperature, -100.0..=100.0, Some(&ts));
|
||||
row(ui, &mut dirty, tl!("Tint"), &mut p.tint, -100.0..=100.0, Some(&tn));
|
||||
row(ui, &mut dirty, tl!("Exposure"), &mut p.exposure, -5.0..=5.0, None);
|
||||
row(ui, &mut dirty, tl!("Contrast"), &mut p.contrast, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Highlights"), &mut p.highlights, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Shadows"), &mut p.shadows, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Whites"), &mut p.whites, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Blacks"), &mut p.blacks, -100.0..=100.0, None);
|
||||
widgets::hairline(ui);
|
||||
row(ui, &mut dirty, tl!("Texture"), &mut p.texture, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Clarity"), &mut p.clarity, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Dehaze"), &mut p.dehaze, -100.0..=100.0, None);
|
||||
widgets::hairline(ui);
|
||||
row(ui, &mut dirty, tl!("Vibrance"), &mut p.vibrance, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Saturation"), &mut p.saturation, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Temperature", &mut p.temperature, -100.0..=100.0, Some(&ts));
|
||||
row(ui, &mut dirty, "Tint", &mut p.tint, -100.0..=100.0, Some(&tn));
|
||||
row(ui, &mut dirty, "Vibrance", &mut p.vibrance, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Saturation", &mut p.saturation, -100.0..=100.0, None);
|
||||
});
|
||||
section(ui, tl!("Curve"), false, |ui| {
|
||||
curve_editor(ui, p, &mut dirty);
|
||||
row(ui, &mut dirty, tl!("Highlights"), &mut p.curve_highlights, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Lights"), &mut p.curve_lights, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Darks"), &mut p.curve_darks, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Shadows"), &mut p.curve_shadows, -100.0..=100.0, None);
|
||||
});
|
||||
section(ui, tl!("Detail"), false, |ui| {
|
||||
widgets::section_label(ui, tl!("Sharpening"));
|
||||
row(ui, &mut dirty, tl!("Amount"), &mut p.sharpen_amount, 0.0..=150.0, None);
|
||||
row(ui, &mut dirty, tl!("Radius"), &mut p.sharpen_radius, 0.5..=3.0, None);
|
||||
row(ui, &mut dirty, tl!("Detail"), &mut p.sharpen_detail, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Masking"), &mut p.sharpen_masking, 0.0..=100.0, None);
|
||||
widgets::section_label(ui, tl!("Noise Reduction"));
|
||||
row(ui, &mut dirty, tl!("Luminance"), &mut p.noise_luminance, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Luminance Detail"), &mut p.noise_luminance_detail, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Color"), &mut p.noise_color, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Color Detail"), &mut p.noise_color_detail, 0.0..=100.0, None);
|
||||
});
|
||||
section(ui, tl!("Color Mixer"), false, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (i, name) in [tl!("Hue"), tl!("Saturation"), tl!("Luminance")].iter().enumerate() {
|
||||
if widgets::pill_tab(ui, name, d.mixer_tab == i).clicked() {
|
||||
d.mixer_tab = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
let arr = match d.mixer_tab {
|
||||
0 => &mut p.hsl_hue,
|
||||
1 => &mut p.hsl_sat,
|
||||
_ => &mut p.hsl_lum,
|
||||
};
|
||||
for (k, name) in BANDS.iter().enumerate() {
|
||||
let c = photocraft_algo::camera_raw::HSL_BANDS[k];
|
||||
let base = hue_color(c);
|
||||
let grad = [Color32::from_gray(128), base];
|
||||
row(ui, &mut dirty, name, &mut arr[k], -100.0..=100.0, Some(&grad));
|
||||
}
|
||||
});
|
||||
section(ui, tl!("Color Grading"), false, |ui| {
|
||||
wheel(ui, &mut dirty, tl!("Shadows"), &mut p.grade_shadows);
|
||||
wheel(ui, &mut dirty, tl!("Midtones"), &mut p.grade_midtones);
|
||||
wheel(ui, &mut dirty, tl!("Highlights"), &mut p.grade_highlights);
|
||||
wheel(ui, &mut dirty, tl!("Global"), &mut p.grade_global);
|
||||
row(ui, &mut dirty, tl!("Blending"), &mut p.grade_blending, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Balance"), &mut p.grade_balance, -100.0..=100.0, None);
|
||||
});
|
||||
section(ui, tl!("Effects"), false, |ui| {
|
||||
widgets::section_label(ui, tl!("Grain"));
|
||||
row(ui, &mut dirty, tl!("Amount"), &mut p.grain_amount, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Size"), &mut p.grain_size, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Roughness"), &mut p.grain_roughness, 0.0..=100.0, None);
|
||||
section(ui, "Effects", false, |ui| {
|
||||
row(ui, &mut dirty, "Texture", &mut p.texture, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Clarity", &mut p.clarity, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Dehaze", &mut p.dehaze, -100.0..=100.0, None);
|
||||
widgets::section_label(ui, tl!("Vignetting"));
|
||||
let mut style = p.vignette_style.clone();
|
||||
if widgets::dropdown(
|
||||
|
|
@ -411,14 +599,73 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
p.vignette_style = style;
|
||||
dirty = true;
|
||||
}
|
||||
row(ui, &mut dirty, tl!("Amount"), &mut p.vignette_amount, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Midpoint"), &mut p.vignette_midpoint, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Roundness"), &mut p.vignette_roundness, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Feather"), &mut p.vignette_feather, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, tl!("Highlights"), &mut p.vignette_highlights, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Amount", &mut p.vignette_amount, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Midpoint", &mut p.vignette_midpoint, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Roundness", &mut p.vignette_roundness, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Feather", &mut p.vignette_feather, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Highlights", &mut p.vignette_highlights, 0.0..=100.0, None);
|
||||
widgets::section_label(ui, tl!("Grain"));
|
||||
row(ui, &mut dirty, "Amount", &mut p.grain_amount, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Size", &mut p.grain_size, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Roughness", &mut p.grain_roughness, 0.0..=100.0, None);
|
||||
});
|
||||
section(ui, "Curve", false, |ui| {
|
||||
d.curve_rect = Some(curve_editor(ui, p, &mut dirty, &mut d.curve_state));
|
||||
row(ui, &mut dirty, "Highlights", &mut p.curve_highlights, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Lights", &mut p.curve_lights, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Darks", &mut p.curve_darks, -100.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Shadows", &mut p.curve_shadows, -100.0..=100.0, None);
|
||||
});
|
||||
section(ui, "Color Mixer", false, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, source) in ["Hue", "Saturation", "Luminance"].iter().enumerate() {
|
||||
let name = crate::i18n::tr_ctx(crate::i18n::current(), "cameraRaw", source);
|
||||
let response = widgets::pill_tab(ui, name, d.mixer_tab == i);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), d.mixer_tab == i, name));
|
||||
if response.clicked() {
|
||||
d.mixer_tab = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
let arr = match d.mixer_tab {
|
||||
0 => &mut p.hsl_hue,
|
||||
1 => &mut p.hsl_sat,
|
||||
_ => &mut p.hsl_lum,
|
||||
};
|
||||
for (k, name) in BANDS.iter().enumerate() {
|
||||
let c = photocraft_algo::camera_raw::HSL_BANDS[k];
|
||||
let base = hue_color(c);
|
||||
let grad = [Color32::from_gray(128), base];
|
||||
row(ui, &mut dirty, name, &mut arr[k], -100.0..=100.0, Some(&grad));
|
||||
}
|
||||
});
|
||||
section(ui, "Color Grading", false, |ui| {
|
||||
wheel(ui, &mut dirty, "Shadows", &mut p.grade_shadows);
|
||||
wheel(ui, &mut dirty, "Midtones", &mut p.grade_midtones);
|
||||
wheel(ui, &mut dirty, "Highlights", &mut p.grade_highlights);
|
||||
wheel(ui, &mut dirty, "Global", &mut p.grade_global);
|
||||
row(ui, &mut dirty, "Blending", &mut p.grade_blending, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Balance", &mut p.grade_balance, -100.0..=100.0, None);
|
||||
});
|
||||
section(ui, "Detail", false, |ui| {
|
||||
widgets::section_label(ui, tl!("Sharpening"));
|
||||
row(ui, &mut dirty, "Amount", &mut p.sharpen_amount, 0.0..=150.0, None);
|
||||
row(ui, &mut dirty, "Radius", &mut p.sharpen_radius, 0.5..=3.0, None);
|
||||
row(ui, &mut dirty, "Detail", &mut p.sharpen_detail, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Masking", &mut p.sharpen_masking, 0.0..=100.0, None);
|
||||
widgets::section_label(ui, tl!("Noise Reduction"));
|
||||
row(ui, &mut dirty, "Luminance", &mut p.noise_luminance, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Luminance Detail", &mut p.noise_luminance_detail, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Color", &mut p.noise_color, 0.0..=100.0, None);
|
||||
row(ui, &mut dirty, "Color Detail", &mut p.noise_color_detail, 0.0..=100.0, None);
|
||||
});
|
||||
});
|
||||
super::camera_raw_scope_ui::floating(ctx, d, scope, right);
|
||||
super::camera_raw_scope_ui::shortcuts(ctx, scope);
|
||||
d.dirty |= dirty;
|
||||
if d.dirty {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Footer.
|
||||
let foot = ERect::from_min_max(pos2(full.left(), full.bottom() - footer_h), full.max);
|
||||
painter.rect_filled(foot, 0.0, t.dock);
|
||||
|
|
@ -434,6 +681,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
|||
widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)"));
|
||||
fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint));
|
||||
});
|
||||
super::camera_raw_scope_ui::persist(app, ctx);
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Y))
|
||||
&& let Some(d) = app.camera_raw.as_mut()
|
||||
{
|
||||
|
|
@ -471,6 +719,83 @@ fn hue_color(h: f32) -> Color32 {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn histogram_follows_preview_and_view_changes_do_not_render_or_edit() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
let initial = menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(initial["histogram"]["samples"], 64 * 48);
|
||||
assert_eq!(initial["histogram"]["red"][128], 64 * 48);
|
||||
let updated = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap();
|
||||
assert_ne!(updated["histogram"]["red"], initial["histogram"]["red"]);
|
||||
let before = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"before": true}})).unwrap().unwrap();
|
||||
assert_eq!(before["histogram"]["source"], "before");
|
||||
assert_eq!(before["histogram"]["red"], initial["histogram"]["red"]);
|
||||
assert_eq!(before["previewRevision"], updated["previewRevision"]);
|
||||
let after = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"before": false, "set": {"exposure": 1}}})).unwrap().unwrap();
|
||||
assert_eq!(after["histogram"], updated["histogram"]);
|
||||
assert_eq!(after["previewRevision"], updated["previewRevision"]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
let pixel = app.session.active().unwrap().doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().rgba(0, 0);
|
||||
assert!((pixel[0] - 128.0 / 255.0).abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_filter_double_click_edits_the_stored_camera_raw_filter_in_its_own_dialog() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
app.run("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
// An older editor could save stacked curve inputs; the filter must stay editable.
|
||||
let legacy = json!([[0.0, 0.0], [60.0, 40.0], [60.0, 200.0], [255.0, 255.0]]);
|
||||
app.run("filter.cameraRaw", json!({"exposure": 1.0, "contrast": 20})).unwrap();
|
||||
app.run("layer.smartFilter.setParams", json!({"index": 0, "params": {"pointCurve": legacy}})).unwrap();
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
let smart = |app: &PhotocraftApp| match &app.session.active().unwrap().doc.layer(layer).unwrap().content {
|
||||
photocraft_doc::LayerContent::Smart(sm) => sm.clone(),
|
||||
_ => panic!("not a smart object"),
|
||||
};
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
|
||||
let sm = smart(&app);
|
||||
crate::smart_ui::open_editor(&mut app, &ctx, layer.0, &sm, 0);
|
||||
assert!(app.ui.dialogs.is_empty(), "no generic parameter form");
|
||||
let opened = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {}})).unwrap().unwrap();
|
||||
assert_eq!(opened["smartFilter"], 0);
|
||||
assert_eq!(opened["params"]["exposure"], 1.0);
|
||||
assert_eq!(opened["params"]["pointCurve"], legacy);
|
||||
// The preview starts from the pixels below the filter, not the filtered cache.
|
||||
let before = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"before": true}})).unwrap().unwrap();
|
||||
assert_eq!(before["histogram"]["red"][128], 64 * 48);
|
||||
|
||||
// Resetting a control to its default must overwrite the stored value.
|
||||
let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 0.0}, "before": false, "commit": true}})).unwrap();
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert!(app.camera_raw.is_none());
|
||||
let sm = smart(&app);
|
||||
assert_eq!(sm.smart_filters.len(), 1, "edited in place, not added");
|
||||
assert_eq!(sm.smart_filters[0].params["exposure"], 0.0);
|
||||
assert_eq!(sm.smart_filters[0].params["contrast"], 20.0);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(smart(&app).smart_filters[0].params["exposure"], 1.0);
|
||||
|
||||
// A broken new curve is still rejected; an unknown index is an error, not a panic.
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": layer.0, "index": 0}})).unwrap().unwrap();
|
||||
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"pointCurve": [[10, 10]]}}})).unwrap().is_err());
|
||||
menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap();
|
||||
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": layer.0, "index": 7}})).unwrap().is_err());
|
||||
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": "x"}})).unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_drives_the_engine() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
|
|
|||
|
|
@ -553,6 +553,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
|||
"brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity},
|
||||
"distort": app.distort.describe(),
|
||||
"jobs": crate::jobs_ui::inspect(app),
|
||||
"cameraRaw": app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2027,3 +2027,29 @@
|
|||
|
||||
Rendering mode Režim vykreslování
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso zmrazení: tažením zmrazíte oblast, tažením s klávesou Alt ji rozmrazíte (L)
|
||||
|
||||
Shadow Clipping Warning (U) Varování oříznutí stínů (U)
|
||||
Highlight Clipping Warning (O) Varování oříznutí světel (O)
|
||||
Show Lab Color Readouts Zobrazit hodnoty barev Lab
|
||||
Show Vectorscope Zobrazit vektorskop
|
||||
Show Red at 3 o'clock Zobrazit červenou na třetí hodině
|
||||
Show Skin Tone Indicator Zobrazit indikátor pleťových tónů
|
||||
Tone Histogram Histogram tónů
|
||||
Show Selected Region Zobrazit vybranou oblast
|
||||
|
||||
cameraRaw Light Světlo
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Filtr Camera Raw ({layer})
|
||||
cameraRaw Red Červené
|
||||
cameraRaw Orange Oranžové
|
||||
cameraRaw Yellow Žluté
|
||||
cameraRaw Green Zelené
|
||||
cameraRaw Aqua Tyrkysové
|
||||
cameraRaw Blue Modré
|
||||
cameraRaw Purple Fialové
|
||||
cameraRaw Magenta Purpurové
|
||||
cameraRaw Blending Míchání
|
||||
cameraRaw Luminance Jas
|
||||
cameraRaw Temperature Barevná teplota
|
||||
cameraRaw Color Barvy
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -2101,3 +2101,32 @@
|
|||
|
||||
Rendering mode Modo de renderizado
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Lazo de congelación: arrastre para congelar un área; Alt+arrastrar para descongelarla (L)
|
||||
|
||||
|
||||
Shadow Clipping Warning (U) Aviso de recorte de sombras (U)
|
||||
Highlight Clipping Warning (O) Aviso de recorte de luces (O)
|
||||
Show Lab Color Readouts Mostrar valores de color Lab
|
||||
Show Vectorscope Mostrar vectorscopio
|
||||
Show Red at 3 o'clock Mostrar rojo a las 3 en punto
|
||||
Show Skin Tone Indicator Mostrar indicador de tono de piel
|
||||
Tone Histogram Histograma tonal
|
||||
Show Selected Region Mostrar región seleccionada
|
||||
|
||||
cameraRaw Light Luz
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Filtro Camera Raw ({layer})
|
||||
cameraRaw Lights Tonos claros
|
||||
cameraRaw Darks Tonos oscuros
|
||||
cameraRaw Red Rojos
|
||||
cameraRaw Orange Naranjas
|
||||
cameraRaw Yellow Amarillos
|
||||
cameraRaw Green Verdes
|
||||
cameraRaw Aqua Aguamarinas
|
||||
cameraRaw Blue Azules
|
||||
cameraRaw Purple Morados
|
||||
cameraRaw Magenta Magentas
|
||||
cameraRaw Blending Mezcla
|
||||
cameraRaw Tint Matiz
|
||||
cameraRaw Temperature Temperatura de color
|
||||
cameraRaw Amount Cantidad
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -2017,3 +2017,22 @@
|
|||
|
||||
Rendering mode レンダリングモード
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) フリーズなげなわ:ドラッグで範囲をフリーズ、Alt+ドラッグで解凍 (L)
|
||||
|
||||
Shadow Clipping Warning (U) シャドウのクリッピング警告 (U)
|
||||
Highlight Clipping Warning (O) ハイライトのクリッピング警告 (O)
|
||||
Show Lab Color Readouts Lab カラー値を表示
|
||||
Show Vectorscope ベクトルスコープを表示
|
||||
Show Red at 3 o'clock 赤を3時の位置に表示
|
||||
Show Skin Tone Indicator スキントーン指標を表示
|
||||
Tone Histogram 階調ヒストグラム
|
||||
Show Selected Region 選択範囲を表示
|
||||
|
||||
cameraRaw Light ライト
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Camera Raw フィルター ({layer})
|
||||
cameraRaw Lights 明部
|
||||
cameraRaw Blending ブレンド
|
||||
cameraRaw Blacks 黒レベル
|
||||
cameraRaw Amount 適用量
|
||||
cameraRaw Color カラー
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -553,4 +553,37 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Camera Raw includes dynamic colour-band labels and contextual labels that the generic
|
||||
/// tl! scanner cannot see. Cover the partial catalog too, without claiming whole-app coverage.
|
||||
#[test]
|
||||
fn camera_raw_labels_are_translated_in_every_available_language() {
|
||||
let sources = [include_str!("../camera_raw_ui.rs"), include_str!("../camera_raw_scope_ui.rs")];
|
||||
let mut labels = std::collections::BTreeSet::new();
|
||||
for source in sources {
|
||||
let code = source.split("#[cfg(test)]").next().unwrap();
|
||||
for marker in ["tl!(\"", "row(ui, &mut dirty, \"", "row(ui, dirty, \"", "section(ui, \"", "wheel(ui, &mut dirty, \"", "=> \""] {
|
||||
for tail in code.split(marker).skip(1) {
|
||||
labels.insert(tail.split('"').next().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let bands = sources[0].split("const BANDS:").nth(1).unwrap().split(" = ").nth(1).unwrap().split(';').next().unwrap();
|
||||
for band in bands.split('"').skip(1).step_by(2) {
|
||||
labels.insert(band);
|
||||
}
|
||||
assert!(labels.len() >= 66, "missing Camera Raw source labels: {labels:?}");
|
||||
for lang in Lang::all().filter(|l| *l != Lang::EN) {
|
||||
let catalog = lang.catalog();
|
||||
let missing: Vec<_> = labels.iter().filter(|s| catalog.contextual("cameraRaw", s).or_else(|| catalog.plain(s)).is_none()).collect();
|
||||
assert!(missing.is_empty(), "{}: Camera Raw labels: {missing:?}", lang.code());
|
||||
assert_ne!(tr_ctx(lang, "cameraRaw", "Highlights"), tr_ctx(lang, "cameraRaw", "Lights"), "{}: distinct curve regions", lang.code());
|
||||
assert_ne!(tr_ctx(lang, "cameraRaw", "Shadows"), tr_ctx(lang, "cameraRaw", "Darks"), "{}: distinct curve regions", lang.code());
|
||||
}
|
||||
let ru = Lang::from_code("ru").unwrap();
|
||||
assert_eq!(tr_ctx(ru, "cameraRaw", "Vibrance"), "Красочность");
|
||||
assert_eq!(tr_ctx(ru, "cameraRaw", "Aqua"), "Голубые");
|
||||
let text = fmt(tr(ru, "Camera Raw Filter ({layer})"), &[("layer", "{Background} 影像")]);
|
||||
assert_eq!(text, "Фильтр Camera Raw ({Background} 影像)", "user layer names are not translated");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2295,3 +2295,38 @@
|
|||
|
||||
Rendering mode Режим отрисовки
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Лассо заморозки: перетащите, чтобы заморозить область; Alt+перетаскивание размораживает (L)
|
||||
|
||||
Shadow Clipping Warning (U) Предупреждение о клиппинге теней (U)
|
||||
Highlight Clipping Warning (O) Предупреждение о клиппинге светов (O)
|
||||
Show Lab Color Readouts Показывать значения Lab
|
||||
Show Vectorscope Показывать вектороскоп
|
||||
Show Red at 3 o'clock Красный в положении «3 часа»
|
||||
Show Skin Tone Indicator Показывать ориентир тона кожи
|
||||
Tone Histogram Тональная гистограмма
|
||||
Show Selected Region Показать выделенную область
|
||||
|
||||
cameraRaw Light Свет
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Фильтр Camera Raw ({layer})
|
||||
Aqua Голубой
|
||||
cameraRaw Amount Интенсивность
|
||||
cameraRaw Blacks Чёрные
|
||||
cameraRaw Vibrance Красочность
|
||||
cameraRaw Color Grading Цветовое тонирование
|
||||
cameraRaw Lights Светлые тона
|
||||
cameraRaw Darks Тёмные тона
|
||||
cameraRaw Global Общее
|
||||
cameraRaw Red Красные
|
||||
cameraRaw Orange Оранжевые
|
||||
cameraRaw Yellow Жёлтые
|
||||
cameraRaw Green Зелёные
|
||||
cameraRaw Aqua Голубые
|
||||
cameraRaw Blue Синие
|
||||
cameraRaw Purple Фиолетовые
|
||||
cameraRaw Magenta Пурпурные
|
||||
cameraRaw Hue Тон
|
||||
cameraRaw Blending Смешивание
|
||||
cameraRaw Luminance Detail Детализация яркости
|
||||
cameraRaw Color Detail Детализация цвета
|
||||
cameraRaw Roughness Неровность
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -1914,3 +1914,24 @@
|
|||
Erase 擦除
|
||||
Right click with painting tools 使用绘画工具时右键单击
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 冻结套索:拖动以冻结区域,按住 Alt 拖动以解冻 (L)
|
||||
|
||||
Shadow Clipping Warning (U) 阴影剪切警告 (U)
|
||||
Highlight Clipping Warning (O) 高光剪切警告 (O)
|
||||
Show Lab Color Readouts 显示 Lab 颜色读数
|
||||
Show Vectorscope 显示矢量示波器
|
||||
Show Red at 3 o'clock 将红色显示在三点钟方向
|
||||
Show Skin Tone Indicator 显示肤色指示线
|
||||
Tone Histogram 色调直方图
|
||||
Show Selected Region 显示选定区域
|
||||
|
||||
cameraRaw Light 光线
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Camera Raw 滤镜({layer})
|
||||
cameraRaw Lights 亮调
|
||||
cameraRaw Darks 暗调
|
||||
cameraRaw Blacks 黑色色阶
|
||||
cameraRaw Amount 强度
|
||||
cameraRaw Tint 色偏
|
||||
cameraRaw Masking 蒙版保护
|
||||
cameraRaw Blending 混合
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -2009,3 +2009,25 @@
|
|||
|
||||
Rendering mode 渲染模式
|
||||
Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 凍結套索:拖曳以凍結區域,按住 Alt 拖曳以解凍 (L)
|
||||
|
||||
Shadow Clipping Warning (U) 陰影剪裁警告 (U)
|
||||
Highlight Clipping Warning (O) 亮部剪裁警告 (O)
|
||||
Show Lab Color Readouts 顯示 Lab 色彩讀值
|
||||
Show Vectorscope 顯示向量示波器
|
||||
Show Red at 3 o'clock 將紅色顯示在三點鐘方向
|
||||
Show Skin Tone Indicator 顯示膚色指示線
|
||||
Tone Histogram 色調直方圖
|
||||
Show Selected Region 顯示選取區域
|
||||
|
||||
cameraRaw Light 光線
|
||||
|
||||
# Camera Raw: contextual controls and window title
|
||||
Camera Raw Filter ({layer}) Camera Raw 濾鏡({layer})
|
||||
cameraRaw Lights 亮調
|
||||
cameraRaw Darks 暗調
|
||||
cameraRaw Blacks 黑色色階
|
||||
cameraRaw Whites 白色色階
|
||||
cameraRaw Amount 強度
|
||||
cameraRaw Tint 色偏
|
||||
cameraRaw Masking 遮色片保護
|
||||
cameraRaw Blending 混合
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -27,6 +27,7 @@ pub mod brush_preview;
|
|||
pub mod brush_resize;
|
||||
pub mod brush_sections;
|
||||
pub mod brushes_tab;
|
||||
mod camera_raw_scope_ui;
|
||||
pub mod camera_raw_ui;
|
||||
pub mod canvas;
|
||||
pub mod channel_view;
|
||||
|
|
@ -84,6 +85,7 @@ pub mod panels;
|
|||
pub mod parity;
|
||||
pub mod perspective_ui;
|
||||
pub mod plugin_ui;
|
||||
pub mod point_curve;
|
||||
pub mod prefs_ui;
|
||||
pub mod preset_files_ui;
|
||||
pub mod preset_panels;
|
||||
|
|
@ -92,6 +94,7 @@ pub mod proxy;
|
|||
pub mod puppet_ui;
|
||||
pub mod rasterize_prompt;
|
||||
pub mod retouch_ui;
|
||||
mod rgb_histogram;
|
||||
pub mod rulers;
|
||||
pub mod scrollbars;
|
||||
pub mod shortcut_dispatch;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
//! Shared point-curve semantics for Camera Raw, adjustment dialogs and layer Properties.
|
||||
//! Hosts own typed gesture state and rendering; this module only edits their point data.
|
||||
use crate::state::{PointCurveDrag, PointCurveState};
|
||||
use egui::{Key, Modifiers, Pos2, Rect, Response, Ui, pos2};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Interaction {
|
||||
pub changed: bool,
|
||||
pub commit: bool,
|
||||
}
|
||||
|
||||
const HIT_RADIUS: f32 = 9.0;
|
||||
/// Interior points disappear outside this margin and return on re-entry during the same drag.
|
||||
const DRAG_OFF: f32 = 12.0;
|
||||
|
||||
/// Most points a curve can hold (Photoshop's limit).
|
||||
pub const MAX_POINTS: usize = 16;
|
||||
/// Points closer than this (input levels) are the same point.
|
||||
pub const MIN_GAP: f32 = 2.0;
|
||||
|
||||
/// Index of the point nearest `pos` (screen) within `radius` pixels.
|
||||
pub fn hit(pts: &[[f32; 2]], to_scr: impl Fn([f32; 2]) -> Pos2, pos: Pos2, radius: f32) -> Option<usize> {
|
||||
pts.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d <= radius).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Adds a point at `v` (kept sorted). A point already at that input is selected instead; a
|
||||
/// full curve takes no more points.
|
||||
pub fn insert(pts: &mut Vec<[f32; 2]>, v: [f32; 2]) -> Option<usize> {
|
||||
if !v.iter().all(|x| x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let v = [v[0].clamp(0.0, 255.0), v[1].clamp(0.0, 255.0)];
|
||||
if let Some(i) = pts.iter().position(|q| (q[0] - v[0]).abs() < MIN_GAP) {
|
||||
return Some(i);
|
||||
}
|
||||
if pts.len() >= MAX_POINTS {
|
||||
return None;
|
||||
}
|
||||
let i = pts.iter().position(|q| q[0] > v[0]).unwrap_or(pts.len());
|
||||
pts.insert(i, v);
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Endpoints stay, and a curve keeps at least two points.
|
||||
pub fn can_delete(pts: &[[f32; 2]], i: usize) -> bool {
|
||||
pts.len() > 2 && i > 0 && i < pts.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
pub fn delete(pts: &mut Vec<[f32; 2]>, i: usize) -> bool {
|
||||
if !can_delete(pts, i) {
|
||||
return false;
|
||||
}
|
||||
pts.remove(i);
|
||||
true
|
||||
}
|
||||
|
||||
/// Moves point `i` to `v`, kept strictly between its neighbours' inputs.
|
||||
pub fn move_to(pts: &mut [[f32; 2]], i: usize, v: [f32; 2]) -> bool {
|
||||
let Some(old) = pts.get(i).copied() else { return false };
|
||||
if !v.iter().chain(old.iter()).all(|x| x.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let lo = i.checked_sub(1).and_then(|j| pts.get(j)).map_or(0.0, |p| p[0] + 1.0);
|
||||
let hi = i.checked_add(1).and_then(|j| pts.get(j)).map_or(255.0, |p| p[0] - 1.0);
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let new = [v[0].round().clamp(lo, hi.max(lo)), v[1].round().clamp(0.0, 255.0)];
|
||||
let changed = new != old;
|
||||
if let Some(point) = pts.get_mut(i) {
|
||||
*point = new;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Capture a point at button-down, preserve its grab offset, and keep it through release.
|
||||
/// `commit` ends a gesture; hosts decide how that becomes document history.
|
||||
pub fn interact(ui: &mut Ui, resp: &Response, graph: Rect, pts: &mut Vec<[f32; 2]>, state: &mut PointCurveState) -> Interaction {
|
||||
if !graph.is_finite() || graph.width() <= 0.0 || graph.height() <= 0.0 {
|
||||
*state = PointCurveState::default();
|
||||
return Interaction::default();
|
||||
}
|
||||
state.selected = state.selected.filter(|i| *i < pts.len());
|
||||
if state.drag.is_some_and(|drag| drag.index.is_some_and(|i| i >= pts.len())) {
|
||||
state.drag = None;
|
||||
}
|
||||
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
|
||||
let to_val =
|
||||
|s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)];
|
||||
let mut e = Interaction::default();
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
if let Some(i) = hit(pts, to_scr, pos, HIT_RADIUS)
|
||||
&& delete(pts, i)
|
||||
{
|
||||
e.changed = true;
|
||||
e.commit = true;
|
||||
}
|
||||
state.selected = None;
|
||||
state.drag = None;
|
||||
}
|
||||
// Press: hit-test where the button went down (not where a drag is first recognised).
|
||||
let press = (resp.is_pointer_button_down_on() || resp.clicked_by(egui::PointerButton::Primary)) && ui.input(|i| i.pointer.primary_pressed());
|
||||
// A press and release may arrive in the same native frame; egui then clears press_origin.
|
||||
// A recognised click still owns its interaction position, so it can select without a drag.
|
||||
let press_pos =
|
||||
ui.input(|i| i.pointer.press_origin()).or_else(|| if resp.clicked_by(egui::PointerButton::Primary) { resp.interact_pointer_pos() } else { None });
|
||||
if press && let Some(pos) = press_pos {
|
||||
resp.request_focus();
|
||||
let delete_pressed = ui.input(|i| i.modifiers.command);
|
||||
match hit(pts, to_scr, pos, HIT_RADIUS) {
|
||||
Some(i) if delete_pressed => {
|
||||
if delete(pts, i) {
|
||||
e.changed = true;
|
||||
e.commit = true;
|
||||
}
|
||||
state.selected = None;
|
||||
state.drag = None;
|
||||
}
|
||||
Some(i) => {
|
||||
state.selected = Some(i);
|
||||
let g = to_val(pos);
|
||||
state.drag = pts.get(i).map(|point| PointCurveDrag { index: Some(i), offset: [point[0] - g[0], point[1] - g[1]], removed: false });
|
||||
}
|
||||
None if graph.contains(pos) && !delete_pressed => {
|
||||
let before = pts.len();
|
||||
state.selected = insert(pts, to_val(pos));
|
||||
e.changed = pts.len() != before;
|
||||
state.drag = state.selected.map(|i| PointCurveDrag { index: Some(i), offset: [0.0, 0.0], removed: false });
|
||||
}
|
||||
None => state.drag = None,
|
||||
}
|
||||
}
|
||||
// Drag: follow the pointer; off the graph the point is removed (back on, it returns).
|
||||
if (resp.is_pointer_button_down_on() || ui.input(|i| i.pointer.primary_released()))
|
||||
&& !press
|
||||
&& let (Some(PointCurveDrag { index, offset: off, removed }), Some(pos)) = (state.drag, ui.input(|i| i.pointer.interact_pos()))
|
||||
{
|
||||
let outside = !graph.expand(DRAG_OFF).contains(pos);
|
||||
let g = to_val(pos);
|
||||
let target = [g[0] + off[0], g[1] + off[1]];
|
||||
match index {
|
||||
Some(i) if outside && can_delete(pts, i) => {
|
||||
delete(pts, i);
|
||||
state.drag = Some(PointCurveDrag { index: None, offset: off, removed: true });
|
||||
state.selected = None;
|
||||
e.changed = true;
|
||||
}
|
||||
Some(i) => e.changed |= move_to(pts, i, target),
|
||||
None if removed && !outside => {
|
||||
state.selected = insert(pts, target);
|
||||
state.drag = Some(PointCurveDrag { index: state.selected, offset: off, removed: true });
|
||||
e.changed |= state.selected.is_some();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if state.drag.is_some() && !resp.is_pointer_button_down_on() {
|
||||
state.drag = None;
|
||||
e.commit = true;
|
||||
}
|
||||
// Arrows edit this graph rather than navigating to another widget on the next frame.
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }));
|
||||
// Keys while the graph has focus: Delete/Backspace removes, arrows nudge (Shift: ×10).
|
||||
if resp.has_focus()
|
||||
&& let Some(i) = state.selected.filter(|i| *i < pts.len())
|
||||
{
|
||||
let del = ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, Key::Delete) || inp.consume_key(Modifiers::NONE, Key::Backspace));
|
||||
if del && delete(pts, i) {
|
||||
state.selected = None;
|
||||
e.changed = true;
|
||||
e.commit = true;
|
||||
} else {
|
||||
let step = if ui.input(|inp| inp.modifiers.shift) { 10.0 } else { 1.0 };
|
||||
let mut d = [0.0f32; 2];
|
||||
for (k, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, 1.0), (Key::ArrowDown, 0.0, -1.0)] {
|
||||
if ui.input_mut(|inp| inp.consume_key(Modifiers::NONE, k) || inp.consume_key(Modifiers::SHIFT, k)) {
|
||||
d = [d[0] + dx * step, d[1] + dy * step];
|
||||
}
|
||||
}
|
||||
let Some(point) = pts.get(i) else { return e };
|
||||
let to = [point[0] + d[0], point[1] + d[1]];
|
||||
if d != [0.0, 0.0] && move_to(pts, i, to) {
|
||||
e.changed = true;
|
||||
e.commit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn line() -> Vec<[f32; 2]> {
|
||||
vec![[0.0, 0.0], [255.0, 255.0]]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_keeps_order_dedupes_and_respects_the_limit() {
|
||||
let mut p = line();
|
||||
assert_eq!(insert(&mut p, [128.0, 150.0]), Some(1));
|
||||
assert_eq!(insert(&mut p, [64.0, 40.0]), Some(1));
|
||||
assert_eq!(p, vec![[0.0, 0.0], [64.0, 40.0], [128.0, 150.0], [255.0, 255.0]]);
|
||||
// A click on an existing input selects that point instead of stacking a second one.
|
||||
assert_eq!(insert(&mut p, [129.0, 10.0]), Some(2));
|
||||
assert_eq!(p.len(), 4);
|
||||
let mut full: Vec<[f32; 2]> = (0..MAX_POINTS).map(|i| [i as f32 * 10.0, 0.0]).collect();
|
||||
assert_eq!(insert(&mut full, [255.0, 0.0]), None);
|
||||
assert_eq!(insert(&mut p, [-50.0, 900.0]), Some(0), "clamped onto the black point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoints_and_the_last_two_points_stay() {
|
||||
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [255.0, 255.0]];
|
||||
assert!(!delete(&mut p, 0) && !delete(&mut p, 2) && !delete(&mut p, 7));
|
||||
assert!(delete(&mut p, 1));
|
||||
assert_eq!(p, line());
|
||||
assert!(!delete(&mut p, 1) && !delete(&mut p, 0));
|
||||
assert_eq!(p.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_stay_between_neighbours() {
|
||||
let mut p = vec![[0.0, 0.0], [100.0, 120.0], [200.0, 180.0], [255.0, 255.0]];
|
||||
assert!(move_to(&mut p, 1, [250.0, 300.0]));
|
||||
assert_eq!(p[1], [199.0, 255.0]);
|
||||
move_to(&mut p, 0, [150.0, 10.0]);
|
||||
assert_eq!(p[0], [150.0, 10.0]);
|
||||
move_to(&mut p, 3, [10.0, 5.0]);
|
||||
assert_eq!(p[3], [201.0, 5.0]);
|
||||
assert!(!move_to(&mut p, 9, [0.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_picks_the_nearest_point_in_radius() {
|
||||
let p = vec![[0.0, 0.0], [100.0, 100.0], [110.0, 110.0], [255.0, 255.0]];
|
||||
let to_scr = |q: [f32; 2]| Pos2::new(q[0], 255.0 - q[1]);
|
||||
assert_eq!(hit(&p, to_scr, Pos2::new(108.0, 146.0), 9.0), Some(2));
|
||||
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_points_and_indices_cannot_panic_or_pollute_a_curve() {
|
||||
let mut p = line();
|
||||
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert_eq!(insert(&mut p, [bad, 128.0]), None);
|
||||
assert!(!move_to(&mut p, 0, [128.0, bad]));
|
||||
}
|
||||
assert!(!can_delete(&p, usize::MAX));
|
||||
assert!(!delete(&mut p, usize::MAX));
|
||||
assert!(!move_to(&mut p, usize::MAX, [128.0, 128.0]));
|
||||
assert_eq!(p, line());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
//! Camera Raw-style continuous RGB ribbons. Raw bins remain exact; display filtering is local.
|
||||
use crate::theme::Tokens;
|
||||
use egui::{Mesh, Rect, pos2};
|
||||
use photocraft_algo::histogram::{BINS, RgbHistogram};
|
||||
|
||||
/// A five-bin binomial display kernel softens quantization combs. It cannot bridge
|
||||
/// a wide empty range. Clipping bins are pinned and excluded from filtering adjacent bins.
|
||||
fn curves(h: &RgbHistogram) -> [[f32; BINS]; 3] {
|
||||
let mut curves = [[0.0; BINS]; 3];
|
||||
for (out, bins) in curves.iter_mut().zip(&h.channels) {
|
||||
for (i, value) in out.iter_mut().enumerate() {
|
||||
let center = bins.get(i).copied().unwrap_or(0) as f32;
|
||||
if i == 0 || i == BINS - 1 {
|
||||
*value = center;
|
||||
} else {
|
||||
let sample = |j: usize| bins.get(j.clamp(1, BINS - 2)).copied().unwrap_or(0) as f32;
|
||||
*value = (sample(i.saturating_sub(2)) + 4.0 * sample(i - 1) + 6.0 * center + 4.0 * sample(i + 1) + sample(i + 2)) / 16.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// One scale for RGB. Edge clipping spikes must not flatten all interior tonal detail;
|
||||
// preserve them at the edges, capped at the top. A flat black/white image uses its edge peak.
|
||||
let interior = curves.iter().flat_map(|c| c.iter().skip(1).take(BINS - 2)).copied().fold(0.0f32, f32::max);
|
||||
let peak = if interior > 0.0 { interior } else { curves.iter().flatten().copied().fold(0.0f32, f32::max) };
|
||||
let scale = if peak > 0.0 { peak * 1.1 } else { 1.0 };
|
||||
for value in curves.iter_mut().flatten() {
|
||||
*value = (*value / scale).min(1.0);
|
||||
}
|
||||
curves
|
||||
}
|
||||
|
||||
fn quad(mesh: &mut Mesh, rect: Rect, x: [f32; 2], bottom: [f32; 2], top: [f32; 2], color: egui::Color32) {
|
||||
let vertex = mesh.vertices.len() as u32; // Bounded: at most 257 spans × 4 cuts × 3 bands × 4 vertices.
|
||||
for (x, height) in [(x[0], bottom[0]), (x[0], top[0]), (x[1], top[1]), (x[1], bottom[1])] {
|
||||
mesh.colored_vertex(pos2(x, rect.bottom() - height * rect.height()), color);
|
||||
}
|
||||
mesh.add_triangle(vertex, vertex + 1, vertex + 2);
|
||||
mesh.add_triangle(vertex, vertex + 2, vertex + 3);
|
||||
}
|
||||
|
||||
/// Split where two RGB curves cross, so every subspan has a stable channel order. This gives
|
||||
/// exact overlap colours without draw-order-dependent alpha stacking or cracks between bars.
|
||||
fn span(mesh: &mut Mesh, rect: Rect, x: [f32; 2], a: [f32; 3], b: [f32; 3], t: &Tokens) {
|
||||
let mut cuts = [0.0f32, 1.0, 1.0, 1.0, 1.0];
|
||||
let mut n = 2;
|
||||
for (i, (ai, bi)) in a.iter().zip(&b).enumerate() {
|
||||
for (aj, bj) in a.iter().zip(&b).skip(i + 1) {
|
||||
let denominator = (bi - ai) - (bj - aj);
|
||||
if denominator.abs() > f32::EPSILON {
|
||||
let crossing = (aj - ai) / denominator;
|
||||
if crossing > 0.0
|
||||
&& crossing < 1.0
|
||||
&& let Some(cut) = cuts.get_mut(n)
|
||||
{
|
||||
*cut = crossing;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(cuts) = cuts.get_mut(..n) else { return };
|
||||
cuts.sort_unstable_by(f32::total_cmp);
|
||||
for cut in cuts.windows(2) {
|
||||
let (start, end) = (cut[0], cut[1]);
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let left = std::array::from_fn::<_, 3, _>(|c| a[c] + (b[c] - a[c]) * start);
|
||||
let right = std::array::from_fn::<_, 3, _>(|c| a[c] + (b[c] - a[c]) * end);
|
||||
let mut levels = [(0usize, 1u8), (1, 2), (2, 4)];
|
||||
levels.sort_unstable_by(|(i, _), (j, _)| (left[*i] + right[*i]).total_cmp(&(left[*j] + right[*j])));
|
||||
let x = [x[0] + (x[1] - x[0]) * start, x[0] + (x[1] - x[0]) * end];
|
||||
let mut floor = [0.0; 2];
|
||||
let mut mask = 7;
|
||||
for (channel, bit) in levels {
|
||||
let top = [left[channel], right[channel]];
|
||||
if top[0] > floor[0] || top[1] > floor[1] {
|
||||
quad(mesh, rect, x, floor, top, t.histogram_fill(mask));
|
||||
}
|
||||
floor = top;
|
||||
mask &= !bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(rect: Rect, curves: &[[f32; BINS]; 3], t: &Tokens) -> Mesh {
|
||||
let mut mesh = Mesh::default();
|
||||
if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() {
|
||||
return mesh;
|
||||
}
|
||||
let mut previous_x = rect.left();
|
||||
let mut previous = curves.map(|channel| channel[0]);
|
||||
for (i, ((r, g), b)) in curves[0].iter().zip(&curves[1]).zip(&curves[2]).enumerate() {
|
||||
let x = rect.left() + (i as f32 + 0.5) / BINS as f32 * rect.width();
|
||||
let next = [*r, *g, *b];
|
||||
span(&mut mesh, rect, [previous_x, x], previous, next, t);
|
||||
previous_x = x;
|
||||
previous = next;
|
||||
}
|
||||
span(&mut mesh, rect, [previous_x, rect.right()], previous, previous, t);
|
||||
mesh
|
||||
}
|
||||
|
||||
pub(crate) fn paint(painter: &egui::Painter, rect: Rect, h: &RgbHistogram, t: &Tokens) {
|
||||
if !rect.is_finite() || !rect.is_positive() || !rect.size().is_finite() {
|
||||
return;
|
||||
}
|
||||
let curves = curves(h);
|
||||
painter.add(fill(rect, &curves, t));
|
||||
for (channel, values) in curves.iter().enumerate() {
|
||||
if !values.iter().any(|&v| v > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let mut points = Vec::with_capacity(BINS + 2);
|
||||
points.push(pos2(rect.left(), rect.bottom() - values[0] * rect.height()));
|
||||
for (i, v) in values.iter().enumerate() {
|
||||
points.push(pos2(rect.left() + (i as f32 + 0.5) / BINS as f32 * rect.width(), rect.bottom() - v * rect.height()));
|
||||
}
|
||||
points.push(pos2(rect.right(), rect.bottom() - values[BINS - 1] * rect.height()));
|
||||
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0, t.histogram_color(1 << channel))));
|
||||
}
|
||||
}
|
||||
|
||||
/// One neutral tone marker, using the same RGB weights as Camera Raw's tone controls.
|
||||
/// This is a constant-size overlay, independent of bin counting and normalization.
|
||||
pub(crate) fn paint_sample(painter: &egui::Painter, rect: Rect, rgb: [f32; 3], t: &Tokens) {
|
||||
if !rect.is_finite() || !rect.is_positive() || !rgb.iter().all(|x| x.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let painter = painter.with_clip_rect(rect);
|
||||
let [r, g, b] = rgb.map(|v| v.clamp(0.0, 1.0));
|
||||
let tone = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
let x = rect.left() + tone * rect.width();
|
||||
let color = t.histogram_color(7);
|
||||
let left = (x - 1.0).round();
|
||||
let band = Rect::from_min_max(pos2(left, rect.top()), pos2(left + 2.0, rect.bottom()));
|
||||
painter.rect_filled(band, 0.0, color.gamma_multiply(0.40));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mesh(rect: Rect, h: &RgbHistogram, t: &Tokens) -> Mesh {
|
||||
fill(rect, &curves(h), t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
fn color_at(mesh: &Mesh, point: egui::Pos2) -> Option<egui::Color32> {
|
||||
mesh.indices.as_chunks::<3>().0.iter().find_map(|triangle| {
|
||||
let a = mesh.vertices[triangle[0] as usize].pos;
|
||||
let b = mesh.vertices[triangle[1] as usize].pos;
|
||||
let c = mesh.vertices[triangle[2] as usize].pos;
|
||||
let cross = |u: egui::Vec2, v: egui::Vec2| u.x * v.y - u.y * v.x;
|
||||
if cross(b - a, c - a).abs() < 0.00001 {
|
||||
return None;
|
||||
}
|
||||
let sides = [cross(b - a, point - a), cross(c - b, point - b), cross(a - c, point - c)];
|
||||
(sides.iter().all(|&s| s >= -0.00001) || sides.iter().all(|&s| s <= 0.00001)).then_some(mesh.vertices[triangle[0] as usize].color)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_scale_and_overlaps_are_exact_for_every_theme() {
|
||||
let h = RgbHistogram { channels: [[3; BINS], [2; BINS], [1; BINS]], ..Default::default() };
|
||||
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 110.0));
|
||||
for kind in ThemeKind::ALL {
|
||||
let t = Tokens::for_kind(kind);
|
||||
let m = mesh(rect, &h, &t);
|
||||
for (y, mask) in [(100.0, 7), (60.0, 3), (20.0, 1)] {
|
||||
assert_eq!(color_at(&m, pos2(128.7, y)), Some(t.histogram_fill(mask)));
|
||||
}
|
||||
assert!(m.vertices.iter().all(|v| rect.contains(v.pos)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_overlaps_and_curve_crossings_have_order_independent_colours() {
|
||||
let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 100.0));
|
||||
let mut h = RgbHistogram::default();
|
||||
for mask in 1..=7 {
|
||||
for (c, bins) in h.channels.iter_mut().enumerate() {
|
||||
bins[mask * 30..mask * 30 + 10].fill(u64::from(mask & (1 << c) != 0));
|
||||
}
|
||||
}
|
||||
for kind in ThemeKind::ALL {
|
||||
let t = Tokens::for_kind(kind);
|
||||
let m = mesh(rect, &h, &t);
|
||||
for mask in 1..=7 {
|
||||
assert_eq!(color_at(&m, pos2((mask * 30 + 5) as f32 + 0.5, 95.0)), Some(t.histogram_fill(mask as u8)));
|
||||
}
|
||||
let mut m = Mesh::default();
|
||||
span(&mut m, rect, [0.0, 256.0], [1.0, 0.25, 0.75], [0.25, 1.0, 0.75], &t);
|
||||
for (x, y, mask) in [(25.6, 90.0, 7), (25.6, 50.0, 5), (25.6, 10.0, 1), (230.4, 50.0, 6), (230.4, 10.0, 2)] {
|
||||
assert_eq!(color_at(&m, pos2(x, y)), Some(t.histogram_fill(mask)));
|
||||
}
|
||||
// Triangle interiors never stack: alpha fill must be independent of the channel order.
|
||||
let area: f32 = m
|
||||
.indices
|
||||
.as_chunks::<3>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|indices| {
|
||||
let a = m.vertices[indices[0] as usize].pos;
|
||||
let b = m.vertices[indices[1] as usize].pos;
|
||||
let c = m.vertices[indices[2] as usize].pos;
|
||||
((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() * 0.5
|
||||
})
|
||||
.sum();
|
||||
assert!((area - 256.0 * 100.0 * (5.0 / 6.0)).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_spikes_do_not_flatten_or_bleed_into_interior_detail() {
|
||||
let mut h = RgbHistogram { channels: [[10; BINS]; 3], ..Default::default() };
|
||||
h.channels[0][0] = 1_000_000;
|
||||
h.channels[1][255] = 1_000_000;
|
||||
let p = curves(&h);
|
||||
assert_eq!(p[0][0], 1.0);
|
||||
assert_eq!(p[1][255], 1.0);
|
||||
assert!((p[0][1] - 1.0 / 1.1).abs() < 0.0001);
|
||||
assert!((p[1][254] - 1.0 / 1.1).abs() < 0.0001);
|
||||
let mut h = RgbHistogram::default();
|
||||
h.channels[0][255] = 100;
|
||||
assert!(curves(&h)[0][255] > 0.9);
|
||||
assert_eq!(curves(&h)[0][254], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_bad_rectangles_draw_no_ribbons() {
|
||||
let t = Tokens::for_kind(ThemeKind::Pro);
|
||||
let h = RgbHistogram::default();
|
||||
assert!(mesh(Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 100.0)), &h, &t).is_empty());
|
||||
assert!(mesh(Rect::NOTHING, &h, &t).is_empty());
|
||||
}
|
||||
fn covers(mesh: &Mesh, point: egui::Pos2) -> bool {
|
||||
color_at(mesh, point).is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periodic_quantization_gaps_do_not_make_a_sawtooth_contour() {
|
||||
let mut h = RgbHistogram::default();
|
||||
for channel in &mut h.channels {
|
||||
for i in (30..220).step_by(3) {
|
||||
channel[i] = 100;
|
||||
}
|
||||
}
|
||||
let p = curves(&h);
|
||||
let low = p[0][40..210].iter().copied().fold(f32::INFINITY, f32::min);
|
||||
let high = p[0][40..210].iter().copied().fold(0.0f32, f32::max);
|
||||
assert!(high - low < 0.2, "quantization comb still dominates the contour: {}", high - low);
|
||||
assert_eq!(p[0][240], 0.0, "wide empty tonal ranges must stay empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_quantization_gaps_do_not_cut_the_ribbon_into_bars() {
|
||||
let mut h = RgbHistogram::default();
|
||||
for channel in &mut h.channels {
|
||||
channel[119] = 100;
|
||||
channel[121] = 100;
|
||||
}
|
||||
let original = h.clone();
|
||||
let m = mesh(Rect::from_min_max(pos2(0.0, 0.0), pos2(256.0, 100.0)), &h, &Tokens::for_kind(ThemeKind::Pro));
|
||||
assert!(covers(&m, pos2(120.5, 85.0)), "isolated empty bin creates a full-height black stripe");
|
||||
assert!(!covers(&m, pos2(180.5, 85.0)), "a real empty tonal range must stay empty");
|
||||
assert_eq!(h, original, "display filtering must never change raw statistics");
|
||||
}
|
||||
}
|
||||
|
|
@ -228,6 +228,12 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu
|
|||
}
|
||||
|
||||
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
// Camera Raw is modal like Photoshop's filter dialog: no application shortcut (Save, Undo,
|
||||
// tools) runs beneath it, and it handles its own keys (Y, U, O, S). Unlike the other dialogs
|
||||
// below it covers the canvas, so canvas zoom keys are blocked too.
|
||||
if app.camera_raw.is_some() {
|
||||
return;
|
||||
}
|
||||
// An open menu owns the keyboard (arrows, ↩, Esc), like a native menu.
|
||||
if crate::menu_nav::is_open(ctx) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -77,15 +77,23 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth:
|
|||
if let Some(i) = index
|
||||
&& resp.double_clicked()
|
||||
{
|
||||
open_editor(app, l.id.0, sm, i);
|
||||
open_editor(app, ui.ctx(), l.id.0, sm, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-opens a smart filter's dialog with its recorded parameters; OK runs
|
||||
/// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`).
|
||||
pub fn open_editor(app: &mut PhotocraftApp, layer: u64, sm: &SmartObject, index: usize) {
|
||||
pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) {
|
||||
let Some(f) = sm.smart_filters.get(index) else { return };
|
||||
// Camera Raw has its own full-window dialog rather than a generic parameter form.
|
||||
if f.command == photocraft_engine::lens_cmds::RAW {
|
||||
if let Err(e) = crate::camera_raw_ui::open_smart_filter(app, ctx, photocraft_doc::LayerId(layer), index) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !crate::filter_dialog::has_dialog(&f.command) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,47 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Shared selection and capture for a point curve; document parameters remain with the host.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct PointCurveState {
|
||||
pub selected: Option<usize>,
|
||||
/// Captured point (None while removed outside), grab offset, and re-entry eligibility.
|
||||
pub drag: Option<PointCurveDrag>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct PointCurveDrag {
|
||||
pub index: Option<usize>,
|
||||
pub offset: [f32; 2],
|
||||
pub removed: bool,
|
||||
}
|
||||
|
||||
/// Curves editor memory (egui temp data): the edited channel and the point gesture.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct CurvesEditorState {
|
||||
pub channel: usize,
|
||||
pub gesture: PointCurveState,
|
||||
}
|
||||
|
||||
/// Camera Raw scope preferences are view state, never filter parameters or document history.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct CameraRawScopeState {
|
||||
pub shadows: bool,
|
||||
pub highlights: bool,
|
||||
pub lab: bool,
|
||||
pub sampler_tool: bool,
|
||||
pub samplers: Vec<[f32; 2]>,
|
||||
pub vectorscope: bool,
|
||||
pub selected_region: bool,
|
||||
pub red_right: bool,
|
||||
pub hide_skin_line: bool,
|
||||
pub floating: bool,
|
||||
pub floating_rect: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Tool {
|
||||
Move,
|
||||
|
|
@ -675,6 +716,8 @@ pub struct UiState {
|
|||
/// Status bar info field, Home screen (see `chrome_ui`).
|
||||
#[serde(default)]
|
||||
pub chrome: crate::chrome_ui::ChromeState,
|
||||
#[serde(default)]
|
||||
pub camera_raw_scope: CameraRawScopeState,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
|
@ -728,6 +771,7 @@ impl Default for UiState {
|
|||
notices: Vec::new(),
|
||||
gpu_fallback_notice: None,
|
||||
chrome: Default::default(),
|
||||
camera_raw_scope: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,10 @@ pub struct Tokens {
|
|||
pub tab_strip: Color32,
|
||||
/// Selected list row (layers, history).
|
||||
pub row_selected: Color32,
|
||||
/// Scope plot background (histogram, vectorscope, clipping diagnostics).
|
||||
pub histogram_bg: Color32,
|
||||
/// Channel intensity of scope outlines and hue markers.
|
||||
pub histogram_level: u8,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
|
|
@ -166,6 +170,8 @@ impl Tokens {
|
|||
pro: true,
|
||||
tab_strip: Color32::from_rgb(38, 38, 38),
|
||||
row_selected: Color32::from_rgb(82, 82, 82),
|
||||
histogram_bg: Color32::from_rgb(40, 40, 40),
|
||||
histogram_level: 225,
|
||||
},
|
||||
ThemeKind::Studio => Tokens {
|
||||
kind,
|
||||
|
|
@ -200,6 +206,8 @@ impl Tokens {
|
|||
pro: false,
|
||||
tab_strip: Color32::TRANSPARENT,
|
||||
row_selected: Color32::TRANSPARENT,
|
||||
histogram_bg: Color32::from_rgb(14, 14, 15),
|
||||
histogram_level: 225,
|
||||
},
|
||||
ThemeKind::StudioLight => Tokens {
|
||||
kind,
|
||||
|
|
@ -234,6 +242,8 @@ impl Tokens {
|
|||
pro: false,
|
||||
tab_strip: Color32::TRANSPARENT,
|
||||
row_selected: Color32::TRANSPARENT,
|
||||
histogram_bg: Color32::from_gray(40),
|
||||
histogram_level: 240,
|
||||
},
|
||||
ThemeKind::Classic => Tokens {
|
||||
kind,
|
||||
|
|
@ -268,6 +278,8 @@ impl Tokens {
|
|||
pro: false,
|
||||
tab_strip: Color32::from_rgb(212, 208, 200),
|
||||
row_selected: Color32::from_rgb(10, 36, 106),
|
||||
histogram_bg: Color32::from_gray(40),
|
||||
histogram_level: 240,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -277,6 +289,30 @@ impl Tokens {
|
|||
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::new("photocraft-theme"))).unwrap_or_else(|| Tokens::for_kind(ThemeKind::Studio))
|
||||
}
|
||||
|
||||
/// Thin RGB outlines; the filled bands use the same hues with subdued coverage.
|
||||
pub fn histogram_color(&self, mask: u8) -> Color32 {
|
||||
let v = self.histogram_level;
|
||||
Color32::from_rgb(if mask & 1 != 0 { v } else { 0 }, if mask & 2 != 0 { v } else { 0 }, if mask & 4 != 0 { v } else { 0 })
|
||||
}
|
||||
|
||||
pub fn histogram_fill(&self, mask: u8) -> Color32 {
|
||||
self.histogram_color(mask).gamma_multiply(0.3)
|
||||
}
|
||||
|
||||
/// Analysis colours are semantic hues, independent of the application accent palette.
|
||||
pub fn scope_hue(&self, h: f32, s: f32) -> Color32 {
|
||||
let v = f32::from(self.histogram_level) / 255.0;
|
||||
let f = |offset: f32| {
|
||||
let k = (offset + h * 6.0).rem_euclid(6.0);
|
||||
(v * (1.0 - s * k.min(4.0 - k).clamp(0.0, 1.0)) * 255.0) as u8
|
||||
};
|
||||
Color32::from_rgb(f(5.0), f(3.0), f(1.0))
|
||||
}
|
||||
|
||||
pub fn histogram_background(&self) -> Color32 {
|
||||
self.histogram_bg
|
||||
}
|
||||
|
||||
pub fn dark(&self) -> bool {
|
||||
matches!(self.kind, ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -341,6 +341,17 @@ fn button_impl(ui: &mut Ui, label: &str, min_width: f32, bg: Color32, fg: Color3
|
|||
resp
|
||||
}
|
||||
|
||||
/// Cached RGB data; drawing work is bounded by the 256 display bins, never the image size.
|
||||
pub fn rgb_histogram(ui: &mut Ui, histogram: &photocraft_algo::histogram::RgbHistogram, height: f32) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, response) = ui.allocate_exact_size(egui::vec2(ui.available_width().max(1.0), height.max(1.0)), egui::Sense::hover());
|
||||
ui.painter().rect_filled(rect, t.radius_sm, t.histogram_background());
|
||||
let plot = rect.shrink(4.0);
|
||||
crate::rgb_histogram::paint(ui.painter(), plot, histogram, &t);
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
response
|
||||
}
|
||||
|
||||
/// Small caps section label.
|
||||
pub fn section_label(ui: &mut Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
//! Camera Raw through the real app shell and its existing agent entry point.
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn invoke(h: &mut Harness<'_, PhotocraftApp>, params: Value) -> Value {
|
||||
let ctx = h.ctx.clone();
|
||||
let (request, _) = control::ControlRequest::new("ui.menu.invoke", json!({"id": "filter.cameraRaw", "params": params}));
|
||||
let outcome = control::handle(h.state_mut(), &ctx, &request);
|
||||
assert!(matches!(outcome, control::Outcome::Done(_)), "dialog control failed");
|
||||
control::inspect(h.state(), &ctx)["cameraRaw"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_stays_fixed_and_resize_hover_scroll_before_do_not_rebuild_the_proxy() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.set_theme(&cc.egui_ctx, theme);
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#708090"})).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
let original = invoke(&mut h, json!({}));
|
||||
h.run_steps(3);
|
||||
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "Tone Histogram");
|
||||
let header = h.query_by_label(label).expect("histogram graph").rect();
|
||||
h.event(Event::PointerMoved(pos2(1050.0, 400.0)));
|
||||
h.event(Event::MouseWheel { unit: MouseWheelUnit::Point, phase: egui::TouchPhase::Move, delta: vec2(0.0, -320.0), modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.query_by_label(label).expect("histogram graph").rect(), header, "{theme:?}: histogram scrolled");
|
||||
h.set_size(vec2(960.0, 600.0));
|
||||
h.set_pixels_per_point(2.0);
|
||||
h.run_steps(3);
|
||||
let before = invoke(&mut h, json!({"ui": {"before": true}}));
|
||||
h.run_steps(3);
|
||||
let inspected = control::inspect(h.state(), &h.ctx);
|
||||
assert_eq!(before["previewRevision"], original["previewRevision"]);
|
||||
assert_eq!(inspected["cameraRaw"]["previewRevision"], original["previewRevision"]);
|
||||
assert_eq!(before["histogram"]["red"], original["histogram"]["red"]);
|
||||
assert!(h.query_by_label(label).expect("histogram graph").rect().bottom() < 600.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_matches_visible_preview_at_all_supported_depths_and_cancel_commit_are_reversible() {
|
||||
for mode in ["rgb", "grayscale"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 16, "height": 8, "mode": mode, "depth": depth})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#808080"})).unwrap();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let steps = app.session.active().unwrap().history.past_len();
|
||||
let initial = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(initial["histogram"]["samples"], 128);
|
||||
let changed = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}}})).unwrap().unwrap();
|
||||
assert_ne!(changed["histogram"]["red"], initial["histogram"]["red"]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps);
|
||||
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"cancel": true}})).unwrap().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original));
|
||||
camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"exposure": 1.0}, "commit": true}})).unwrap().unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), steps + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &original));
|
||||
app.run("edit.redo", json!({})).unwrap();
|
||||
let committed = camera_raw_ui::menu(&mut app, &ctx, "filter.cameraRaw", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(committed["histogram"]["red"], changed["histogram"]["red"], "{mode}/{depth}: graph and committed pixels disagree");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_sections_group_existing_controls_without_rendering_or_document_edits() {
|
||||
assert_eq!(photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::Lang::from_code("ru").unwrap(), "cameraRaw", "Light"), "Свет");
|
||||
for theme in ThemeKind::ALL {
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 900.0)).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.set_theme(&cc.egui_ctx, theme);
|
||||
app.run("file.new", json!({"width":64,"height":48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#708090"})).unwrap();
|
||||
app
|
||||
});
|
||||
let before = invoke(&mut h, json!({}));
|
||||
let doc = h.state().session.active().unwrap().doc.clone();
|
||||
h.run_steps(4);
|
||||
let lang = photocraft_ui_egui::i18n::current();
|
||||
let light = photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", "Light");
|
||||
// The dialog translates through the `cameraRaw` context; match it in any system language.
|
||||
let tr = |s| photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", s);
|
||||
let mut y = 0.0;
|
||||
for label in [light, tr("Color"), tr("Effects"), tr("Curve"), tr("Color Mixer"), tr("Color Grading"), tr("Detail")] {
|
||||
let next = h.get_by_label(label).rect().top();
|
||||
assert!(next > y, "section order: {label}");
|
||||
y = next;
|
||||
}
|
||||
assert!(h.query_by_label(tr("Basic")).is_none());
|
||||
assert!(h.query_by_label(tr("Temperature")).is_none());
|
||||
assert!(h.query_by_label(tr("Texture")).is_none());
|
||||
assert!(h.query_by_label(tr("Exposure")).is_some());
|
||||
h.get_by_label(light).click();
|
||||
h.run_steps(12);
|
||||
h.get_by_label(tr("Color")).click();
|
||||
h.run_steps(12);
|
||||
for label in ["Temperature", "Tint", "Vibrance", "Saturation"] {
|
||||
assert!(h.query_by_label(tr(label)).is_some(), "color control: {label}");
|
||||
}
|
||||
assert!(h.query_by_label(tr("Exposure")).is_none());
|
||||
h.get_by_label(tr("Color")).click();
|
||||
h.run_steps(12);
|
||||
h.get_by_label(tr("Effects")).click();
|
||||
h.run_steps(12);
|
||||
for label in ["Texture", "Clarity", "Dehaze", "Vignetting", "Grain"] {
|
||||
assert!(h.query_by_label(tr(label)).is_some(), "effects control: {label}");
|
||||
}
|
||||
let after = control::inspect(h.state(), &h.ctx);
|
||||
assert_eq!(after["cameraRaw"]["params"], before["params"]);
|
||||
assert_eq!(after["cameraRaw"]["previewRevision"], before["previewRevision"]);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
//! Camera Raw labels and language changes through the real shared native/WASM shell.
|
||||
use egui::{pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, i18n, theme::ThemeKind};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn all_seven_languages_render_camera_raw_and_switch_without_resetting_view_or_filter() {
|
||||
for lang in i18n::Lang::all() {
|
||||
let gpu = std::env::var_os("PHOTOCRAFT_LOCALE_SNAPSHOTS").is_some();
|
||||
let builder = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1440.0, 1000.0));
|
||||
let builder = if gpu { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":64,"height":48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#80ff00"})).unwrap();
|
||||
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, ThemeKind::Pro);
|
||||
h.state_mut().session.prefs.edit(|p| p.interface.language = lang.code().into());
|
||||
h.run_steps(4);
|
||||
let tr = |s| i18n::tr_ctx(lang, "cameraRaw", s);
|
||||
let before = control::inspect(h.state(), &ctx)["cameraRaw"].clone();
|
||||
for section in ["Light", "Color", "Effects", "Curve", "Color Mixer", "Color Grading", "Detail"] {
|
||||
assert!(h.query_by_label(tr(section)).is_some(), "{}: {section}", lang.code());
|
||||
}
|
||||
assert!(h.query_by_label(tr("Blacks")).is_some());
|
||||
h.get_by_label(tr("Light")).click();
|
||||
h.run_steps(12);
|
||||
h.get_by_label(tr("Color")).click();
|
||||
h.run_steps(12);
|
||||
for label in ["White Balance: As Shot", "Temperature", "Tint", "Vibrance", "Saturation"] {
|
||||
assert!(h.query_by_label(tr(label)).is_some(), "{}: {label}", lang.code());
|
||||
}
|
||||
h.get_by_label(tr("Color")).click();
|
||||
h.run_steps(12);
|
||||
h.get_by_label(tr("Color Mixer")).click();
|
||||
h.run_steps(12);
|
||||
for tab in ["Hue", "Saturation", "Luminance"] {
|
||||
assert!(h.get_by_label(tr(tab)).rect().right() <= 1426.0, "{}: mixer tabs overflow the panel", lang.code());
|
||||
}
|
||||
for band in ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"] {
|
||||
assert!(h.query_by_label(tr(band)).is_some(), "{}: {band}", lang.code());
|
||||
}
|
||||
h.get_by_label(tr("Color Mixer")).click();
|
||||
h.run_steps(12);
|
||||
for (section, labels) in [
|
||||
("Effects", vec!["Texture", "Clarity", "Dehaze", "Vignetting", "Grain", "Roughness"]),
|
||||
("Curve", vec!["Highlights", "Lights", "Darks", "Shadows"]),
|
||||
("Color Grading", vec!["Midtones", "Global", "Hue", "Saturation", "Luminance", "Blending", "Balance"]),
|
||||
("Detail", vec!["Sharpening", "Masking", "Noise Reduction", "Luminance Detail", "Color Detail"]),
|
||||
] {
|
||||
h.query_all_by_label(tr(section)).next().unwrap().click();
|
||||
h.run_steps(12);
|
||||
for label in labels {
|
||||
assert!(h.query_all_by_label(tr(label)).next().is_some(), "{}: {section}/{label}", lang.code());
|
||||
}
|
||||
// Detail is also the sharpening slider label; the section header comes first.
|
||||
h.query_all_by_label(tr(section)).next().unwrap().click();
|
||||
h.run_steps(12);
|
||||
}
|
||||
h.get_by_label(tr("Color Mixer")).click();
|
||||
h.run_steps(12);
|
||||
let graph = h.get_by_label(tr("Tone Histogram")).rect();
|
||||
h.event(egui::Event::PointerMoved(graph.center()));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: graph.center(), button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
for label in ["Show Lab Color Readouts", "Show Vectorscope"] {
|
||||
assert!(h.query_by_label(tr(label)).is_some(), "{}: scope menu/{label}", lang.code());
|
||||
}
|
||||
h.get_by_label(tr("Show Vectorscope")).click();
|
||||
h.run_steps(3);
|
||||
// The context menu stays open for its dependent scope options.
|
||||
for label in ["Show Red at 3 o'clock", "Show Skin Tone Indicator"] {
|
||||
assert!(h.query_by_label(tr(label)).is_some(), "{}: scope menu/{label}", lang.code());
|
||||
}
|
||||
let outside = pos2(100.0, 400.0);
|
||||
h.event(egui::Event::PointerMoved(outside));
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: outside, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
let preview = control::inspect(h.state(), &ctx)["cameraRaw"]["previewRect"].clone();
|
||||
h.hover_at(pos2((preview[0].as_f64().unwrap() + preview[2].as_f64().unwrap()) as f32 * 0.5, 400.0));
|
||||
h.run_steps(3);
|
||||
if let Ok(dir) = std::env::var("PHOTOCRAFT_LOCALE_SNAPSHOTS") {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.render().unwrap().save(std::path::Path::new(&dir).join(format!("{}.png", lang.code()))).unwrap();
|
||||
}
|
||||
// Changing labels must keep the same collapse IDs and open mixer, without an image edit.
|
||||
h.state_mut().session.prefs.edit(|p| p.interface.language = "en".into());
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Aqua").is_some(), "{} → en: preserve the open section", lang.code());
|
||||
assert!(h.query_by_label("Exposure").is_none(), "{} → en: Light stays collapsed", lang.code());
|
||||
let after = control::inspect(h.state(), &ctx)["cameraRaw"].clone();
|
||||
assert_eq!(after["params"], before["params"]);
|
||||
assert_eq!(after["previewRevision"], before["previewRevision"]);
|
||||
assert_eq!(after["histogram"], before["histogram"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,752 @@
|
|||
//! Real dialog/controller acceptance: interactions never make a second document edit path.
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, p: Value) -> Result<Value, String> {
|
||||
camera_raw_ui::menu(app, ctx, "filter.cameraRaw", &p).ok_or("missing handler")?
|
||||
}
|
||||
fn inspect(h: &Harness<'_, PhotocraftApp>) -> Value {
|
||||
control::inspect(h.state(), &h.ctx)["cameraRaw"].clone()
|
||||
}
|
||||
fn rect(h: &Harness<'_, PhotocraftApp>, field: &str) -> Rect {
|
||||
let v = inspect(h);
|
||||
let a = v[field].as_array().unwrap();
|
||||
Rect::from_min_max(pos2(a[0].as_f64().unwrap() as f32, a[1].as_f64().unwrap() as f32), pos2(a[2].as_f64().unwrap() as f32, a[3].as_f64().unwrap() as f32))
|
||||
}
|
||||
fn fixture(theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
|
||||
fixture_render(theme, false)
|
||||
}
|
||||
fn fixture_render(theme: ThemeKind, gpu: bool) -> Harness<'static, PhotocraftApp> {
|
||||
let builder = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0));
|
||||
let builder = if gpu { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.set_theme(&cc.egui_ctx, theme);
|
||||
app.run("file.new", json!({"width":64,"height":48})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#80ff00"})).unwrap();
|
||||
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
// Startup preferences can replace the initial theme; apply the requested test theme after it.
|
||||
let ctx = h.ctx.clone();
|
||||
h.state_mut().set_theme(&ctx, theme);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.theme, theme);
|
||||
h
|
||||
}
|
||||
fn press(h: &mut Harness<'_, PhotocraftApp>, p: Pos2, pressed: bool, modifiers: Modifiers) {
|
||||
h.event(Event::ModifiersChanged(modifiers));
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers });
|
||||
h.run_steps(2);
|
||||
}
|
||||
fn drag(h: &mut Harness<'_, PhotocraftApp>, a: Pos2, b: Pos2) {
|
||||
press(h, a, true, Modifiers::NONE);
|
||||
for n in 1..=4 {
|
||||
h.event(Event::PointerMoved(a + (b - a) * n as f32 / 4.0));
|
||||
h.run_steps(2);
|
||||
}
|
||||
press(h, b, false, Modifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mixed_controller_requests_leave_dialog_preferences_and_document_unchanged() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let ctx = h.ctx.clone();
|
||||
let before = inspect(&h);
|
||||
let preferences = h.state().session.prefs().dialogs.clone();
|
||||
let document = h.state().session.active().unwrap().doc.clone();
|
||||
for request in [
|
||||
json!({"set":{"exposure":1.0},"before":true,"scope":{"sample":[-1,0]}}),
|
||||
json!({"set":{"exposure":1.0},"scope":{"floatingRect":[0,0,1,1]}}),
|
||||
json!({"set":{"exposure":1.0},"scope":{"unknown":true}}),
|
||||
json!({"set":{"unknown":1.0}}),
|
||||
json!({"before":"yes"}),
|
||||
json!({"commit":true,"cancel":true}),
|
||||
json!({"unknown":true}),
|
||||
] {
|
||||
assert!(menu(h.state_mut(), &ctx, json!({"ui":request})).is_err());
|
||||
assert_eq!(inspect(&h), before);
|
||||
assert_eq!(h.state().session.prefs().dialogs, preferences);
|
||||
assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &document));
|
||||
}
|
||||
let result = menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":1.0},"scope":{"tone":{"zone":"exposure","delta":0.5}}}})).unwrap();
|
||||
assert_eq!(result["params"]["exposure"], 1.5);
|
||||
assert_eq!(result["previewRevision"].as_u64().unwrap(), before["previewRevision"].as_u64().unwrap() + 1, "one completed preview per transaction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_requests_do_not_open_a_dialog_or_replace_scope_preferences() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"cancel":true}})).unwrap();
|
||||
let view = h.state().ui.camera_raw_scope.clone();
|
||||
let prefs = h.state().session.prefs().dialogs.clone();
|
||||
for request in
|
||||
[json!(null), json!([]), json!(1), json!({"ui":[]}), json!({"ui":{"set":{"pointCurve":vec![[0,0];17]}}}), json!({"ui":{"scope":{"unknown":true}}})]
|
||||
{
|
||||
assert!(menu(h.state_mut(), &ctx, request).is_err());
|
||||
assert!(inspect(&h).is_null());
|
||||
assert_eq!(h.state().ui.camera_raw_scope, view);
|
||||
assert_eq!(h.state().session.prefs().dialogs, prefs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_commit_keeps_the_dialog_and_filter_settings_for_retry() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":0.5}}})).unwrap();
|
||||
h.state_mut().run("layer.lockLayers", json!({"pixels":true})).unwrap();
|
||||
let document = h.state().session.active().unwrap().doc.clone();
|
||||
assert!(menu(h.state_mut(), &ctx, json!({"ui":{"commit":true}})).is_err());
|
||||
assert_eq!(inspect(&h)["params"]["exposure"], 0.5);
|
||||
assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &document));
|
||||
h.state_mut().run("layer.lockLayers", json!({"pixels":false})).unwrap();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"commit":true}})).unwrap();
|
||||
assert!(inspect(&h).is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_curves_fail_in_both_preview_and_engine_without_an_edit() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let ctx = h.ctx.clone();
|
||||
let before = inspect(&h);
|
||||
let document = h.state().session.active().unwrap().doc.clone();
|
||||
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
|
||||
for points in [json!([[255, 255], [0, 0]]), json!([[0, 0], [0, 128]]), json!([[0, -1], [255, 255]]), json!(vec![[0, 0]; 17])] {
|
||||
let settings = json!({field:points});
|
||||
assert!(menu(h.state_mut(), &ctx, json!({"ui":{"set":settings}})).is_err());
|
||||
assert!(h.state_mut().run("filter.cameraRaw", settings).is_err());
|
||||
assert_eq!(inspect(&h), before);
|
||||
assert!(std::sync::Arc::ptr_eq(&h.state().session.active().unwrap().doc, &document));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_clipping_rgb_lab_capped_samplers_and_reversible_tone_controls() {
|
||||
for mode in ["rgb", "grayscale"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":16,"height":8,"mode":mode,"depth":depth})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
let initial = menu(&mut app, &ctx, json!({})).unwrap();
|
||||
for (zone, amount) in [("blacks", 20.0), ("shadows", 20.0), ("exposure", 0.5), ("highlights", 20.0), ("whites", 20.0)] {
|
||||
let change = menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":zone,"delta":amount}}}})).unwrap();
|
||||
assert_eq!(change["params"][zone], amount);
|
||||
assert!(change["previewRevision"].as_u64().unwrap() > initial["previewRevision"].as_u64().unwrap());
|
||||
menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":zone,"reset":true}}}})).unwrap();
|
||||
}
|
||||
for i in 0..9 {
|
||||
menu(&mut app, &ctx, json!({"ui":{"scope":{"addSampler":[i as f32/9.0,0.5]}}})).unwrap();
|
||||
}
|
||||
let reading = menu(&mut app, &ctx, json!({"ui":{"scope":{"lab":true,"sample":[0.5,0.5],"vectorscope":true}}})).unwrap();
|
||||
assert_eq!(reading["pointerReadout"]["space"], "lab");
|
||||
assert_eq!(reading["samplerReadouts"].as_array().unwrap().len(), 9);
|
||||
assert!(reading["pointerReadout"]["values"][0].as_f64().unwrap() > 45.0);
|
||||
for bad in [
|
||||
json!({"addSampler":[0.5,0.5]}),
|
||||
json!({"sample":[-1,0]}),
|
||||
json!({"sample":[1e30,0]}),
|
||||
json!({"samplers":vec![[0.5,0.5];10]}),
|
||||
json!({"removeSampler":999}),
|
||||
json!({"tone":{"zone":"nonsense","delta":1}}),
|
||||
json!({"floatingRect":[0,0,1,1]}),
|
||||
json!({"unknown":true}),
|
||||
] {
|
||||
assert!(menu(&mut app, &ctx, json!({"ui":{"scope":bad}})).is_err());
|
||||
}
|
||||
let no_op = menu(&mut app, &ctx, json!({"ui":{"scope":{"lab":true,"redRight":true,"hideSkinLine":true,"floating":true}}})).unwrap();
|
||||
assert_eq!(no_op["previewRevision"], reading["previewRevision"]);
|
||||
assert_eq!(no_op["scopeRevision"], reading["scopeRevision"]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
|
||||
menu(&mut app, &ctx, json!({"ui":{"cancel":true}})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
|
||||
menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":"exposure","delta":0.5}},"commit":true}})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history + 1);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_readouts_respect_embedded_rgb_profile() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":4,"height":4})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#ff0000"})).unwrap();
|
||||
let srgb = menu(&mut app, &ctx, json!({"ui":{"scope":{"sample":[0.5,0.5],"lab":true}}})).unwrap();
|
||||
menu(&mut app, &ctx, json!({"ui":{"cancel":true}})).unwrap();
|
||||
app.run("edit.assignProfile", json!({"profile":"display-p3"})).unwrap();
|
||||
let p3 = menu(&mut app, &ctx, json!({"ui":{"scope":{"sample":[0.5,0.5],"lab":true}}})).unwrap();
|
||||
let a = srgb["pointerReadout"]["values"][1].as_f64().unwrap();
|
||||
let b = p3["pointerReadout"]["values"][1].as_f64().unwrap();
|
||||
assert!((a - b).abs() > 5.0, "Lab must use document ICC: {a} vs {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_drag_hover_warnings_sampler_motion_and_floating_scope_across_themes() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let mut h = fixture(theme);
|
||||
let initial = inspect(&h);
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let graph = rect(&h, "scopeRect");
|
||||
for fraction in [0.05, 0.18, 0.5, 0.82, 0.95] {
|
||||
let start = pos2(graph.left() + graph.width() * fraction, graph.center().y);
|
||||
drag(&mut h, start, start + vec2(18.0, 0.0));
|
||||
}
|
||||
let after = inspect(&h);
|
||||
for zone in ["blacks", "shadows", "exposure", "highlights", "whites"] {
|
||||
assert!(after["params"][zone].as_f64().unwrap() > 0.0, "{theme:?}/{zone}");
|
||||
}
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"samplerTool":true,"vectorscope":true,"shadows":true,"highlights":true}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
let revision = inspect(&h)["previewRevision"].clone();
|
||||
let scope_revision = inspect(&h)["scopeRevision"].clone();
|
||||
let preview = rect(&h, "previewRect");
|
||||
let a = preview.center();
|
||||
let b = a + vec2(70.0, 30.0);
|
||||
press(&mut h, a, true, Modifiers::NONE);
|
||||
press(&mut h, a, false, Modifiers::NONE);
|
||||
assert_eq!(inspect(&h)["scope"]["samplers"].as_array().unwrap().len(), 1, "{theme:?}: sample click");
|
||||
drag(&mut h, a, b);
|
||||
assert!(inspect(&h)["scope"]["samplers"][0][0].as_f64().unwrap() > 0.5);
|
||||
h.event(Event::PointerMoved(preview.center()));
|
||||
h.run_steps(3);
|
||||
assert!(!inspect(&h)["pointerReadout"].is_null());
|
||||
assert_eq!(inspect(&h)["previewRevision"], revision);
|
||||
assert_eq!(inspect(&h)["scopeRevision"], scope_revision);
|
||||
press(&mut h, b, true, Modifiers { alt: true, ..Modifiers::NONE });
|
||||
press(&mut h, b, false, Modifiers { alt: true, ..Modifiers::NONE });
|
||||
assert!(inspect(&h)["scope"]["samplers"].as_array().unwrap().is_empty());
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":true,"floatingRect":[50,70,400,400]}}})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(inspect(&h)["scope"]["floating"], true);
|
||||
h.set_size(vec2(960.0, 600.0));
|
||||
h.set_pixels_per_point(2.0);
|
||||
h.run_steps(3);
|
||||
assert_eq!(inspect(&h)["previewRevision"], revision);
|
||||
assert_eq!(inspect(&h)["scopeRevision"], scope_revision);
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":false}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(inspect(&h)["scope"]["floating"], false);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
assert_eq!(initial["histogram"]["shadows"][2], 64 * 48);
|
||||
assert_eq!(initial["histogram"]["highlights"][1], 64 * 48);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_shortcuts_triangles_menu_and_scope_preferences_roundtrip() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let revision = inspect(&h)["previewRevision"].clone();
|
||||
for (key, field) in [(egui::Key::U, "shadows"), (egui::Key::O, "highlights"), (egui::Key::S, "samplerTool")] {
|
||||
h.key_press(key);
|
||||
h.run_steps(2);
|
||||
assert_eq!(inspect(&h)["scope"][field], true, "{field} shortcut");
|
||||
h.key_press(key);
|
||||
h.run_steps(2);
|
||||
assert_eq!(inspect(&h)["scope"][field], false);
|
||||
}
|
||||
let graph = rect(&h, "scopeRect");
|
||||
for (p, field) in [(graph.min + vec2(9.0, 9.0), "shadows"), (pos2(graph.right() - 9.0, graph.top() + 9.0), "highlights")] {
|
||||
press(&mut h, p, true, Modifiers::NONE);
|
||||
press(&mut h, p, false, Modifiers::NONE);
|
||||
assert_eq!(inspect(&h)["scope"][field], true, "{field} triangle");
|
||||
}
|
||||
h.event(Event::PointerMoved(graph.center()));
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: graph.center(), button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "Show Lab Color Readouts");
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(inspect(&h)["scope"]["lab"], true);
|
||||
assert_eq!(inspect(&h)["previewRevision"], revision);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":true,"floatingRect":[60,90,400,420],"vectorscope":true}}})).unwrap();
|
||||
h.run_steps(4);
|
||||
let saved = h.state().session.prefs().dialogs.get("filter.cameraRaw.scope").unwrap().clone();
|
||||
assert_eq!(saved["floating"], true);
|
||||
assert_eq!(saved["samplers"], json!([]));
|
||||
assert_eq!(saved["vectorscope"], false);
|
||||
let prefs = serde_json::to_string(h.state().session.prefs()).unwrap();
|
||||
let mut fresh = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
fresh.session.load_prefs_json(&prefs).unwrap();
|
||||
fresh.session.prefs.edit(|p| p.dialogs.get_mut("filter.cameraRaw.scope").unwrap()["vectorscope"] = json!(true));
|
||||
fresh.run("file.new", json!({"width":16,"height":16})).unwrap();
|
||||
fresh.run("layer.new.layer", json!({})).unwrap();
|
||||
menu(&mut fresh, &egui::Context::default(), json!({})).unwrap();
|
||||
assert!(!fresh.ui.camera_raw_scope.vectorscope, "vectorscope stays opt-in when reopening Camera Raw");
|
||||
assert!(fresh.ui.camera_raw_scope.floating);
|
||||
assert!(fresh.ui.camera_raw_scope.lab);
|
||||
assert_eq!(fresh.ui.camera_raw_scope.floating_rect, h.state().ui.camera_raw_scope.floating_rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_region_scope_and_preview_share_the_engine_selection_boundary() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":16,"height":8})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
|
||||
app.run("select.rect", json!({"x":0,"y":0,"width":8,"height":8})).unwrap();
|
||||
let original = app.session.active().unwrap().doc.clone();
|
||||
menu(&mut app, &ctx, json!({})).unwrap();
|
||||
let full = menu(&mut app, &ctx, json!({"ui":{"set":{"exposure":1.0},"scope":{"vectorscope":true}}})).unwrap();
|
||||
assert_eq!(full["vectorscope"]["samples"], 128);
|
||||
let selected = menu(&mut app, &ctx, json!({"ui":{"scope":{"selectedRegion":true}}})).unwrap();
|
||||
assert_eq!(selected["vectorscope"]["samples"], 64);
|
||||
assert_eq!(selected["previewRevision"], full["previewRevision"]);
|
||||
assert_ne!(selected["scopeRevision"], full["scopeRevision"]);
|
||||
let before = menu(&mut app, &ctx, json!({"ui":{"before":true}})).unwrap();
|
||||
assert_eq!(before["vectorscope"]["before"], true);
|
||||
let after = menu(&mut app, &ctx, json!({"ui":{"before":false}})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &app.session.active().unwrap().doc));
|
||||
// Atomic validation: a bad property must not apply the valid tone delta or probe.
|
||||
assert!(menu(&mut app, &ctx, json!({"ui":{"scope":{"tone":{"zone":"exposure","delta":1},"sample":[0.5,0.5],"zzz":true}}})).is_err());
|
||||
let unchanged = control::inspect(&app, &ctx)["cameraRaw"].clone();
|
||||
assert_eq!(unchanged["params"], after["params"]);
|
||||
assert_eq!(unchanged["scope"], after["scope"]);
|
||||
assert_eq!(unchanged["pointerReadout"], after["pointerReadout"]);
|
||||
menu(&mut app, &ctx, json!({"ui":{"commit":true}})).unwrap();
|
||||
let committed = menu(&mut app, &ctx, json!({})).unwrap();
|
||||
assert_eq!(committed["histogram"], after["histogram"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_window_is_above_the_dialog_and_can_be_dragged_back_to_the_dock() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
// No heading, grab control or undock/sample actions in the context menu.
|
||||
assert!(h.query_by_label("Histogram").is_none());
|
||||
let graph = rect(&h, "scopeRect");
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerMoved(graph.center()));
|
||||
h.event(Event::PointerButton { pos: graph.center(), button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
for source in ["Undock Scope", "Dock Scope", "Color Sampler (S)", "Clear Samplers"] {
|
||||
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), source);
|
||||
assert!(h.query_by_label(label).is_none(), "removed action must not appear: {source}");
|
||||
}
|
||||
assert!(!h.state().ui.camera_raw_scope.vectorscope);
|
||||
let label = photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "Show Vectorscope");
|
||||
h.get_by_label(label).click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.camera_raw_scope.vectorscope, "context menu still enables the optional vectorscope");
|
||||
press(&mut h, pos2(200.0, 400.0), true, Modifiers::NONE);
|
||||
press(&mut h, pos2(200.0, 400.0), false, Modifiers::NONE);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floating":true,"floatingRect":[60,90,400,420]}}})).unwrap();
|
||||
h.run_steps(5);
|
||||
let r = inspect(&h)["scope"]["floatingRect"].clone();
|
||||
let title = pos2(r[0].as_f64().unwrap() as f32 + 70.0, r[1].as_f64().unwrap() as f32 + 12.0);
|
||||
let revision = inspect(&h)["previewRevision"].clone();
|
||||
drag(&mut h, title, title + vec2(120.0, 50.0));
|
||||
let moved = inspect(&h)["scope"]["floatingRect"].clone();
|
||||
assert!(moved[0].as_f64().unwrap() > r[0].as_f64().unwrap() + 80.0, "window must receive title drag above the full-screen dialog");
|
||||
assert_eq!(inspect(&h)["scope"]["floating"], true);
|
||||
// Resize the real floating window; geometry-only work keeps the image cache.
|
||||
let corner = pos2(moved[2].as_f64().unwrap() as f32 - 1.0, moved[3].as_f64().unwrap() as f32 - 1.0);
|
||||
drag(&mut h, corner, corner + vec2(50.0, 40.0));
|
||||
let resized = inspect(&h)["scope"]["floatingRect"].clone();
|
||||
assert!(resized[2].as_f64().unwrap() > moved[2].as_f64().unwrap() + 20.0);
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"floatingRect":[180,100,530,440]}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
let moved = inspect(&h)["scope"]["floatingRect"].clone();
|
||||
assert!((moved[0].as_f64().unwrap() - 180.0).abs() < 2.0, "controller must reposition an already open panel: {moved}");
|
||||
assert!((moved[2].as_f64().unwrap() - 530.0).abs() < 4.0, "controller must resize an already open panel");
|
||||
let title = pos2(moved[0].as_f64().unwrap() as f32 + 70.0, moved[1].as_f64().unwrap() as f32 + 12.0);
|
||||
drag(&mut h, title, pos2(1000.0, 130.0));
|
||||
assert_eq!(inspect(&h)["scope"]["floating"], false, "release over dock");
|
||||
assert_eq!(inspect(&h)["previewRevision"], revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_tone_diagnostics_and_double_click_reset_follow_the_same_proxy_revision() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":0.5}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(inspect(&h)["overlayRevision"].is_null());
|
||||
let graph = rect(&h, "scopeRect");
|
||||
let p = graph.center();
|
||||
let alt = Modifiers { alt: true, ..Modifiers::NONE };
|
||||
press(&mut h, p, true, alt);
|
||||
h.event(Event::PointerMoved(p + vec2(15.0, 0.0)));
|
||||
h.run_steps(4);
|
||||
assert_eq!(inspect(&h)["overlayRevision"], inspect(&h)["previewRevision"], "Alt drag generates clipping diagnostics for the current image");
|
||||
press(&mut h, p + vec2(15.0, 0.0), false, Modifiers::NONE);
|
||||
// Reset through actual double-clicks, after waiting beyond the preceding gesture.
|
||||
h.run_steps(30);
|
||||
for _ in 0..2 {
|
||||
press(&mut h, p, true, Modifiers::NONE);
|
||||
press(&mut h, p, false, Modifiers::NONE);
|
||||
}
|
||||
assert_eq!(inspect(&h)["params"]["exposure"], 0.0);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_clipping_warnings_leave_unclipped_preview_pixels_visible_on_gpu() {
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(800.0, 600.0)).wgpu().build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.set_theme(&cc.egui_ctx, ThemeKind::Pro);
|
||||
app.run("file.new", json!({"width":16,"height":16})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
|
||||
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(5);
|
||||
let center = rect(&h, "previewRect").center();
|
||||
let pixel = (center.x as u32, center.y as u32);
|
||||
let original = h.render().unwrap().get_pixel(pixel.0, pixel.1).0;
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"shadows":true,"highlights":true}}})).unwrap();
|
||||
h.run_steps(4);
|
||||
let with_warnings = h.render().unwrap().get_pixel(pixel.0, pixel.1).0;
|
||||
assert_eq!(with_warnings, original, "both warnings must preserve an unclipped image pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_drag_moves_the_existing_point_without_creating_another() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let graph = open_curve(&mut h);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"pointCurve":[[0,0],[128,128],[255,255]]}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
let a = pos2(graph.left() + 128.0 / 255.0 * 260.0, graph.bottom() - 128.0 / 255.0 * 260.0);
|
||||
let b = a + vec2(40.0, -32.0);
|
||||
drag(&mut h, a, b);
|
||||
let points = inspect(&h)["params"]["pointCurve"].clone();
|
||||
assert_eq!(points.as_array().unwrap().len(), 3, "drag must keep the original point count: {points}");
|
||||
assert!(points[1][0].as_f64().unwrap() > 155.0, "existing point must move horizontally: {points}");
|
||||
assert!(points[1][1].as_f64().unwrap() > 150.0, "existing point must move vertically: {points}");
|
||||
}
|
||||
|
||||
fn open_curve(h: &mut Harness<'_, PhotocraftApp>) -> Rect {
|
||||
let lang = photocraft_ui_egui::i18n::current();
|
||||
h.get_by_label(photocraft_ui_egui::i18n::tr_ctx(lang, "cameraRaw", "Light")).click();
|
||||
h.run_steps(16);
|
||||
h.get_by_label(photocraft_ui_egui::i18n::tr(lang, "Curve")).click();
|
||||
h.run_steps(16);
|
||||
rect(h, "curveRect")
|
||||
}
|
||||
fn curve_at(graph: Rect, point: [f32; 2]) -> Pos2 {
|
||||
pos2(graph.left() + point[0] / 255.0 * graph.width(), graph.bottom() - point[1] / 255.0 * graph.height())
|
||||
}
|
||||
fn set_curve(h: &mut Harness<'_, PhotocraftApp>, points: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"pointCurve":points}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
}
|
||||
fn curve_points(h: &Harness<'_, PhotocraftApp>) -> Value {
|
||||
inspect(h)["params"]["pointCurve"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controller_click_delivered_in_one_raw_frame_selects_an_existing_curve_point() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let graph = open_curve(&mut h);
|
||||
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
|
||||
let at = curve_at(graph, [128.0, 128.0]);
|
||||
let ctx = h.ctx.clone();
|
||||
let (request, _) = control::ControlRequest::new("ui.click", json!({"x":at.x,"y":at.y}));
|
||||
assert!(matches!(control::handle(h.state_mut(), &ctx, &request), control::Outcome::AfterInput));
|
||||
// Harness::event splits a press/release pair across frames. Feed the controller's actual
|
||||
// raw-input batch to egui to cover the native rapid-click boundary instead.
|
||||
let input = egui::RawInput { screen_rect: Some(ctx.content_rect()), events: h.state_mut().take_synthetic_step(), ..Default::default() };
|
||||
let mut output = ctx.run_ui(input, |ui| camera_raw_ui::show(h.state_mut(), ui.ctx()));
|
||||
// This CPU interaction assertion intentionally does not present a frame.
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(inspect(&h)["curveState"]["selected"], 1);
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_capture_handles_fast_and_slow_motion_without_jumps_in_every_theme() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let mut h = fixture(theme);
|
||||
let graph = open_curve(&mut h);
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
for steps in [1, 16] {
|
||||
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
|
||||
let a = curve_at(graph, [128.0, 128.0]) + vec2(4.0, 3.0);
|
||||
let b = a + vec2(40.0, -32.0);
|
||||
let revision = inspect(&h)["previewRevision"].clone();
|
||||
press(&mut h, a, true, Modifiers::NONE);
|
||||
assert_eq!(curve_points(&h), json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]), "press must preserve the grab offset");
|
||||
assert_eq!(inspect(&h)["previewRevision"], revision, "selection alone does not rebuild the image");
|
||||
for i in 1..=steps {
|
||||
h.event(Event::PointerMoved(a + (b - a) * i as f32 / steps as f32));
|
||||
h.run_steps(1);
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 3);
|
||||
assert_eq!(inspect(&h)["curveState"]["drag"]["index"], 1, "capture survives every frame");
|
||||
}
|
||||
press(&mut h, b, false, Modifiers::NONE);
|
||||
let points = curve_points(&h);
|
||||
assert!((points[1][0].as_f64().unwrap() - (128.0 + 40.0 / graph.width() as f64 * 255.0)).abs() < 1.0);
|
||||
assert!((points[1][1].as_f64().unwrap() - (128.0 + 32.0 / graph.height() as f64 * 255.0)).abs() < 1.0);
|
||||
assert!(inspect(&h)["curveState"]["drag"].is_null());
|
||||
}
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"commit":true}})).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_deletion_reentry_keyboard_limits_and_controller_replacement_share_capture() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let graph = open_curve(&mut h);
|
||||
let original = h.state().session.active().unwrap().doc.clone();
|
||||
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
|
||||
let a = curve_at(graph, [128.0, 128.0]);
|
||||
let outside = graph.right_center() + vec2(30.0, 0.0);
|
||||
press(&mut h, a, true, Modifiers::NONE);
|
||||
h.event(Event::PointerMoved(outside));
|
||||
h.run_steps(2);
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 2);
|
||||
let b = curve_at(graph, [150.0, 160.0]);
|
||||
h.event(Event::PointerMoved(b));
|
||||
h.run_steps(2);
|
||||
assert_eq!(curve_points(&h), json!([[0.0, 0.0], [150.0, 160.0], [255.0, 255.0]]));
|
||||
press(&mut h, b, false, Modifiers::NONE);
|
||||
h.key_press(egui::Key::ArrowUp);
|
||||
h.run_steps(3);
|
||||
assert_eq!(curve_points(&h)[1][1].as_f64(), Some(161.0));
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 2);
|
||||
// Same-frame native/controller clicks also insert; right-click deletes independently.
|
||||
for button in [PointerButton::Primary, PointerButton::Secondary] {
|
||||
h.event(Event::PointerMoved(a));
|
||||
h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.event(Event::PointerButton { pos: a, button, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), if button == PointerButton::Primary { 3 } else { 2 });
|
||||
}
|
||||
set_curve(&mut h, json!([[0.0, 0.0], [128.0, 128.0], [255.0, 255.0]]));
|
||||
press(&mut h, a, true, Modifiers::NONE);
|
||||
// A controller replacing points must cancel the old capture even with the same length.
|
||||
set_curve(&mut h, json!([[0.0, 0.0], [80.0, 170.0], [255.0, 255.0]]));
|
||||
assert!(inspect(&h)["curveState"]["drag"].is_null());
|
||||
press(&mut h, b, false, Modifiers::NONE);
|
||||
assert_eq!(curve_points(&h), json!([[0.0, 0.0], [80.0, 170.0], [255.0, 255.0]]));
|
||||
set_curve(&mut h, json!([[0.0, 0.0], [100.0, 120.0], [200.0, 180.0], [255.0, 255.0]]));
|
||||
drag(&mut h, curve_at(graph, [100.0, 120.0]), curve_at(graph, [240.0, 220.0]));
|
||||
assert_eq!(curve_points(&h)[1][0].as_f64(), Some(199.0), "point cannot pass its neighbour");
|
||||
drag(&mut h, graph.left_bottom(), graph.left_top() + vec2(-30.0, -30.0));
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 4, "endpoint cannot be removed by dragging off");
|
||||
let full: Vec<_> = (0..16).map(|i| [i * 17, i * 17]).collect();
|
||||
set_curve(&mut h, json!(full));
|
||||
let p = curve_at(graph, [100.0, 180.0]);
|
||||
press(&mut h, p, true, Modifiers::NONE);
|
||||
press(&mut h, p, false, Modifiers::NONE);
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 16);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"cancel":true}})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &h.state().session.active().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_gpu_paints_the_moved_handle_in_every_theme() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let mut h = fixture_render(theme, true);
|
||||
let graph = open_curve(&mut h);
|
||||
set_curve(&mut h, json!([[0, 0], [128, 128], [255, 255]]));
|
||||
let before = h.render().unwrap();
|
||||
let a = curve_at(graph, [128.0, 128.0]);
|
||||
drag(&mut h, a, a + vec2(40.0, -32.0));
|
||||
assert_eq!(curve_points(&h).as_array().unwrap().len(), 3);
|
||||
let points = curve_points(&h);
|
||||
let b = curve_at(graph, [points[1][0].as_f64().unwrap() as f32, points[1][1].as_f64().unwrap() as f32]);
|
||||
let after = h.render().unwrap();
|
||||
assert_ne!(
|
||||
before.get_pixel(b.x.round() as u32, b.y.round() as u32),
|
||||
after.get_pixel(b.x.round() as u32, b.y.round() as u32),
|
||||
"moved handle must appear in the native GPU frame"
|
||||
);
|
||||
if let Ok(dir) = std::env::var("PHOTOCRAFT_CURVE_SNAPSHOTS") {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
after.save(std::path::Path::new(&dir).join(format!("{theme:?}.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_hover_tracks_displayed_rgb_before_after_and_keeps_analysis_cached() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":true,"lab":true}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
let preview = rect(&h, "previewRect");
|
||||
let initial = inspect(&h);
|
||||
assert!(initial["hoverSample"].is_null());
|
||||
h.hover_at(preview.center());
|
||||
h.run_steps(3);
|
||||
let sample = inspect(&h);
|
||||
assert!(!sample["hoverSample"].is_null(), "ordinary hover works with sampler tool disabled");
|
||||
assert_eq!(sample["scope"]["samplerTool"], false);
|
||||
let rgb = &sample["hoverSample"]["rgb"];
|
||||
assert!((rgb[0].as_f64().unwrap() - 128.0 / 255.0).abs() < 0.0001);
|
||||
assert_eq!(rgb[1].as_f64(), Some(1.0));
|
||||
assert_eq!(rgb[2].as_f64(), Some(0.0));
|
||||
assert_eq!(sample["pointerReadout"]["space"], "lab", "badge RGB stays independent of Lab probe preferences");
|
||||
assert!((sample["hoverSample"]["hueSaturation"][0].as_f64().unwrap() - 0.25).abs() < 0.003);
|
||||
assert_eq!(sample["hoverSample"]["hueSaturation"][1].as_f64(), Some(1.0));
|
||||
for n in 1..=8 {
|
||||
h.hover_at(preview.center() + vec2(n as f32, n as f32));
|
||||
h.run_steps(1);
|
||||
}
|
||||
let moved = inspect(&h);
|
||||
assert_eq!(moved["previewRevision"], initial["previewRevision"]);
|
||||
assert_eq!(moved["scopeRevision"], initial["scopeRevision"]);
|
||||
assert_eq!(moved["histogram"], initial["histogram"]);
|
||||
assert_eq!(moved["vectorscope"], initial["vectorscope"]);
|
||||
let history = h.state().session.active().unwrap().history.past_len();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"set":{"exposure":0.5}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_ne!(inspect(&h)["hoverSample"]["rgb"], sample["hoverSample"]["rgb"]);
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"before":true}})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(inspect(&h)["hoverSample"]["rgb"], sample["hoverSample"]["rgb"]);
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
|
||||
h.hover_at(pos2(500.0, 20.0));
|
||||
h.run_steps(3);
|
||||
assert!(inspect(&h)["hoverSample"].is_null(), "leaving the image clears overlays instead of retaining a stale pixel");
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":false}}})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(inspect(&h)["vectorscopeRect"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_hover_gpu_badge_single_band_and_crosshair_appear_and_clear_in_all_themes() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let mut h = fixture_render(theme, true);
|
||||
let ctx = h.ctx.clone();
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"vectorscope":true}}})).unwrap();
|
||||
h.run_steps(4);
|
||||
let histogram = rect(&h, "scopeRect");
|
||||
let scope = rect(&h, "vectorscopeRect");
|
||||
let image = rect(&h, "previewRect");
|
||||
let no_hover = h.render().unwrap();
|
||||
h.hover_at(image.center());
|
||||
h.run_steps(3);
|
||||
let hovered = h.render().unwrap();
|
||||
// RGB text/badge occupies the top centre; one tone marker crosses the histogram.
|
||||
let area_changed = |r: Rect| {
|
||||
(r.top().ceil() as u32..r.bottom().floor() as u32)
|
||||
.any(|y| (r.left().ceil() as u32..r.right().floor() as u32).any(|x| no_hover.get_pixel(x, y) != hovered.get_pixel(x, y)))
|
||||
};
|
||||
let badge = Rect::from_center_size(pos2(histogram.center().x, histogram.top() + 13.0), vec2(140.0, 18.0));
|
||||
let bands = Rect::from_min_max(pos2(histogram.left() + 4.0, histogram.center().y), pos2(histogram.right() - 4.0, histogram.bottom() - 4.0));
|
||||
assert!(area_changed(badge), "{theme:?}: RGB badge");
|
||||
assert!(area_changed(bands), "{theme:?}: translucent tone band");
|
||||
let y = histogram.center().y as u32;
|
||||
let changed: Vec<_> =
|
||||
(histogram.left().ceil() as u32..histogram.right().floor() as u32).filter(|&x| no_hover.get_pixel(x, y) != hovered.get_pixel(x, y)).collect();
|
||||
let first = *changed.first().expect("visible tone marker");
|
||||
let last = *changed.last().unwrap();
|
||||
assert_eq!(last - first, 1, "{theme:?}: exactly one two-pixel band, without a wide or blurred halo: {changed:?}");
|
||||
assert_eq!(changed.len(), (last - first + 1) as usize, "{theme:?}: one connected band");
|
||||
let rgb = inspect(&h)["hoverSample"]["rgb"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>();
|
||||
let tone = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
||||
let plot = histogram.shrink(4.0);
|
||||
let expected = plot.left() as f64 + tone * plot.width() as f64;
|
||||
assert!((f64::from(first + last) * 0.5 - expected).abs() <= 1.0, "{theme:?}: marker follows the pixel tone");
|
||||
assert!(area_changed(scope), "{theme:?}: vectorscope target");
|
||||
let bright_target = (scope.top().ceil() as u32..scope.bottom().floor() as u32).any(|y| {
|
||||
(scope.left().ceil() as u32..scope.right().floor() as u32).any(|x| {
|
||||
let before = no_hover.get_pixel(x, y).0;
|
||||
let after = hovered.get_pixel(x, y).0;
|
||||
let luminance = |p: [u8; 4]| p.iter().take(3).map(|c| u16::from(*c)).sum::<u16>();
|
||||
luminance(after) > 600 && luminance(before) < 450
|
||||
})
|
||||
});
|
||||
assert!(bright_target, "{theme:?}: target must contrast against the dark scope even in light themes");
|
||||
if let Ok(dir) = std::env::var("PHOTOCRAFT_HOVER_SNAPSHOTS") {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
hovered.save(std::path::Path::new(&dir).join(format!("{theme:?}.png"))).unwrap();
|
||||
}
|
||||
h.hover_at(pos2(500.0, 20.0));
|
||||
h.run_steps(3);
|
||||
let cleared = h.render().unwrap();
|
||||
for r in [histogram, scope] {
|
||||
for y in r.top().ceil() as u32..r.bottom().floor() as u32 {
|
||||
for x in r.left().ceil() as u32..r.right().floor() as u32 {
|
||||
assert_eq!(no_hover.get_pixel(x, y), cleared.get_pixel(x, y), "overlay must clear: {theme:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_list_replacement_and_operations_cannot_silently_cancel_each_other() {
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let ctx = h.ctx.clone();
|
||||
let combined = json!({"ui":{"scope":{"samplers":[[0.1,0.1]],"addSampler":[0.5,0.5]}}});
|
||||
assert!(menu(h.state_mut(), &ctx, combined).is_err(), "replacing and editing the list in one request is ambiguous");
|
||||
menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"samplers":[[0.1,0.1],[0.2,0.2]]}}})).unwrap();
|
||||
// Fixed order: clear, then remove, then add.
|
||||
let r = menu(h.state_mut(), &ctx, json!({"ui":{"scope":{"clearSamplers":true,"addSampler":[0.5,0.5]}}})).unwrap();
|
||||
assert_eq!(r["scope"]["samplers"], json!([[0.5, 0.5]]));
|
||||
}
|
||||
|
||||
/// Alt-drags the slider under the only visible `label` and returns the inspected dialog.
|
||||
fn alt_drag_slider(h: &mut Harness<'_, PhotocraftApp>, label: &str) -> Value {
|
||||
// The label sits above its 18 pt slider track.
|
||||
let text = h.get_by_label(label).rect();
|
||||
let at = pos2(text.left() + 80.0, text.bottom() + 12.0);
|
||||
let alt = Modifiers { alt: true, ..Modifiers::NONE };
|
||||
press(h, at, true, alt);
|
||||
for n in 1..=3 {
|
||||
h.event(Event::PointerMoved(at + vec2(10.0 * n as f32, 0.0)));
|
||||
h.run_steps(2);
|
||||
}
|
||||
let during = inspect(h);
|
||||
press(h, at + vec2(30.0, 0.0), false, Modifiers::NONE);
|
||||
during
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_diagnostics_follow_light_tone_sliders_not_same_named_ones() {
|
||||
let tr = |s| photocraft_ui_egui::i18n::tr_ctx(photocraft_ui_egui::i18n::current(), "cameraRaw", s);
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
let during = alt_drag_slider(&mut h, tr("Highlights"));
|
||||
assert_ne!(during["params"]["highlights"], 0.0, "the drag hit the Light slider");
|
||||
assert_eq!(during["overlayRevision"], during["previewRevision"], "Light tone sliders drive Alt diagnostics");
|
||||
|
||||
let mut h = fixture(ThemeKind::Pro);
|
||||
h.get_by_label(tr("Light")).click();
|
||||
h.run_steps(12);
|
||||
h.get_by_label(tr("Curve")).click();
|
||||
h.run_steps(12);
|
||||
// The Curve section's Highlights shares its label with the Light tone slider.
|
||||
let during = alt_drag_slider(&mut h, tr("Highlights"));
|
||||
assert_ne!(during["params"]["curveHighlights"], 0.0, "the drag hit the Curve slider");
|
||||
assert!(during["overlayRevision"].is_null(), "a same-named non-tone slider must not drive Alt diagnostics");
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Camera Raw histogram and scopes
|
||||
|
||||
A clean-room implementation of Camera Raw's SDR histogram interaction and vectorscope,
|
||||
based on Adobe's public documentation. It does not claim numerical identity with Adobe's
|
||||
develop pipeline. Automation fields are in [control-protocol.md](control-protocol.md).
|
||||
|
||||
| Behaviour | Implementation |
|
||||
|---|---|
|
||||
| RGB histogram | Continuous ribbons with additive overlap colours; display-only five-bin binomial smoothing over exact raw counts; edge spikes pinned and excluded from the auto-scale |
|
||||
| Updates, Before/After | Counted from the same completed float proxy as the preview texture |
|
||||
| Five tonal zones | Hover tooltip, horizontal drag and double-click reset of the existing Blacks / Shadows / Exposure / Highlights / Whites parameters |
|
||||
| Clipping warnings | Exact per-channel endpoints (≤0 / ≥1); coloured triangles, U / O, blue / red preview overlays |
|
||||
| Alt/Option diagnostics | Per-channel shadow or highlight clipping while Alt-dragging a Light tone slider or a histogram zone |
|
||||
| Pixel hover | Tone band and RGB badge on the histogram; ICC-managed crosshair on the vectorscope |
|
||||
| Readouts | RGB or Lab (document ICC profile → D50 Lab); up to nine movable probes (S, Alt-click removes) |
|
||||
| Vectorscope | Hidden on opening, enabled from the context menu; 128×64 hue/saturation bins of the whole image or the document selection; red at 3 o'clock and skin-tone line options |
|
||||
| Floating scopes | Resizable panel; drag it back onto the dock to restore |
|
||||
| Preferences | Display options and panel geometry persist in `dialogs["filter.cameraRaw.scope"]`; probes and vectorscope visibility do not |
|
||||
|
||||
The settings panel is grouped as Light, Color, Effects, Curve, Color Mixer, Color Grading and
|
||||
Detail, with the existing filter parameters and processing. The point Curve shares its editing
|
||||
model with Image › Adjustments › Curves and layer Properties (`point_curve.rs`): button-down hit
|
||||
testing, grab offsets, ordered points, endpoint protection, drag-off removal and re-entry,
|
||||
keyboard nudges and deletion, at most 16 points. Camera Raw commits one `filter.cameraRaw` edit on
|
||||
OK; scope interaction never edits the document. Double-clicking a Camera Raw smart filter reopens
|
||||
this dialog with its stored settings and the pixels below it; OK updates that filter in place.
|
||||
|
||||
## Layering and bounds
|
||||
|
||||
Analysis lives in L2 `photocraft-algo` (`histogram`, `vectorscope`), colour transforms in L0
|
||||
`photocraft-cms`, view state in `state.rs`, interaction and painting in `camera_raw_scope_ui.rs`
|
||||
and `rgb_histogram.rs`. No engine commands or document fields were added.
|
||||
|
||||
- The preview and histogram use the existing proxy, capped at 900 px on the longest side. Each
|
||||
pixel with positive finite alpha counts once; transparent and non-finite pixels are excluded,
|
||||
finite values outside 0–1 count in the edge bins and the overflow counters.
|
||||
- The vectorscope converts at most 8192 pixels, on a 2-D grid, through CMS. It is cached per
|
||||
preview revision, Before/After and selected-region view.
|
||||
- Pointer hover samples one proxy pixel and does at most one ICC conversion; it never recounts
|
||||
the histogram or rebuilds the vectorscope.
|
||||
- A feathered selection blends the preview with its proxy coverage, as the engine does on OK.
|
||||
Thin details and masks are approximate at proxy size.
|
||||
- Point curves in new settings (dialog, control channel, `filter.cameraRaw`) are validated:
|
||||
empty, or 2–16 finite points in 0–255 with inputs at least one level apart. Stored Smart
|
||||
Filters are not validated on load, so curves saved by earlier versions keep rendering.
|
||||
|
||||
`camera_raw_bench` times the CPU path on a 36 MP layer (P31: develop + histogram; P36: all SDR
|
||||
scopes, probes, hover and tessellation). GPU upload and presentation are not included.
|
||||
|
||||
## Not covered
|
||||
|
||||
- HDR histograms, output and display: no suitable HDR hardware was available for validation.
|
||||
Float input and counters outside 0–1 do not imply HDR support.
|
||||
- Persisting Camera Raw as a PSD Smart Filter (see the file-compatibility scorecard).
|
||||
- Camera Raw's own mask authoring; scopes use the document selection.
|
||||
|
||||
References: [Adobe, Make color and tonal adjustments](https://helpx.adobe.com/camera-raw/desktop/using/make-color-tonal-adjustments-camera.html),
|
||||
[Julieanne Kost, Camera Raw shortcuts and interactions](https://jkost.com/blog/2022/10/225-shortcuts-tips-and-tricks-for-adobe-camera-raw.html).
|
||||
Only public behaviour was consulted; no Adobe code, shaders, profiles or assets are included.
|
||||
|
|
@ -277,3 +277,53 @@ fall back on graphics errors rather than risk documents. CPU compatibility compo
|
|||
on the CPU and prefers software window adapters, when available. macOS still uses Metal for
|
||||
the window. A failed window renderer initialization retries once in CPU compatibility mode;
|
||||
a driver process crash is detected by the startup marker on the next launch.
|
||||
|
||||
### Camera Raw dialog
|
||||
|
||||
`ui.menu.invoke {"id":"filter.cameraRaw","params":{}}` opens Camera Raw on the active
|
||||
RGB/Grayscale layer. `params: {"smartFilter": {"layer": id, "index": i}}` opens it on an existing
|
||||
Camera Raw smart filter instead (as double-clicking the filter in the Layers panel does): the
|
||||
stored settings over the pixels below that filter, previewed through the filter mask; commit
|
||||
then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `commit` and
|
||||
`cancel`. All parts of one request are validated before any is applied; a rejected request
|
||||
leaves the settings, view state, preferences and document unchanged (and closes a dialog it
|
||||
opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / `cancel`, and
|
||||
`commit` together with `cancel` are errors. Point curves (`pointCurve`, `pointCurveRed`,
|
||||
`pointCurveGreen`, `pointCurveBlue`) are empty or 2–16 finite points in 0–255 with inputs at
|
||||
least one level apart. Commit dispatches one `filter.cameraRaw` engine command; a failed commit
|
||||
keeps the dialog open for correction. Nothing else writes document history.
|
||||
|
||||
The response and `ui.inspect.cameraRaw` contain:
|
||||
|
||||
- `histogram`: `source` (`before` | `after`), `size`, `approximate`, 256-bin `red` / `green` /
|
||||
`blue`, `samples`, `transparent`, `invalid`, per-channel `underflow` / `overflow`, and exact
|
||||
endpoint counters `shadows` (≤0) / `highlights` (≥1). Counts describe the bounded preview
|
||||
proxy in its RGB sample domain, not full-resolution or ICC display-gamut statistics.
|
||||
- `previewRevision` (advances only when the proxy is re-developed), `renderMs`, `histogramMs`
|
||||
(CPU only; zero on WebAssembly).
|
||||
- `curveState` (`selected`, `drag`) and `curveRect: [left, top, right, bottom] | null` (null while
|
||||
the Curve section is closed). Changing `set.pointCurve` cancels a stale gesture.
|
||||
- `scope` (the view state below), `hasScopeSelection`, `vectorscope` (`bins`, `samples`,
|
||||
`before`, `selectedRegion`) or null, `scopeMs`, `scopeRevision`, `overlayRevision`.
|
||||
- `pointerReadout` and `samplerReadouts`: `position`, `space` (`rgb` 0–255 in the preview sample
|
||||
space, or `lab` through the document ICC profile → D50), `values`, `alpha`; null on invisible
|
||||
pixels.
|
||||
- `hoverSample`: `position`, `rgb` (histogram domain) and `hueSaturation` (turns, 0–1; only
|
||||
while the vectorscope is shown), or null.
|
||||
- `hoveredZone`, `previewRect`, `scopeRect`, `vectorscopeRect`.
|
||||
|
||||
`params.ui.scope` changes presentation only: `shadows` / `highlights` (clipping warnings, U / O),
|
||||
`lab`, `samplerTool` (S), `vectorscope`, `selectedRegion` (requires a document selection),
|
||||
`redRight`, `hideSkinLine`, `floating`, `floatingRect: [left, top, right, bottom]` (200×150 to
|
||||
4096×4096 points), `sample: [u,v] | null`, and the probe list: `samplers: [[u,v], …]` replaces it,
|
||||
or `clearSamplers: true`, `removeSampler: index` and `addSampler: [u,v]` edit it, applied in that
|
||||
order (the two forms can't be combined). Coordinates are normalized to the displayed proxy; at
|
||||
most nine probes. `tone: {zone: "blacks|shadows|exposure|highlights|whites", delta}` adjusts the
|
||||
same parameter as a histogram drag (`reset: true` sets it to 0); Exposure clamps to ±5 stops, the
|
||||
others to ±100.
|
||||
|
||||
Hover, probe movement, theme changes and panel resizing never re-develop the image; Before/After
|
||||
and region changes rebuild only the dependent analysis. Display options and the floating panel
|
||||
geometry persist in preferences `dialogs["filter.cameraRaw.scope"]`; probes and vectorscope
|
||||
visibility reset when the dialog opens. HDR scopes are not implemented. See
|
||||
[camera-raw-histogram.md](camera-raw-histogram.md).
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|
|||
|
||||
| Area | Done | Partial | Missing | Key numbers |
|
||||
|---|---:|---:|---:|---|
|
||||
| [Performance](#performance) | 3 | 22 | 12 | 46 scenarios: 3 within budget, 22 over budget or failing, 13 tracked only, 4 not run, 8 not measurable yet (baseline 2026-10-05) |
|
||||
| [Performance](#performance) | 3 | 22 | 13 | 47 scenarios: 3 within budget, 22 over budget or failing, 15 tracked only, 6 not run, 7 not measurable yet (baseline 2026-10-05) |
|
||||
| [File compatibility](#file-compatibility) | 1 | 8 | 18 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 229/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 132/256, round trip ≥ 258/256 |
|
||||
| [Tools](#tools) | 5 | 13 | 12 | |
|
||||
| [Workspace and UI](#workspace-and-ui) | 0 | 4 | 19 | settings that do nothing: 60 of 135 (target 0) |
|
||||
|
|
@ -52,7 +52,9 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
|
|||
| P27 | Select › Modify › Feather on 24 MP (radius 50) | ≤ 100 ms p95 | 122 | 357 | 360 | 1.1 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P28 | Content-Aware Scale 24 MP (to 90 % width) | ≤ 3000 ms p95 | 954192 | 954192 | 954192 | 2.1 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P29 | Select Subject on 24 MP, repeated | ≤ 100 ms p95 | 559 | 693 | 693 | 1.4 GB | – | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P31 | Camera Raw exposure change (proxy) | ≤ 10 ms | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P33 | Brush dab on A4 300 ppi CMYK (dab to screen) | ≤ 6 ms p95 | 34.7 | 235 | 235 | 775 MB | 87 MB | over budget (target not met yet) | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P36 | Camera Raw exposure change with all SDR scopes (CPU) | ≤ 10 ms p95 | – | – | – | – | – | not run | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P40 | Brush dab 60 px on 36 MP | track only | 1.45 | 3.19 | 3.19 | 1.3 GB | – | tracked | |
|
||||
| P41 | Gaussian Blur r 40 on 36 MP | track only | 2963 | 3069 | 3069 | 1.9 GB | – | tracked | |
|
||||
| P42 | Curves dialog change, layer preview, 36 MP | track only | 38.7 | 158 | 158 | 1.1 GB | – | tracked | |
|
||||
|
|
@ -71,7 +73,6 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
|
|||
| P20 | Open a 2 GB PSD / 4–5 GB PSB | opens with progress, editable, never blank | opens run in the background with progress (#210), but there are no generated large-file fixtures yet (#216) | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P23 | Warp / Puppet / Perspective preview at 4K | ≤ 16 ms per frame, commit in the background | the previews run inside the UI tools; no headless frame harness, no background commit | [#210](https://github.com/storytold/photocraft/issues/210) |
|
||||
| P30 | Liquify 800 px brush on 3500×2200 at full resolution | ≤ 1 ms per update | the Liquify proxy is capped at 1600 px and the stroke path lives in the UI tool | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P31 | Camera Raw exposure change (proxy) | ≤ 10 ms | the proxy is built inside the dialog; no headless entry point yet | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P32 | Brush release on a long stroke | no hitch at mouse-up | the live renderer and the release re-render are UI-side; needs a frame harness | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P34 | Brush preview memory, small dab on an 8K viewport | ≤ 2 MiB | the live brush preview buffers are UI-side; no headless counter yet | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
| P35 | Marquee drag on 24 MP with Alt/Shift | ≤ 17 ms p95 | Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side | [#211](https://github.com/storytold/photocraft/issues/211) |
|
||||
|
|
@ -99,7 +100,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
|||
| FILE-215-9 | SVG opens and places as shape layers or a vector Smart Object | missing | [#215](https://github.com/storytold/photocraft/issues/215) | No SVG codec or import |
|
||||
| FILE-215-10 | Multi-page PDF save, PDF presentation, layer comps to PDF, vector/text output, PDF import | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only print_cmds.rs:133 print_pdf (raster print page) |
|
||||
| FILE-215-11 | RAW: lossy DNG, highlight modes, .xmp sidecars, RAW prefs wired, per-camera corpus | missing | [#215](https://github.com/storytold/photocraft/issues/215) | raw sensor.rs:210 lossy DNG unsupported; raw_defaults unread; no xmp |
|
||||
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | No histogram; lens_cmds.rs:268 stores an in-app Smart Filter, PSD drops it |
|
||||
| FILE-215-12 | Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Interactive RGB/Lab scopes, clipping, tone zones, nine probes, vectorscope and floating panel; HDR deferred without test hardware. In-app Smart Filter persists, PSD drops it |
|
||||
| FILE-215-13 | Video layer frames persist in .pcraft; PSD video layers mapped | partial | [#215](https://github.com/storytold/photocraft/issues/215) | format convert.rs:89 + tests/roundtrip.rs:282 persist frames; no PSD video mapping |
|
||||
| FILE-215-14 | User swatch model with ACO/ASE import and export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | panels.rs:954 const SWATCHES: [[u8; 3]; 40] |
|
||||
| FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | missing | [#215](https://github.com/storytold/photocraft/issues/215) | doc effects.rs:57 Contour model only |
|
||||
|
|
|
|||
|
|
@ -114,3 +114,10 @@ documents), strings assembled with `format!` that were not converted to `fmt`/`t
|
|||
per-language font fallback (the CJK fallback prefers Japanese forms), right-to-left layout,
|
||||
locale-aware number and date formats, automatic language detection on the web build (native
|
||||
builds read `LANG`/`LC_*`, the macOS preferred languages and the Windows user locale).
|
||||
|
||||
Camera Raw has explicit coverage for all seven available languages, including the partial
|
||||
Simplified Chinese catalog. Its contextual `cameraRaw` entries distinguish tonal regions,
|
||||
colour-band names and font-weight Light. The title uses a `{layer}` placeholder; user layer
|
||||
names and the universal RGB/Lab channel symbols stay unchanged. Collapse IDs use English
|
||||
source keys, so switching languages preserves open sections. Mixer tabs wrap within the
|
||||
panel width. Catalog coverage and actual shell language-switch tests enforce these rules.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ package = "photocraft-ui-egui"
|
|||
args = ["--no-cpu", "--reps", "5"]
|
||||
note = "36 MP document with layer effects, GPU compositor"
|
||||
|
||||
[[bench]]
|
||||
name = "camera_raw_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
quick_args = ["--quick"]
|
||||
note = "36 MP dense RGB layer: CPU develop, histogram, ICC vectorscope, clipping, nine Lab probes and egui tessellation; excludes GPU upload/presentation"
|
||||
|
||||
[[bench]]
|
||||
name = "type_bench"
|
||||
package = "photocraft-ui-egui"
|
||||
|
|
@ -343,7 +349,10 @@ id = "P31"
|
|||
title = "Camera Raw exposure change (proxy)"
|
||||
issue = 211
|
||||
target = "≤ 10 ms"
|
||||
not_measurable = "the proxy is built inside the dialog; no headless entry point yet"
|
||||
bench = "camera_raw_bench"
|
||||
row = "exposure proxy update"
|
||||
budget_ms = 10
|
||||
enforce = false # not in the 2026-10-05 baseline yet; contributor M4 run 2026-10-06: p95 5.4 ms
|
||||
|
||||
[[scenario]]
|
||||
id = "P32"
|
||||
|
|
@ -375,6 +384,15 @@ issue = 211
|
|||
target = "≤ 17 ms p95"
|
||||
not_measurable = "Shift/Alt marquee modifiers don't exist yet (#208) and the drag preview is UI-side"
|
||||
|
||||
[[scenario]]
|
||||
id = "P36"
|
||||
title = "Camera Raw exposure change with all SDR scopes (CPU)"
|
||||
issue = 211
|
||||
bench = "camera_raw_bench"
|
||||
row = "interactive scope update"
|
||||
budget_ms = 10
|
||||
enforce = false # not in the 2026-10-05 baseline yet; contributor M4 run 2026-10-06: p95 8.0 ms
|
||||
|
||||
# ---- tracked (no budget yet): regressions only -------------------------------------------------
|
||||
|
||||
[[scenario]]
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ id = "FILE-215-12"
|
|||
target = "Camera Raw dialog shows a histogram; settings persist as a PSD Smart Filter"
|
||||
status = "partial"
|
||||
issue = 215
|
||||
note = "No histogram; lens_cmds.rs:268 stores an in-app Smart Filter, PSD drops it"
|
||||
note = "Interactive RGB/Lab scopes, clipping, tone zones, nine probes, vectorscope and floating panel; HDR deferred without test hardware. In-app Smart Filter persists, PSD drops it"
|
||||
|
||||
[[item]]
|
||||
id = "FILE-215-13"
|
||||
|
|
|
|||
Loading…
Reference in New Issue