Files
figcraft/crates/engine/src/cmd/image.rs
T
BFlatandClaude Opus 5.5 d3dbc6f495 M3: paint: colour profiles (Display P3), image adjustment presets, style picker (#54)
* M3: colour profiles: sRGB or Display P3 per file, convert or keep values, tagged exports

- color: ColorProfile (srgb, displayP3) with conversions derived from the primaries (shared D65
  white and sRGB curve; out-of-gamut channels clip), table-driven pixel conversion, a generated
  ICC v4 matrix/TRC profile and PNG cICP code points.
- doc: Document.color_profile (serde default sRGB, written only when set); every colour value
  (paints, text runs, effects, grids, page and prototype backgrounds, overlay backdrops, styles,
  colour variables, document colours) can be mapped; set_color_profile converts or reinterprets.
- engine: file.colorProfile {profile?, convert?} (File > Color profile...; without a profile it
  opens the dialog); document.inspect reports colorProfile.
- render: images are taken as sRGB and managed into the file's space; encode_export tags PNG
  (sRGB chunk, or iCCP + cICP for Display P3) and embeds the ICC profile in Display P3 JPG/WebP;
  SVG and PDF export Display P3 files converted to sRGB. The CLI exports the same way.
- ui: Color profile dialog (keep appearance / keep values) and a Color profile row on the page
  panel; the canvas, presenter, thumbnails, swatches and colour picker show a Display P3 file's
  colours converted to the screen's sRGB, and Copy as PNG converts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: image adjustment presets: built-in, saved in the file, reset

- color: ImageFilters::PRESETS (Vivid, Muted, Warm, Cool, Black & white, Fade, Reset), lookup by
  id or label, and sanitized values.
- doc: Document.image_presets (name + adjustments; written only when there are some).
- engine: image.adjust {ids?, target?, index?, preset?, filters?} sets the adjustments of image
  paints from a preset (built-in or saved) with values on top, one undo step; image.savePreset,
  image.deletePreset and image.presets.
- ui: the colour picker's image section gets a presets menu named after the preset in effect
  ("Custom" otherwise), saved presets with delete, "Save as preset...", a Reset button, and
  aligned adjustment sliders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M3: style picker: search, folders, list or swatch grid, edit, create, detach

The Fill, Stroke, Effects and Layout grid sections' Styles button opens a popover laid out like
the variable picker (crates/ui-egui/src/style_picker.rs):

- search over style names; styles grouped by the folders of their `/`-separated names, which
  collapse (searching opens them); colour styles as a list or a grid of swatches;
- click applies, a check marks the applied style; the row's edit button (or a right-click) edits
  the name, description and values: colour style paints open the colour picker on the style,
  effect and grid styles update from the selection; delete;
- `+` creates a style from the selection's current values and opens it for naming; detach;
- colour slots still list colour variables to bind;
- the applied style chip opens the picker; new icons Adjust, ViewList, ViewGrid (drawn in code);
- control channel: ui.set {stylePicker, stylePickerEdit, stylePickerView}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:38:17 +07:00

174 lines
7.1 KiB
Rust

//! Image paints: adjustment presets, built in and saved in the file.
use figcraft_color::{ImageFilters, PaintKind};
use figcraft_doc::{Document, ImagePreset};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"image.adjust",
"Adjust image",
"{ids?, target? (fill|stroke, default fill), index? (default every image paint), preset? (vivid|muted|warm|cool|blackAndWhite|fade|reset, or the name of a preset saved in the file), filters? ({exposure, contrast, saturation, temperature, tint, highlights, shadows}: each -1..1, applied over the preset)} sets the adjustments of the layers' image paints; returns {changed}",
true,
adjust
),
spec!(
"image.savePreset",
"Save image preset",
"{name, filters? (default the adjustments of the first image paint of `ids`/the selection), ids?, target?, index?} saved in the file; a preset with the same name is replaced",
true,
save_preset
),
spec!("image.deletePreset", "Delete image preset", "{name}", true, |s, p| {
let name = req_string("image.deletePreset", p, "name")?;
let doc = s.doc_mut()?;
let before = doc.image_presets.len();
doc.image_presets.retain(|x| x.name != name);
if doc.image_presets.len() == before {
return Err(bad("image.deletePreset", format!("no preset named `{name}`")));
}
ok()
}),
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
}),
]
}
/// The longest name a saved preset may have.
const MAX_NAME: usize = 100;
/// The most presets a file keeps.
const MAX_PRESETS: usize = 500;
/// Whether `p` asks for strokes (`target: stroke`) rather than fills.
fn strokes(cmd: &str, p: &Value) -> Result<bool> {
match string(p, "target").as_deref() {
None | Some("fill") | Some("fills") => Ok(false),
Some("stroke") | Some("strokes") => Ok(true),
Some(_) => Err(bad(cmd, "target: fill|stroke")),
}
}
/// The preset `name`: one saved in the file under that exact name, else a built-in one.
pub fn find_preset(doc: &Document, name: &str) -> Option<ImageFilters> {
doc.image_presets.iter().find(|x| x.name == name).map(|x| x.filters).or_else(|| ImageFilters::preset(name))
}
/// `base` with the adjustments `v` names (an object of -1..1 numbers) set.
fn with_values(cmd: &str, base: ImageFilters, v: &Value) -> Result<ImageFilters> {
let Value::Object(m) = v else { return Err(bad(cmd, "filters: an object of adjustments")) };
let mut f = base;
for (k, val) in m {
let x = f64_of(val).ok_or_else(|| bad(cmd, format!("filters.{k}: a number")))? as f32;
let slot = match k.as_str() {
"exposure" => &mut f.exposure,
"contrast" => &mut f.contrast,
"saturation" => &mut f.saturation,
"temperature" => &mut f.temperature,
"tint" => &mut f.tint,
"highlights" => &mut f.highlights,
"shadows" => &mut f.shadows,
_ => return Err(bad(cmd, format!("unknown adjustment `{k}`"))),
};
*slot = x;
}
Ok(f.sanitized())
}
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.adjust";
let ids = req_targets(cmd, s, p)?;
let stroke = strokes(cmd, p)?;
let index = match get(p, "index") {
None | Some(Value::Null) => None,
Some(_) => Some(req_index(cmd, p, "index")?),
};
let preset = match string(p, "preset") {
Some(name) => Some(find_preset(s.doc()?, &name).ok_or_else(|| bad(cmd, format!("no preset named `{name}`")))?),
None => None,
};
let values = get(p, "filters").filter(|v| !v.is_null());
if preset.is_none() && values.is_none() {
return Err(bad(cmd, "pass `preset` or `filters`"));
}
let doc = s.doc_mut()?;
let mut changed = 0;
for id in ids {
let n = doc.get(id)?;
if !if stroke { &n.strokes } else { &n.fills }.iter().any(|x| matches!(x.kind, PaintKind::Image(_))) {
continue;
}
let n = doc.get_mut(id)?;
let list = if stroke { &mut n.strokes } else { &mut n.fills };
let mut touched = false;
for (i, paint) in list.iter_mut().enumerate() {
let PaintKind::Image(img) = &mut paint.kind else { continue };
if index.is_some_and(|k| k != i) {
continue;
}
let base = preset.unwrap_or(img.filters);
img.filters = match values {
Some(v) => with_values(cmd, base, v)?,
None => base,
};
touched = true;
changed += 1;
}
if touched {
// Like any paint edit, the layer leaves its paint style.
if stroke {
n.styles.stroke = None;
} else {
n.styles.fill = None;
}
}
}
if changed == 0 {
return Err(bad(cmd, if index.is_some() { "no image paint at that index" } else { "the layers have no image paints" }));
}
Ok(json!({"changed": changed}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.savePreset";
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
if name.is_empty() {
return Err(bad(cmd, "`name` is empty"));
}
let filters = match get(p, "filters").filter(|v| !v.is_null()) {
Some(v) => with_values(cmd, ImageFilters::NONE, v)?,
None => {
let stroke = strokes(cmd, p)?;
let index = num(p, "index").filter(|k| k.is_finite() && *k >= 0.0).map(|k| k as usize);
let doc = s.doc()?;
targets(s, p)
.iter()
.filter_map(|id| doc.node(*id))
.flat_map(|n| if stroke { n.strokes.iter() } else { n.fills.iter() }.enumerate())
.find_map(|(i, x)| match &x.kind {
PaintKind::Image(img) if index.is_none_or(|k| k == i) => Some(img.filters),
_ => None,
})
.ok_or_else(|| bad(cmd, "pass `filters`, or select a layer with an image paint"))?
}
};
let doc = s.doc_mut()?;
let preset = ImagePreset { name: name.clone(), filters };
match doc.image_presets.iter().position(|x| x.name == name) {
Some(i) => {
if let Some(x) = doc.image_presets.get_mut(i) {
*x = preset;
}
}
None if doc.image_presets.len() >= MAX_PRESETS => return Err(bad(cmd, format!("a file keeps at most {MAX_PRESETS} presets"))),
None => doc.image_presets.push(preset),
}
Ok(json!({"name": name, "filters": filters}))
}