Merge pull request #41 from storytold/fix/issue-12-14-curve
Tone curve: drag points, reset curves, point-curve presets (fixes #12, #13, #14)
This commit is contained in:
commit
e3b61fc883
|
|
@ -154,6 +154,8 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
|||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
write: Some(Box::new(download)),
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,24 @@ fn services() -> Services {
|
|||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,357 @@
|
|||
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
|
||||
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
|
||||
//! exported as small JSON files).
|
||||
|
||||
use lightcraft_develop::ToneCurve;
|
||||
use lightcraft_geom::Point;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Channels `curve.reset` accepts.
|
||||
pub const RESET_CHANNELS: [&str; 7] = ["all", "point", "parametric", "master", "red", "green", "blue"];
|
||||
|
||||
/// `c` with `channel` back at its default: `all` (the whole Tone Curve, incl. Refine Saturation),
|
||||
/// `point` (the four point curves), `parametric` (region sliders and splits) or one point channel.
|
||||
pub fn reset_channel(c: &mut ToneCurve, channel: &str) -> std::result::Result<(), String> {
|
||||
let d = ToneCurve::default();
|
||||
match channel {
|
||||
"all" => *c = d,
|
||||
"point" => {
|
||||
c.master.clear();
|
||||
c.red.clear();
|
||||
c.green.clear();
|
||||
c.blue.clear();
|
||||
}
|
||||
"parametric" => {
|
||||
(c.highlights, c.lights, c.darks, c.shadows) = (d.highlights, d.lights, d.darks, d.shadows);
|
||||
(c.split_shadows, c.split_mid, c.split_highlights) = (d.split_shadows, d.split_mid, d.split_highlights);
|
||||
}
|
||||
"master" | "rgb" | "luma" => c.master.clear(),
|
||||
"red" => c.red.clear(),
|
||||
"green" => c.green.clear(),
|
||||
"blue" => c.blue.clear(),
|
||||
other => return Err(format!("unknown channel `{other}` (one of {})", RESET_CHANNELS.join(", "))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.reset";
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let channel = str_param(p, "channel").unwrap_or("all");
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
reset_channel(&mut d.curve, channel).map_err(|e| bad(C, e))?;
|
||||
let label = if channel == "all" { "Reset Tone Curve".to_string() } else { format!("Reset Tone Curve ({channel})") };
|
||||
s.set_develop(id, d, &label)?;
|
||||
Ok(json!({"channel": channel}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"curve.reset",
|
||||
"Reset Tone Curve",
|
||||
[],
|
||||
None,
|
||||
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
|
||||
has_active,
|
||||
reset
|
||||
),
|
||||
cmd!(
|
||||
query "curve.presets",
|
||||
"Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"curve.applyPreset",
|
||||
"Apply Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
|
||||
has_active,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.savePreset",
|
||||
"Save Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
|
||||
has_active,
|
||||
save_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.deletePreset",
|
||||
"Delete Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — a user preset (built-ins can't be deleted)",
|
||||
always,
|
||||
delete_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.importPresets",
|
||||
"Import Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
|
||||
always,
|
||||
import_presets
|
||||
),
|
||||
cmd!(
|
||||
"curve.exportPresets",
|
||||
"Export Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
|
||||
always,
|
||||
export_presets
|
||||
),
|
||||
]
|
||||
}
|
||||
// ---------------------------------------------------------------- point-curve presets
|
||||
|
||||
/// File extension of exported curve presets (JSON).
|
||||
pub const CURVE_PRESET_EXT: &str = "lccurve";
|
||||
/// `format` tag of an exported curve-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.curvePresets";
|
||||
|
||||
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
|
||||
/// Built-ins ship with the app; the user's are saved with the library preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CurvePreset {
|
||||
pub name: String,
|
||||
pub master: Vec<[f64; 2]>,
|
||||
pub red: Vec<[f64; 2]>,
|
||||
pub green: Vec<[f64; 2]>,
|
||||
pub blue: Vec<[f64; 2]>,
|
||||
/// Built-in presets can't be deleted or overwritten (never stored).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
|
||||
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let mut out: Vec<[f64; 2]> =
|
||||
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
|
||||
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
|
||||
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
|
||||
out.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
|
||||
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
|
||||
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
impl CurvePreset {
|
||||
/// The point curves of `c` under `name`.
|
||||
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
|
||||
CurvePreset {
|
||||
name: name.into(),
|
||||
master: from_points(&c.master),
|
||||
red: from_points(&c.red),
|
||||
green: from_points(&c.green),
|
||||
blue: from_points(&c.blue),
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
|
||||
pub fn apply(&self, c: &mut ToneCurve) {
|
||||
c.master = to_points(&self.master);
|
||||
c.red = to_points(&self.red);
|
||||
c.green = to_points(&self.green);
|
||||
c.blue = to_points(&self.blue);
|
||||
}
|
||||
/// Does `c` carry exactly this preset's point curves?
|
||||
pub fn matches(&self, c: &ToneCurve) -> bool {
|
||||
let same = |a: &[[f64; 2]], b: &[Point]| {
|
||||
let (a, b) = (normalize(a), from_points(b));
|
||||
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
|
||||
};
|
||||
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
|
||||
}
|
||||
fn is_linear(&self) -> bool {
|
||||
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
|
||||
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
|
||||
pub fn builtin_presets() -> Vec<CurvePreset> {
|
||||
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
|
||||
vec![
|
||||
s("Linear", Vec::new()),
|
||||
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
|
||||
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Built-in presets, then the user's.
|
||||
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
|
||||
let mut v = builtin_presets();
|
||||
v.extend(s.curve_presets.iter().cloned());
|
||||
v
|
||||
}
|
||||
|
||||
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
|
||||
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
|
||||
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
|
||||
}
|
||||
|
||||
fn is_builtin_name(name: &str) -> bool {
|
||||
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) a user preset.
|
||||
fn upsert(s: &mut Session, p: CurvePreset) {
|
||||
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
|
||||
Some(x) => *x = p,
|
||||
None => s.curve_presets.push(p),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
|
||||
json!({"presets": all_presets(s), "current": current})
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.applyPreset";
|
||||
let name = name_param(p, C)?;
|
||||
let preset =
|
||||
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
preset.apply(&mut d.curve);
|
||||
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
|
||||
Ok(json!({"name": preset.name}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.savePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
|
||||
}
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
|
||||
if preset.is_linear() {
|
||||
return Err(bad(C, "the point curve is linear: shape it first"));
|
||||
}
|
||||
upsert(s, preset);
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.deletePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, "built-in presets can't be deleted"));
|
||||
}
|
||||
let n = s.curve_presets.len();
|
||||
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if n == s.curve_presets.len() {
|
||||
return Err(bad(C, format!("no curve preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let items = match &v {
|
||||
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
|
||||
Value::Array(_) => v.clone(),
|
||||
Value::Object(_) => Value::Array(vec![v.clone()]),
|
||||
_ => return Err("not a curve preset file".into()),
|
||||
};
|
||||
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no curve presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The JSON of a curve-preset file holding `presets`.
|
||||
pub fn to_file(presets: &[CurvePreset]) -> String {
|
||||
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
|
||||
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.importPresets";
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(x) = str_param(p, "path") {
|
||||
paths.push(x.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "missing `path` / `paths`"));
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for f in &paths {
|
||||
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
|
||||
Ok(list) => {
|
||||
for mut preset in list {
|
||||
preset.name = preset.name.trim().to_string();
|
||||
preset.builtin = false;
|
||||
if is_builtin_name(&preset.name) {
|
||||
preset.name = format!("{} (imported)", preset.name);
|
||||
}
|
||||
imported.push(preset.name.clone());
|
||||
upsert(s, preset);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
if !imported.is_empty() {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.exportPresets";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let mut path = std::path::PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(CURVE_PRESET_EXT);
|
||||
}
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let chosen: Vec<CurvePreset> = match &names {
|
||||
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
|
||||
None => s.curve_presets.clone(),
|
||||
};
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ mod browse;
|
|||
mod color;
|
||||
pub(crate) mod convert;
|
||||
mod cull;
|
||||
pub mod curves;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod export;
|
||||
|
|
@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(library::specs());
|
||||
v.extend(develop::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(curves::specs());
|
||||
v.extend(masks::specs());
|
||||
v.extend(organize::specs());
|
||||
v.extend(keywords::specs());
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ pub struct Session {
|
|||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||||
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
|
||||
/// persisted in prefs.json.
|
||||
pub curve_presets: Vec<cmd::curves::CurvePreset>,
|
||||
/// Saved colour-label name sets.
|
||||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||||
/// Look-alike signatures by content key (Find Similar).
|
||||
|
|
@ -195,6 +198,7 @@ impl Session {
|
|||
export_presets: Vec::new(),
|
||||
metadata_presets: Vec::new(),
|
||||
filter_presets: Vec::new(),
|
||||
curve_presets: Vec::new(),
|
||||
label_sets: Vec::new(),
|
||||
signatures: Default::default(),
|
||||
lut_profiles: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ struct PrefsFile {
|
|||
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
|
||||
/// Filter presets.
|
||||
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
|
||||
/// Point-curve presets.
|
||||
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
|
||||
/// Colour-label name sets.
|
||||
label_sets: Vec<crate::cmd::manage::LabelSet>,
|
||||
/// Imported LUT profiles.
|
||||
|
|
@ -189,6 +191,7 @@ impl Session {
|
|||
self.export_presets = prefs.export_presets;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
self.label_sets = prefs.label_sets;
|
||||
self.lut_profiles = prefs.lut_profiles;
|
||||
crate::cmd::lut_profiles::register_all(self);
|
||||
|
|
@ -309,6 +312,7 @@ impl Session {
|
|||
export_presets: self.export_presets.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
label_sets: self.label_sets.clone(),
|
||||
lut_profiles: self.lut_profiles.clone(),
|
||||
keyword_sets: self.keyword_sets.clone(),
|
||||
|
|
|
|||
|
|
@ -671,3 +671,35 @@ fn mask_components_invert_duplicate_rename_change_mode_and_delete() {
|
|||
assert!(active_dev(&s).masks.is_empty());
|
||||
assert_eq!(s.active_mask, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_reset_by_channel_and_whole() {
|
||||
let mut s = demo();
|
||||
let s_curve = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]];
|
||||
for ch in ["master", "red", "green", "blue"] {
|
||||
s.execute("develop.curve", &json!({"channel": ch, "points": s_curve})).unwrap();
|
||||
}
|
||||
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 30})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "curve.refineSaturation", "value": 50})).unwrap();
|
||||
// one channel
|
||||
s.execute("curve.reset", &json!({"channel": "red"})).unwrap();
|
||||
let c = active_dev(&s).curve;
|
||||
assert!(c.red.is_empty() && c.green.len() == 4 && c.master.len() == 4, "only red resets");
|
||||
assert_eq!(c.shadows, 30.0);
|
||||
// parametric only
|
||||
s.execute("curve.reset", &json!({"channel": "parametric"})).unwrap();
|
||||
let c = active_dev(&s).curve;
|
||||
assert_eq!(c.shadows, 0.0);
|
||||
assert_eq!(c.master.len(), 4, "point curves stay");
|
||||
// all point curves, then undo restores them in one step
|
||||
s.execute("curve.reset", &json!({"channel": "point"})).unwrap();
|
||||
let c = active_dev(&s).curve;
|
||||
assert!(c.master.is_empty() && c.green.is_empty() && c.blue.is_empty());
|
||||
assert_eq!(c.refine_saturation, 50.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(active_dev(&s).curve.master.len(), 4);
|
||||
// everything (the default)
|
||||
s.execute("curve.reset", &json!({})).unwrap();
|
||||
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
|
||||
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,3 +202,62 @@ fn metadata_presets_apply_to_photos_and_imports() {
|
|||
assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_presets_save_apply_export_import_and_persist() {
|
||||
let lib = temp_dir("curves");
|
||||
let file = lib.join("out").join("looks");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let r = s.execute("curve.presets", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Linear", "Medium Contrast", "Strong Contrast"]);
|
||||
assert_eq!(r["current"], "Linear", "a fresh photo has linear point curves");
|
||||
// built-in: an S-curve on the master channel, parametric untouched
|
||||
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 20})).unwrap();
|
||||
s.execute("curve.applyPreset", &json!({"name": "strong contrast"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert_eq!(d.curve.master.len(), 5);
|
||||
assert!(d.curve.master[1].y < 0.25 && d.curve.master[3].y > 0.75, "an S-curve");
|
||||
assert_eq!(d.curve.shadows, 20.0);
|
||||
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Strong Contrast");
|
||||
// save a custom one (with a red channel)
|
||||
s.execute("develop.curve", &json!({"channel": "red", "points": [[0.0, 0.1], [1.0, 0.9]]})).unwrap();
|
||||
assert!(s.execute("curve.savePreset", &json!({"name": "Linear"})).is_err(), "built-in names are reserved");
|
||||
s.execute("curve.savePreset", &json!({"name": "Faded Red"})).unwrap();
|
||||
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Faded Red");
|
||||
// Linear resets every point channel
|
||||
s.execute("curve.applyPreset", &json!({"name": "Linear"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert!(d.curve.master.is_empty() && d.curve.red.is_empty());
|
||||
s.execute("curve.applyPreset", &json!({"name": "Faded Red"})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().curve.red.len(), 2);
|
||||
// export (extension added), delete, re-import
|
||||
let r = s.execute("curve.exportPresets", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
let path = r["path"].as_str().unwrap().to_string();
|
||||
assert!(path.ends_with(".lccurve"));
|
||||
assert!(s.execute("curve.deletePreset", &json!({"name": "Medium Contrast"})).is_err());
|
||||
s.execute("curve.deletePreset", &json!({"name": "faded red"})).unwrap();
|
||||
assert!(s.curve_presets.is_empty());
|
||||
let r = s.execute("curve.importPresets", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Faded Red"]));
|
||||
assert_eq!(s.curve_presets.len(), 1);
|
||||
// a file carrying a built-in name doesn't shadow it; junk fails cleanly
|
||||
let other = lib.join("one.lccurve");
|
||||
std::fs::write(&other, r#"{"name": "Linear", "master": [[0, 0.05], [1, 1]]}"#).unwrap();
|
||||
let junk = lib.join("junk.lccurve");
|
||||
std::fs::write(&junk, "not json").unwrap();
|
||||
let r = s.execute("curve.importPresets", &json!({"paths": [other.to_string_lossy(), junk.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Linear (imported)"]));
|
||||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
// saved with the library preferences
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let names: Vec<String> = s.curve_presets.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ pub mod state;
|
|||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_curve;
|
||||
#[cfg(test)]
|
||||
mod tests_masking;
|
||||
#[cfg(test)]
|
||||
|
|
@ -60,6 +62,10 @@ pub struct Services {
|
|||
pub pick_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
/// Open dialog for point-curve preset files (`.lccurve`).
|
||||
pub pick_curve_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lccurve` file.
|
||||
pub save_curve_preset_file: Option<SaveFile>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
|
||||
pub write_shared: Option<SharedWrite>,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
|
||||
("file.importPresets", "Import Profiles & Presets…", None, "File"),
|
||||
("file.exportPresets", "Export Presets…", None, "File"),
|
||||
// Edit panel ▸ Curve ▸ Point Curve dropdown
|
||||
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
|
||||
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
|
||||
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
|
||||
("app.openLibrary", "Open Library…", None, "File"),
|
||||
("app.about", "About LightCraft", None, "Help"),
|
||||
|
|
@ -1005,6 +1008,48 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.importCurvePresets" => {
|
||||
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => match app.services.pick_curve_preset_files.as_mut() {
|
||||
Some(f) => f(),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string());
|
||||
if let Ok(v) = &r {
|
||||
let n = v["imported"].as_array().map_or(0, Vec::len);
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" });
|
||||
if failed > 0 {
|
||||
msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" });
|
||||
}
|
||||
app.toast(&ctx, msg);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.exportCurvePresets" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => match app.services.save_curve_preset_file.as_mut() {
|
||||
Some(f) => f("Point Curves.lccurve"),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = json!({"path": path});
|
||||
if let Some(n) = p.get("names") {
|
||||
params["names"] = n.clone();
|
||||
}
|
||||
let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string());
|
||||
if let Ok(v) = &r {
|
||||
app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"app.export" => crate::control::export_active(app, p),
|
||||
"app.showInFinder" => show_in_finder(app),
|
||||
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => {
|
||||
|
|
@ -1037,6 +1082,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
|||
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
|
||||
}
|
||||
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
|
|
|
|||
|
|
@ -598,7 +598,18 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
if sel {
|
||||
ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text));
|
||||
}
|
||||
if resp.clicked() {
|
||||
let name = match ch {
|
||||
"parametric" => "Parametric curve",
|
||||
"master" => "Point curve",
|
||||
"red" => "Red channel",
|
||||
"green" => "Green channel",
|
||||
_ => "Blue channel",
|
||||
};
|
||||
let resp = resp.on_hover_text(format!("{name} — double-click to reset it"));
|
||||
if resp.double_clicked() {
|
||||
app.ui.curve_channel = ch.into();
|
||||
let _ = app.run("curve.reset", json!({"channel": ch}));
|
||||
} else if resp.clicked() {
|
||||
app.ui.curve_channel = ch.into();
|
||||
}
|
||||
}
|
||||
|
|
@ -614,6 +625,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side));
|
||||
let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag());
|
||||
register(ui.ctx(), "curve", r);
|
||||
resp.context_menu(|ui| curve_reset_menu(app, ui, &ch));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 2.0, t.canvas);
|
||||
for i in 1..4 {
|
||||
|
|
@ -652,6 +664,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220))));
|
||||
curve_footer(app, ui, d);
|
||||
for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
|
|
@ -677,13 +690,37 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
};
|
||||
let line: Vec<Pos2> = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(2.0, color)));
|
||||
for q in &pts {
|
||||
p.circle_filled(to_screen(q.x, q.y), 4.0, color);
|
||||
}
|
||||
// interaction: drag the nearest point, click empty space to add, double-click to delete
|
||||
// interaction: drag a point (both axes, between its neighbours), drag empty space to add and
|
||||
// drag a new point, click empty space to add, double-click a point to delete it
|
||||
let drag_id = egui::Id::new(("curve-drag", &ch));
|
||||
let mut dragging: Option<usize> = ui.data(|dd| dd.get_temp(drag_id));
|
||||
// the point being dragged (stored as `Option<usize>` so a stale value can be cleared)
|
||||
let mut dragging: Option<usize> = ui.data(|dd| dd.get_temp::<Option<usize>>(drag_id)).flatten();
|
||||
let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let hovered = resp.hover_pos().and_then(nearest).filter(|(_, dist)| *dist < 10.0).map(|(i, _)| i);
|
||||
if dragging.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
} else if hovered.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let c = to_screen(q.x, q.y);
|
||||
if dragging == Some(i) || (dragging.is_none() && hovered == Some(i)) {
|
||||
p.circle_filled(c, 5.5, color);
|
||||
p.circle_stroke(c, 5.5, Stroke::new(1.0, t.canvas));
|
||||
} else {
|
||||
p.circle_filled(c, 4.0, color);
|
||||
}
|
||||
}
|
||||
// input / output readout of the point being dragged (0–255 like the histogram)
|
||||
if let Some(q) = dragging.and_then(|i| pts.get(i)) {
|
||||
p.text(
|
||||
r.left_top() + vec2(6.0, 4.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("{} / {}", (q.x * 255.0).round(), (q.y * 255.0).round()),
|
||||
t.font(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
let mut new_pts = None;
|
||||
if resp.double_clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
|
|
@ -696,8 +733,9 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
v.remove(i);
|
||||
new_pts = Some(v);
|
||||
} else if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
// pick the point under the press, not where the pointer is once the drag threshold passed
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"}));
|
||||
match nearest(q) {
|
||||
Some((i, dist)) if dist < 10.0 => dragging = Some(i),
|
||||
|
|
@ -747,10 +785,102 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id, dragging));
|
||||
curve_footer(app, ui, d);
|
||||
control(app, ui, d, "curve.refineSaturation", true);
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
/// The row under the curve graph: point-curve presets and reset every curve.
|
||||
fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
use lightcraft_engine::cmd::curves::{all_presets, matching_preset};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Point Curve").font(t.font(12.0)).color(t.text_label));
|
||||
let current = matching_preset(&app.session, &d.curve);
|
||||
let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
let presets = all_presets(&app.session);
|
||||
let mut user_seen = false;
|
||||
for p in &presets {
|
||||
if !p.builtin && !user_seen {
|
||||
user_seen = true;
|
||||
ui.separator();
|
||||
}
|
||||
let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name);
|
||||
register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("curve.applyPreset", json!({"name": p.name}));
|
||||
ui.close();
|
||||
}
|
||||
if !p.builtin {
|
||||
r.context_menu(|ui| {
|
||||
if ui.button("Delete Preset").clicked() {
|
||||
let _ = app.run("curve.deletePreset", json!({"name": p.name}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let shaped = current.as_deref() != Some("Linear");
|
||||
let r = ui.add_enabled(shaped, egui::Button::new("Save Point Curve…"));
|
||||
register(ui.ctx(), "curvePresetMenu:save", r.rect);
|
||||
if r.clicked() {
|
||||
crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name");
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new("Import Presets…")).clicked() {
|
||||
let _ = app.run("file.importCurvePresets", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty();
|
||||
if ui.add_enabled(can_export, egui::Button::new("Export Presets…")).clicked() {
|
||||
let _ = app.run("file.exportCurvePresets", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if presets.iter().any(|p| !p.builtin) {
|
||||
ui.label(RichText::new("Right-click a preset to delete it").font(t.font(11.0)).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = text_button(ui, "curveReset", "Reset", false)
|
||||
.on_hover_text("Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)");
|
||||
if r.clicked() {
|
||||
let _ = app.run("curve.reset", json!({"channel": "all"}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Right-click menu of the curve graph.
|
||||
fn curve_reset_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, ch: &str) {
|
||||
let label = if ch == "parametric" { "Reset Parametric Curve".to_string() } else { format!("Reset {} Channel", channel_label(ch)) };
|
||||
let r = ui.button(label);
|
||||
register(ui.ctx(), "curveMenu:resetChannel", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("curve.reset", json!({"channel": ch}));
|
||||
ui.close();
|
||||
}
|
||||
let r = ui.button("Reset All Curves");
|
||||
register(ui.ctx(), "curveMenu:resetAll", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("curve.reset", json!({"channel": "all"}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_label(ch: &str) -> &'static str {
|
||||
match ch {
|
||||
"red" => "Red",
|
||||
"green" => "Green",
|
||||
"blue" => "Blue",
|
||||
_ => "RGB",
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle for a targeted-adjustment tool (`tool` = `tat:<target>`).
|
||||
fn tat_button(app: &mut LightcraftApp, ui: &mut egui::Ui, tool: &str, tip: &str) {
|
||||
let active = app.ui.tool == tool;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
//! Headless tests of the point-curve editor: dragging points, resetting, presets.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Detail view with the Edit panel's Curve flyout open on `channel`.
|
||||
fn curve_open(channel: &str) -> Headless {
|
||||
let services = Services { png: None, ..Default::default() };
|
||||
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services);
|
||||
let mut h = Headless::new(app, [1200.0, 1400.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "detail"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.app.ui.right = crate::state::RightPanel::Edit;
|
||||
if !h.app.ui.section_open("light") {
|
||||
h.app.ui.toggle_section("light");
|
||||
}
|
||||
if !h.app.ui.flyout_open("curve") {
|
||||
h.app.ui.toggle_flyout("curve");
|
||||
}
|
||||
h.app.ui.curve_channel = channel.into();
|
||||
h.step();
|
||||
h.step();
|
||||
h
|
||||
}
|
||||
|
||||
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings {
|
||||
let id = h.app.session.active().expect("active photo");
|
||||
(*h.app.session.develop_of(id).unwrap_or_default()).clone()
|
||||
}
|
||||
|
||||
fn pts(v: &[lightcraft_geom::Point]) -> Vec<(f64, f64)> {
|
||||
v.iter().map(|p| (p.x, p.y)).collect()
|
||||
}
|
||||
|
||||
/// Side of the (square) curve graph in screen points.
|
||||
fn curve_side(h: &mut Headless) -> f64 {
|
||||
let r = h.request("ui.widgets", json!({"filter": "curve"}), T);
|
||||
let w = r["result"].as_array().unwrap().iter().find(|w| w["id"] == "curve").cloned().unwrap_or_else(|| panic!("curve widget on screen: {r}"));
|
||||
w["rect"][2].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_point_moves_it_in_both_axes_with_one_undo_step() {
|
||||
let mut h = curve_open("master");
|
||||
exec(&mut h, "develop.curve", json!({"channel": "master", "points": [[0.0, 0.0], [0.5, 0.5], [1.0, 1.0]]}));
|
||||
h.step();
|
||||
let side = curve_side(&mut h);
|
||||
let undo_before = h.app.session.undo.len();
|
||||
// grab the middle point and drag it up-left: x 0.5 → 0.4, y 0.5 → 0.7
|
||||
let r = h.request("ui.dragWidget", json!({"id": "curve", "fx": 0.5, "fy": 0.5, "dx": -0.1 * side, "dy": -0.2 * side, "steps": 12}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
let c = pts(&develop(&h).curve.master);
|
||||
assert_eq!(c.len(), 3, "dragging moves the point, it doesn't add one: {c:?}");
|
||||
assert!((c[1].0 - 0.4).abs() < 0.03, "x follows the pointer: {c:?}");
|
||||
assert!((c[1].1 - 0.7).abs() < 0.03, "y follows the pointer: {c:?}");
|
||||
assert_eq!(h.app.session.undo.len(), undo_before + 1, "one undo step per drag");
|
||||
}
|
||||
|
||||
fn click_widget(h: &mut Headless, id: &str, count: u64) {
|
||||
let r = h.request("ui.clickWidget", json!({"id": id, "count": count}), T);
|
||||
assert_eq!(r["ok"], true, "{id}: {r}");
|
||||
h.step();
|
||||
}
|
||||
|
||||
const S_CURVE: [[f64; 2]; 4] = [[0.0, 0.0], [0.25, 0.15], [0.75, 0.85], [1.0, 1.0]];
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_channel_resets_only_that_channel() {
|
||||
let mut h = curve_open("master");
|
||||
exec(&mut h, "develop.curve", json!({"channel": "master", "points": S_CURVE}));
|
||||
exec(&mut h, "develop.curve", json!({"channel": "green", "points": S_CURVE}));
|
||||
h.step();
|
||||
click_widget(&mut h, "curveChannel:green", 2);
|
||||
let c = develop(&h).curve;
|
||||
assert!(c.green.is_empty(), "green resets: {:?}", c.green);
|
||||
assert_eq!(c.master.len(), 4, "the other channels stay");
|
||||
assert_eq!(h.app.ui.curve_channel, "green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_button_resets_every_curve() {
|
||||
let mut h = curve_open("red");
|
||||
for ch in ["master", "red", "blue"] {
|
||||
exec(&mut h, "develop.curve", json!({"channel": ch, "points": S_CURVE}));
|
||||
}
|
||||
exec(&mut h, "develop.set", json!({"control": "curve.highlights", "value": -40}));
|
||||
h.step();
|
||||
click_widget(&mut h, "button:curveReset", 1);
|
||||
let c = develop(&h).curve;
|
||||
assert_eq!(c, lightcraft_develop::ToneCurve::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_curve_preset_dropdown_applies_and_saves() {
|
||||
let mut h = curve_open("master");
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePreset:Medium Contrast", 1);
|
||||
let c = develop(&h).curve;
|
||||
assert_eq!(c.master.len(), 5, "the preset's S-curve: {:?}", c.master);
|
||||
assert!(c.master[1].y < 0.25 && c.master[3].y > 0.75);
|
||||
// shape it further, then save it from the dropdown (name prompt)
|
||||
exec(&mut h, "develop.curve", json!({"channel": "blue", "points": [[0.0, 0.08], [1.0, 1.0]]}));
|
||||
h.step();
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePresetMenu:save", 1);
|
||||
let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens");
|
||||
match &mut dlg {
|
||||
crate::state::Dialog::TextPrompt { command, value, .. } => {
|
||||
assert_eq!(command, "curve.savePreset");
|
||||
*value = "Cool Shadows".into();
|
||||
}
|
||||
_ => panic!("a name prompt opens"),
|
||||
}
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert_eq!(h.app.session.curve_presets.len(), 1);
|
||||
// Linear from the dropdown flattens every channel
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePreset:Linear", 1);
|
||||
let c = develop(&h).curve;
|
||||
assert!(c.master.is_empty() && c.blue.is_empty());
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePreset:Cool Shadows", 1);
|
||||
assert_eq!(develop(&h).curve.blue.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_point_past_its_neighbour_is_clamped() {
|
||||
let mut h = curve_open("red");
|
||||
exec(&mut h, "develop.curve", json!({"channel": "red", "points": [[0.0, 0.0], [0.3, 0.3], [0.6, 0.6], [1.0, 1.0]]}));
|
||||
h.step();
|
||||
let side = curve_side(&mut h);
|
||||
// drag the point at 0.3 far to the right (past 0.6)
|
||||
let r = h.request("ui.dragWidget", json!({"id": "curve", "fx": 0.3, "fy": 0.7, "dx": 0.5 * side, "dy": 0.0, "steps": 12}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
let c = pts(&develop(&h).curve.red);
|
||||
assert_eq!(c.len(), 4, "{c:?}");
|
||||
assert!(c[1].0 < c[2].0 && c[1].0 > 0.55, "clamped just left of its neighbour: {c:?}");
|
||||
assert_eq!(c[2], (0.6, 0.6), "the neighbour stays: {c:?}");
|
||||
}
|
||||
|
|
@ -211,7 +211,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | |
|
||||
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | |
|
||||
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | |
|
||||
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `crates/ui-egui/src/panels/edit.rs` | no curve presets (linear / medium / strong) |
|
||||
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON |
|
||||
| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | |
|
||||
| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | |
|
||||
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue