mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
- crates/ui-egui/src/layer_style.rs: values_of keeps the PR's method/smoothness keys and source-stop ends, and main's (#7072) unconditional From/To and geometry fields. - crates/compose/src/cmyk_tests.rs (no textual conflict): add the PR's new `interpolation: None` field to main's CMYK test GradientOverlay literal. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1739 lines
78 KiB
Rust
1739 lines
78 KiB
Rust
//! `layer.layerStyle.*` commands: add/replace layer effects on a layer.
|
||
|
||
use photocraft_color::{BlendMode, Color};
|
||
use photocraft_doc::adjust::CurvePoint;
|
||
use photocraft_doc::{
|
||
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx,
|
||
StrokePosition,
|
||
};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::commands::{CommandSpec, blend_from_str};
|
||
use crate::presets::{always, bad};
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
mod gradient_overlay;
|
||
|
||
/// Parses a gradient style name (`linear`, `radial`, `angle`, `reflected`, `diamond`).
|
||
pub fn gradient_style(s: &str) -> GradientStyle {
|
||
match s.to_ascii_lowercase().as_str() {
|
||
"radial" => GradientStyle::Radial,
|
||
"angle" => GradientStyle::Angle,
|
||
"reflected" => GradientStyle::Reflected,
|
||
"diamond" => GradientStyle::Diamond,
|
||
_ => GradientStyle::Linear,
|
||
}
|
||
}
|
||
|
||
fn f(p: &Value, k: &str, d: f32) -> f32 {
|
||
p.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32)
|
||
}
|
||
fn b(p: &Value, k: &str, d: bool) -> bool {
|
||
p.get(k).and_then(Value::as_bool).unwrap_or(d)
|
||
}
|
||
fn f_of(p: &Value, k: &str) -> Option<f32> {
|
||
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
|
||
}
|
||
fn b_of(p: &Value, k: &str) -> Option<bool> {
|
||
p.get(k).and_then(Value::as_bool)
|
||
}
|
||
fn color_of(p: &Value, k: &str) -> Option<Color> {
|
||
match p.get(k) {
|
||
Some(Value::Array(a)) if a.len() >= 3 => {
|
||
Some(Color::rgb(a[0].as_f64().unwrap_or(0.0) as f32, a[1].as_f64().unwrap_or(0.0) as f32, a[2].as_f64().unwrap_or(0.0) as f32))
|
||
}
|
||
Some(Value::String(s)) => {
|
||
let s = s.trim_start_matches('#');
|
||
let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map_or(0.0, |v| f32::from(v) / 255.0);
|
||
(s.len() >= 6).then_some(Color::rgb(h(0), h(2), h(4)))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
fn color(p: &Value, k: &str, d: [f32; 3]) -> Color {
|
||
let c = color_of(p, k).map_or(d, |c| c.to_rgb());
|
||
Color::rgb(c[0], c[1], c[2])
|
||
}
|
||
/// The stops of a two-colour gradient, baked with Photoshop's interpolation when `method` names
|
||
/// one (`classic`, `perceptual`, `linear`, `smooth`; `smoothness` 0..=100, default 100 as in
|
||
/// Photoshop): the compositors interpolate stops linearly, so the curve becomes dense stops, as on
|
||
/// PSD import. Photoshop 27.11's Classic Gradient Overlay eases (t + smoothstep(t)) / 2 between the
|
||
/// colours: a straight ramp was up to 12 levels off.
|
||
fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> {
|
||
let stops = vec![(0.0, from), (1.0, to)];
|
||
match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) {
|
||
Some(m) => photocraft_io::bake_gradient(stops, &[0.5], (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m),
|
||
None => stops,
|
||
}
|
||
}
|
||
fn gradient(p: &Value) -> Gradient {
|
||
Gradient {
|
||
stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])),
|
||
style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")),
|
||
angle: f(p, "angle", 90.0),
|
||
scale: (f(p, "scale", 100.0) / 100.0).clamp(0.1, 1.5),
|
||
reverse: b(p, "reverse", false),
|
||
..Gradient::default()
|
||
}
|
||
}
|
||
|
||
/// Factory defaults for each effect kind, in the command's parameter units
|
||
/// (what the Layer Style dialog starts a new effect from).
|
||
pub fn effect_defaults(kind: &str) -> Value {
|
||
match kind {
|
||
"dropShadow" => {
|
||
json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "spread": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0, "knocksOut": true})
|
||
}
|
||
"innerShadow" => {
|
||
json!({"blend": "Multiply", "color": "#000000", "opacity": 75, "angle": 120, "useGlobalLight": true, "distance": 5, "choke": 0, "size": 5, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||
}
|
||
"outerGlow" => {
|
||
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "spread": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||
}
|
||
"innerGlow" => {
|
||
json!({"blend": "Screen", "opacity": 75, "color": "#ffffbe", "technique": "softer", "source": "edge", "choke": 0, "size": 5, "range": 50, "contour": "Linear", "antiAlias": false, "noise": 0})
|
||
}
|
||
"stroke" => json!({"size": 1, "position": "inside", "blend": "Normal", "opacity": 100, "overprint": false, "color": "#000000"}),
|
||
"colorOverlay" => json!({"blend": "Normal", "color": "#ff0000", "opacity": 100}),
|
||
"gradientOverlay" => {
|
||
json!({"blend": "Normal", "opacity": 100, "from": "#000000", "to": "#ffffff", "reverse": false, "style": "linear", "angle": 90, "scale": 100, "method": "custom"})
|
||
}
|
||
"patternOverlay" => json!({"blend": "Normal", "opacity": 100, "pattern": "", "angle": 0, "scale": 100, "link": true}),
|
||
"bevelEmboss" => {
|
||
json!({"style": "inner", "technique": "smooth", "depth": 100, "direction": "up", "size": 5, "soften": 0, "angle": 120, "useGlobalLight": true, "altitude": 30, "glossContour": "Linear", "glossAntiAlias": false, "highlightBlend": "Screen", "highlightColor": "#ffffff", "highlightOpacity": 75, "shadowBlend": "Multiply", "shadowColor": "#000000", "shadowOpacity": 75, "contour": false, "contourName": "Linear", "contourRange": 50, "contourAntiAlias": false})
|
||
}
|
||
"satin" => {
|
||
json!({"blend": "Multiply", "color": "#000000", "opacity": 50, "angle": 19, "distance": 11, "size": 14, "contour": "Linear", "antiAlias": true, "invert": true})
|
||
}
|
||
_ => json!({}),
|
||
}
|
||
}
|
||
|
||
/// A contour preset point: input, output (both `0..=1`) and whether it is a corner.
|
||
pub type ContourPoint = (f32, f32, bool);
|
||
|
||
/// Built-in contour presets, in the order of Photoshop's default contour picker. Each is Photoshop's
|
||
/// own curve as measured in Photoshop 27.11 (a Satin of each preset against a Linear one, 38
|
||
/// levels per curve, read back through Satin's mirrored lookup — see
|
||
/// `photocraft_compose::effects::satin_lut` — and thinned to within 1 %), joined by straight
|
||
/// segments (every inner point a corner). Imported PSD contours keep their own points.
|
||
pub const CONTOURS: &[(&str, &[ContourPoint])] = &[
|
||
("Linear", &[(0.0, 0.0, false), (1.0, 1.0, false)]),
|
||
(
|
||
"Cone",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.172, 0.477, true),
|
||
(0.195, 0.555, true),
|
||
(0.266, 0.711, true),
|
||
(0.383, 0.922, true),
|
||
(0.43, 0.969, true),
|
||
(0.477, 0.992, true),
|
||
(0.523, 0.992, true),
|
||
(0.57, 0.969, true),
|
||
(0.664, 0.859, true),
|
||
(0.703, 0.789, true),
|
||
(0.742, 0.711, true),
|
||
(0.836, 0.477, true),
|
||
(0.859, 0.398, true),
|
||
(0.914, 0.266, true),
|
||
(0.945, 0.156, true),
|
||
(0.977, 0.078, true),
|
||
(1.0, 0.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Cone - Inverted",
|
||
&[
|
||
(0.0, 1.0, false),
|
||
(0.031, 0.922, true),
|
||
(0.07, 0.789, true),
|
||
(0.172, 0.523, true),
|
||
(0.195, 0.445, true),
|
||
(0.266, 0.289, true),
|
||
(0.383, 0.078, true),
|
||
(0.43, 0.031, true),
|
||
(0.477, 0.008, true),
|
||
(0.523, 0.008, true),
|
||
(0.57, 0.031, true),
|
||
(0.664, 0.141, true),
|
||
(0.703, 0.211, true),
|
||
(0.742, 0.289, true),
|
||
(0.836, 0.523, true),
|
||
(0.859, 0.602, true),
|
||
(0.914, 0.734, true),
|
||
(0.945, 0.844, true),
|
||
(0.977, 0.922, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Cove - Deep",
|
||
&[
|
||
(0.0, 0.258, false),
|
||
(0.344, 0.289, true),
|
||
(0.383, 0.297, true),
|
||
(0.477, 0.352, true),
|
||
(0.57, 0.43, true),
|
||
(0.617, 0.484, true),
|
||
(0.664, 0.57, true),
|
||
(0.703, 0.711, true),
|
||
(0.742, 0.906, true),
|
||
(0.773, 1.0, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Cove - Shallow",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.008, 0.164, true),
|
||
(0.016, 0.477, true),
|
||
(0.039, 0.508, true),
|
||
(0.172, 0.57, true),
|
||
(0.305, 0.688, true),
|
||
(0.344, 0.734, true),
|
||
(0.477, 0.945, true),
|
||
(0.523, 1.0, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Gaussian",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.117, 0.023, true),
|
||
(0.172, 0.055, true),
|
||
(0.234, 0.117, true),
|
||
(0.305, 0.234, true),
|
||
(0.383, 0.414, true),
|
||
(0.523, 0.688, true),
|
||
(0.617, 0.812, true),
|
||
(0.703, 0.891, true),
|
||
(0.805, 0.953, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Half Round",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.07, 0.18, true),
|
||
(0.195, 0.445, true),
|
||
(0.266, 0.555, true),
|
||
(0.383, 0.703, true),
|
||
(0.523, 0.828, true),
|
||
(0.617, 0.883, true),
|
||
(0.773, 0.945, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Ring",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.031, 0.078, true),
|
||
(0.039, 0.117, true),
|
||
(0.117, 0.383, true),
|
||
(0.172, 0.609, true),
|
||
(0.195, 0.734, true),
|
||
(0.266, 0.953, true),
|
||
(0.305, 1.0, true),
|
||
(0.344, 0.992, true),
|
||
(0.383, 0.922, true),
|
||
(0.43, 0.805, true),
|
||
(0.477, 0.641, true),
|
||
(0.57, 0.281, true),
|
||
(0.617, 0.141, true),
|
||
(0.664, 0.055, true),
|
||
(0.703, 0.016, true),
|
||
(0.742, 0.016, true),
|
||
(0.773, 0.062, true),
|
||
(0.836, 0.234, true),
|
||
(0.859, 0.344, true),
|
||
(0.891, 0.445, true),
|
||
(0.914, 0.547, true),
|
||
(0.945, 0.734, true),
|
||
(0.977, 0.875, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Ring - Double",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.055, 0.086, true),
|
||
(0.094, 0.172, true),
|
||
(0.117, 0.242, true),
|
||
(0.172, 0.477, true),
|
||
(0.195, 0.625, true),
|
||
(0.266, 0.891, true),
|
||
(0.305, 0.945, true),
|
||
(0.344, 0.891, true),
|
||
(0.383, 0.695, true),
|
||
(0.43, 0.367, true),
|
||
(0.477, 0.07, true),
|
||
(0.523, 0.047, true),
|
||
(0.57, 0.32, true),
|
||
(0.617, 0.648, true),
|
||
(0.664, 0.867, true),
|
||
(0.703, 0.93, true),
|
||
(0.742, 0.852, true),
|
||
(0.773, 0.641, true),
|
||
(0.805, 0.375, true),
|
||
(0.836, 0.156, true),
|
||
(0.859, 0.031, true),
|
||
(0.891, 0.023, true),
|
||
(0.914, 0.109, true),
|
||
(0.977, 0.703, true),
|
||
(0.984, 0.82, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Rolling Slope - Descending",
|
||
&[
|
||
(0.0, 1.0, false),
|
||
(0.008, 1.0, true),
|
||
(0.031, 0.93, true),
|
||
(0.039, 0.883, true),
|
||
(0.07, 0.758, true),
|
||
(0.141, 0.539, true),
|
||
(0.195, 0.406, true),
|
||
(0.266, 0.352, true),
|
||
(0.305, 0.352, true),
|
||
(0.523, 0.57, true),
|
||
(0.57, 0.602, true),
|
||
(0.617, 0.617, true),
|
||
(0.703, 0.602, true),
|
||
(0.742, 0.562, true),
|
||
(0.836, 0.391, true),
|
||
(0.914, 0.211, true),
|
||
(0.945, 0.109, true),
|
||
(0.977, 0.039, true),
|
||
(0.984, 0.008, true),
|
||
(1.0, 0.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Rounded Steps",
|
||
&[
|
||
(0.0, 0.234, false),
|
||
(0.031, 0.305, true),
|
||
(0.039, 0.336, true),
|
||
(0.094, 0.43, true),
|
||
(0.141, 0.477, true),
|
||
(0.234, 0.492, true),
|
||
(0.266, 0.516, true),
|
||
(0.305, 0.602, true),
|
||
(0.344, 0.664, true),
|
||
(0.383, 0.711, true),
|
||
(0.477, 0.734, true),
|
||
(0.523, 0.797, true),
|
||
(0.57, 0.898, true),
|
||
(0.617, 0.953, true),
|
||
(0.664, 0.984, true),
|
||
(0.773, 0.984, true),
|
||
(0.836, 0.906, true),
|
||
(0.891, 0.891, true),
|
||
(0.914, 0.898, true),
|
||
(1.0, 1.0, false),
|
||
],
|
||
),
|
||
(
|
||
"Sawtooth 1",
|
||
&[
|
||
(0.0, 0.0, false),
|
||
(0.031, 0.047, true),
|
||
(0.195, 0.375, true),
|
||
(0.266, 0.492, true),
|
||
(0.477, 0.047, true),
|
||
(0.523, 0.055, true),
|
||
(0.742, 0.469, true),
|
||
(0.773, 0.445, true),
|
||
(0.914, 0.18, true),
|
||
(0.945, 0.102, true),
|
||
(1.0, 0.0, false),
|
||
],
|
||
),
|
||
];
|
||
|
||
/// Names earlier versions offered, still accepted by `contour` params (scripts, saved defaults).
|
||
const LEGACY_CONTOURS: &[(&str, &[ContourPoint])] = &[
|
||
("Cone (Inverted)", &[(0.0, 1.0, false), (0.5, 0.0, false), (1.0, 0.0, false)]),
|
||
("Domed", &[(0.0, 0.0, false), (0.25, 1.0, false), (0.75, 1.0, false), (1.0, 0.0, false)]),
|
||
("Domed (Inverted)", &[(0.0, 1.0, false), (0.25, 0.0, false), (0.75, 0.0, false), (1.0, 1.0, false)]),
|
||
("Diagonal (Descending)", &[(0.0, 1.0, false), (1.0, 0.0, false)]),
|
||
];
|
||
|
||
/// Most points a contour param may carry.
|
||
pub const MAX_CONTOUR_POINTS: usize = 64;
|
||
|
||
fn preset_contour(name: &str, pts: &[ContourPoint]) -> Contour {
|
||
let corners: Vec<bool> = pts.iter().map(|p| p.2).collect();
|
||
Contour::Custom {
|
||
name: name.to_string(),
|
||
points: pts.iter().map(|(input, output, _)| CurvePoint { input: *input, output: *output }).collect(),
|
||
corners: if corners.contains(&true) { corners } else { Vec::new() },
|
||
}
|
||
}
|
||
|
||
/// The built-in contour called `name` (`Linear` is the identity); `None` for
|
||
/// names we don't ship, so imported contours are carried rather than clobbered.
|
||
pub fn builtin_contour(name: &str) -> Option<Contour> {
|
||
if name.eq_ignore_ascii_case("Linear") {
|
||
return Some(Contour::Linear);
|
||
}
|
||
CONTOURS.iter().skip(1).chain(LEGACY_CONTOURS).find(|(n, _)| *n == name).map(|(n, pts)| preset_contour(n, pts))
|
||
}
|
||
|
||
/// The param naming a contour: `"Linear"` or a built-in preset's name when the contour is that
|
||
/// preset, else the curve itself as `{"name", "points": [[input, output], …], "corners": [bool, …]}`
|
||
/// (inputs and outputs `0..=1`), so a round trip through the param keeps an imported or edited
|
||
/// curve exactly.
|
||
pub fn contour_param(c: &Contour) -> Value {
|
||
match c {
|
||
Contour::Linear => json!("Linear"),
|
||
Contour::Custom { name, .. } if builtin_contour(name).as_ref() == Some(c) => json!(name),
|
||
Contour::Custom { name, points, corners } => {
|
||
let pts: Vec<Value> = points.iter().map(|p| json!([short(p.input), short(p.output)])).collect();
|
||
let corners: Vec<bool> = (0..points.len()).map(|i| corners.get(i).copied().unwrap_or(false)).collect();
|
||
json!({"name": name, "points": pts, "corners": corners})
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `x` as the shortest decimal that reads back as the same `f32` (`0.2`, not `0.20000000298…`).
|
||
fn short(x: f32) -> Value {
|
||
x.to_string().parse::<f64>().map_or(Value::Null, |v| json!(v))
|
||
}
|
||
|
||
/// The contour a `contour` param names: a built-in preset's name, or a curve object (see
|
||
/// [`contour_param`]). `None` for an unknown name or a malformed curve (fewer than two points,
|
||
/// more than [`MAX_CONTOUR_POINTS`], non-finite values), so the effect keeps its contour.
|
||
/// Points are clamped to `0..=1` and sorted by input; points at the same input collapse.
|
||
pub fn contour_from_param(v: &Value) -> Option<Contour> {
|
||
match v {
|
||
Value::String(name) => builtin_contour(name),
|
||
Value::Object(o) => {
|
||
let raw = o.get("points")?.as_array()?;
|
||
if raw.len() < 2 || raw.len() > MAX_CONTOUR_POINTS {
|
||
return None;
|
||
}
|
||
let flags = o.get("corners").and_then(Value::as_array);
|
||
let mut pts: Vec<ContourPoint> = Vec::with_capacity(raw.len());
|
||
for (i, q) in raw.iter().enumerate() {
|
||
let q = q.as_array()?;
|
||
let (x, y) = (q.first()?.as_f64()? as f32, q.get(1)?.as_f64()? as f32);
|
||
if !x.is_finite() || !y.is_finite() {
|
||
return None;
|
||
}
|
||
let corner = flags.and_then(|f| f.get(i)).and_then(Value::as_bool).unwrap_or(false);
|
||
pts.push((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0), corner));
|
||
}
|
||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
|
||
if pts.len() < 2 {
|
||
return None;
|
||
}
|
||
let name = o.get("name").and_then(Value::as_str).unwrap_or("Custom");
|
||
Some(preset_contour(name, &pts))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The contours of an `.shc` contour preset file (Contour Editor › Load…), as `contour` params.
|
||
/// Points are rescaled from the file's `0..=255` levels; unusable curves are skipped.
|
||
pub fn contours_from_shc(bytes: &[u8]) -> Result<Vec<Value>> {
|
||
let file = photocraft_psd::shc::parse(bytes).map_err(|e| EngineError::Other(format!("not a contour file: {e}")))?;
|
||
Ok(file
|
||
.into_iter()
|
||
.filter_map(|c| {
|
||
let v = json!({
|
||
"name": c.name,
|
||
"points": c.points.iter().map(|p| json!([p.input / 255.0, p.output / 255.0])).collect::<Vec<_>>(),
|
||
"corners": c.points.iter().map(|p| !p.continuous).collect::<Vec<_>>(),
|
||
});
|
||
contour_from_param(&v).map(|c| contour_param(&c))
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// An `.shc` file holding `contour` (Contour Editor › Save…).
|
||
pub fn contour_to_shc(contour: &Contour) -> Result<Vec<u8>> {
|
||
contour_to_shc_all(&[contour])
|
||
}
|
||
|
||
/// An `.shc` file holding every contour of `list` (Save Contours…).
|
||
pub fn contour_to_shc_all(list: &[&Contour]) -> Result<Vec<u8>> {
|
||
let file: Vec<photocraft_psd::shc::ShcContour> = list
|
||
.iter()
|
||
.map(|contour| {
|
||
let (name, points) = match contour {
|
||
Contour::Linear => ("Linear".to_string(), vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]),
|
||
Contour::Custom { name, points, .. } => (name.clone(), points.clone()),
|
||
};
|
||
let points = points
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, p)| photocraft_psd::shc::ShcPoint { input: p.input * 255.0, output: p.output * 255.0, continuous: !contour.is_corner(i) })
|
||
.collect();
|
||
photocraft_psd::shc::ShcContour { name, points }
|
||
})
|
||
.collect();
|
||
photocraft_psd::shc::write(&file).map_err(|e| EngineError::Other(format!("can't write the contour: {e}")))
|
||
}
|
||
|
||
/// An effect of `kind` with its factory settings (before params are applied).
|
||
fn fresh_effect(kind: &str) -> Option<Effect> {
|
||
let shadow = |inner: bool| Shadow {
|
||
common: FxCommon::new(BlendMode::Multiply, 0.75),
|
||
color: Color::BLACK,
|
||
angle: 120.0,
|
||
use_global_light: true,
|
||
distance: 5.0,
|
||
spread: 0.0,
|
||
size: 5.0,
|
||
contour: Contour::Linear,
|
||
anti_alias: false,
|
||
noise: 0.0,
|
||
knocks_out: !inner,
|
||
};
|
||
let glow = || Glow {
|
||
common: FxCommon::new(BlendMode::Screen, 0.75),
|
||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 190.0 / 255.0)),
|
||
technique: GlowTechnique::Softer,
|
||
spread: 0.0,
|
||
size: 5.0,
|
||
contour: Contour::Linear,
|
||
anti_alias: false,
|
||
range: 0.5,
|
||
jitter: 0.0,
|
||
noise: 0.0,
|
||
source: GlowSource::Edge,
|
||
};
|
||
Some(match kind {
|
||
"dropShadow" => Effect::DropShadow(shadow(false)),
|
||
"innerShadow" => Effect::InnerShadow(shadow(true)),
|
||
"outerGlow" => Effect::OuterGlow(glow()),
|
||
"innerGlow" => Effect::InnerGlow(glow()),
|
||
"stroke" => Effect::Stroke(StrokeFx {
|
||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||
size: 1.0,
|
||
position: StrokePosition::Inside,
|
||
paint: FxPaint::Color(Color::BLACK),
|
||
overprint: false,
|
||
}),
|
||
"colorOverlay" => Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 0.0, 0.0) },
|
||
"gradientOverlay" => {
|
||
Effect::GradientOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), gradient: Gradient::default(), dither: false, interpolation: None }
|
||
}
|
||
"patternOverlay" => Effect::PatternOverlay {
|
||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||
name: String::new(),
|
||
id: String::new(),
|
||
scale: 1.0,
|
||
angle: 0.0,
|
||
link: true,
|
||
phase: (0.0, 0.0),
|
||
},
|
||
"satin" => Effect::Satin(Satin {
|
||
common: FxCommon::new(BlendMode::Multiply, 0.5),
|
||
color: Color::BLACK,
|
||
angle: 19.0,
|
||
distance: 11.0,
|
||
size: 14.0,
|
||
contour: Contour::Linear,
|
||
anti_alias: true,
|
||
invert: true,
|
||
}),
|
||
"bevelEmboss" => Effect::BevelEmboss(Bevel {
|
||
enabled: true,
|
||
style: BevelStyle::InnerBevel,
|
||
technique: BevelTechnique::Smooth,
|
||
depth: 1.0,
|
||
up: true,
|
||
size: 5.0,
|
||
soften: 0.0,
|
||
angle: 120.0,
|
||
altitude: 30.0,
|
||
use_global_light: true,
|
||
gloss_contour: Contour::Linear,
|
||
gloss_anti_alias: false,
|
||
highlight: FxCommon::new(BlendMode::Screen, 0.75),
|
||
highlight_color: Color::WHITE,
|
||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||
shadow_color: Color::BLACK,
|
||
contour: None,
|
||
texture: None,
|
||
}),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Edits an existing gradient in place: only the end colours and geometry.
|
||
/// Intermediate stops, opacity stops, alignment and offset survive a round trip.
|
||
fn overlay_gradient(g: &mut Gradient, p: &Value) {
|
||
// A method rebuilds the gradient from its end colours (the stops between are the old curve).
|
||
if p.get("method").and_then(Value::as_str).is_some_and(|m| photocraft_io::GradientMethod::from_name(m).is_some()) {
|
||
let ends = (g.stops.first().map(|s| s.1), g.stops.last().map(|s| s.1));
|
||
let from = color_of(p, "from").or(ends.0).unwrap_or(Color::BLACK);
|
||
let to = color_of(p, "to").or(ends.1).unwrap_or(Color::WHITE);
|
||
g.stops = gradient_stops(p, from, to);
|
||
}
|
||
if let Some(c) = color_of(p, "from")
|
||
&& let Some(first) = g.stops.first_mut()
|
||
{
|
||
first.1 = c;
|
||
}
|
||
if let Some(c) = color_of(p, "to")
|
||
&& let Some(last) = g.stops.last_mut()
|
||
{
|
||
last.1 = c;
|
||
}
|
||
if let Some(s) = p.get("style").and_then(Value::as_str) {
|
||
g.style = gradient_style(s);
|
||
}
|
||
if let Some(x) = f_of(p, "angle") {
|
||
g.angle = x;
|
||
}
|
||
if let Some(x) = f_of(p, "scale") {
|
||
g.scale = (x / 100.0).clamp(0.1, 1.5);
|
||
}
|
||
if let Some(x) = b_of(p, "reverse") {
|
||
g.reverse = x;
|
||
}
|
||
}
|
||
|
||
/// Applies the params present in `p` onto an existing effect, in the command's
|
||
/// parameter units. Absent (or unparseable) keys leave the effect unchanged, so
|
||
/// a dialog round trip never touches what it doesn't model: an imported
|
||
/// gradient stroke keeps its gradient while its opacity is edited.
|
||
pub fn overlay_effect(fx: &mut Effect, p: &Value) {
|
||
if crate::commands::check_f32_values("layer.layerStyle", p).is_err() {
|
||
return;
|
||
}
|
||
fn set_color(p: &Value, k: &str, c: &mut Color) {
|
||
if let Some(x) = color_of(p, k) {
|
||
*c = x;
|
||
}
|
||
}
|
||
fn set_contour(p: &Value, key: &str, c: &mut Contour) {
|
||
if let Some(x) = p.get(key).and_then(contour_from_param) {
|
||
*c = x;
|
||
}
|
||
}
|
||
fn set_bool(p: &Value, key: &str, b: &mut bool) {
|
||
if let Some(x) = b_of(p, key) {
|
||
*b = x;
|
||
}
|
||
}
|
||
fn common(c: &mut FxCommon, p: &Value) {
|
||
if let Some(x) = b_of(p, "enabled") {
|
||
c.enabled = x;
|
||
}
|
||
if let Some(x) = p.get("blend").and_then(Value::as_str).and_then(blend_from_str) {
|
||
c.blend = x;
|
||
}
|
||
if let Some(x) = f_of(p, "opacity") {
|
||
c.opacity = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
}
|
||
let inner_shadow = matches!(fx, Effect::InnerShadow(_));
|
||
let inner_glow = matches!(fx, Effect::InnerGlow(_));
|
||
match fx {
|
||
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
|
||
let inner = inner_shadow;
|
||
common(&mut s.common, p);
|
||
set_color(p, "color", &mut s.color);
|
||
if let Some(x) = f_of(p, "angle") {
|
||
s.angle = x;
|
||
}
|
||
if let Some(x) = b_of(p, "useGlobalLight") {
|
||
s.use_global_light = x;
|
||
}
|
||
if let Some(x) = f_of(p, "distance") {
|
||
s.distance = x;
|
||
}
|
||
if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) {
|
||
s.spread = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
if let Some(x) = f_of(p, "size") {
|
||
s.size = x;
|
||
}
|
||
set_contour(p, "contour", &mut s.contour);
|
||
set_bool(p, "antiAlias", &mut s.anti_alias);
|
||
if let Some(x) = f_of(p, "noise") {
|
||
s.noise = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
if !inner && let Some(x) = b_of(p, "knocksOut") {
|
||
s.knocks_out = x;
|
||
}
|
||
}
|
||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => {
|
||
let inner = inner_glow;
|
||
common(&mut g.common, p);
|
||
if let Some(c) = color_of(p, "color") {
|
||
g.paint = FxPaint::Color(c);
|
||
}
|
||
if let Some(t) = p.get("technique").and_then(Value::as_str) {
|
||
g.technique = if t == "precise" { GlowTechnique::Precise } else { GlowTechnique::Softer };
|
||
}
|
||
if let Some(x) = f_of(p, if inner { "choke" } else { "spread" }) {
|
||
g.spread = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
if let Some(x) = f_of(p, "size") {
|
||
g.size = x;
|
||
}
|
||
set_contour(p, "contour", &mut g.contour);
|
||
set_bool(p, "antiAlias", &mut g.anti_alias);
|
||
if let Some(x) = f_of(p, "range") {
|
||
g.range = (x / 100.0).clamp(0.01, 1.0);
|
||
}
|
||
if let Some(x) = f_of(p, "noise") {
|
||
g.noise = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
if inner && let Some(s) = p.get("source").and_then(Value::as_str) {
|
||
g.source = if s == "center" { GlowSource::Center } else { GlowSource::Edge };
|
||
}
|
||
}
|
||
Effect::Stroke(s) => {
|
||
common(&mut s.common, p);
|
||
if let Some(x) = f_of(p, "size") {
|
||
s.size = x;
|
||
}
|
||
if let Some(pos) = p.get("position").and_then(Value::as_str) {
|
||
s.position = match pos {
|
||
"inside" => StrokePosition::Inside,
|
||
"center" => StrokePosition::Center,
|
||
_ => StrokePosition::Outside,
|
||
};
|
||
}
|
||
set_bool(p, "overprint", &mut s.overprint);
|
||
if p.get("from").is_some() {
|
||
s.paint = FxPaint::Gradient(gradient(p));
|
||
} else if let Some(c) = color_of(p, "color") {
|
||
s.paint = FxPaint::Color(c);
|
||
}
|
||
}
|
||
Effect::ColorOverlay { common: c, color } => {
|
||
common(c, p);
|
||
set_color(p, "color", color);
|
||
}
|
||
Effect::GradientOverlay { common: c, gradient: g, dither, interpolation } => {
|
||
common(c, p);
|
||
gradient_overlay::overlay(g, interpolation, p);
|
||
if let Some(x) = b_of(p, "dither") {
|
||
*dither = x;
|
||
}
|
||
}
|
||
Effect::PatternOverlay { common: c, name, id, scale, angle, link, phase } => {
|
||
common(c, p);
|
||
if let Some(x) = p.get("pattern").and_then(Value::as_str) {
|
||
*name = x.to_string();
|
||
*id = String::new();
|
||
}
|
||
if let Some(x) = f_of(p, "scale") {
|
||
*scale = (x / 100.0).clamp(0.01, 10.0);
|
||
}
|
||
if let Some(x) = f_of(p, "angle") {
|
||
*angle = x;
|
||
}
|
||
if let Some(x) = b_of(p, "link") {
|
||
*link = x;
|
||
}
|
||
if let Some(x) = f_of(p, "phaseX") {
|
||
phase.0 = x;
|
||
}
|
||
if let Some(x) = f_of(p, "phaseY") {
|
||
phase.1 = x;
|
||
}
|
||
}
|
||
Effect::Satin(s) => {
|
||
common(&mut s.common, p);
|
||
set_color(p, "color", &mut s.color);
|
||
if let Some(x) = f_of(p, "angle") {
|
||
s.angle = x;
|
||
}
|
||
if let Some(x) = f_of(p, "distance") {
|
||
s.distance = x;
|
||
}
|
||
if let Some(x) = f_of(p, "size") {
|
||
s.size = x;
|
||
}
|
||
if let Some(x) = b_of(p, "invert") {
|
||
s.invert = x;
|
||
}
|
||
set_contour(p, "contour", &mut s.contour);
|
||
set_bool(p, "antiAlias", &mut s.anti_alias);
|
||
}
|
||
Effect::BevelEmboss(bv) => {
|
||
if let Some(x) = b_of(p, "enabled") {
|
||
bv.enabled = x;
|
||
}
|
||
if let Some(s) = p.get("style").and_then(Value::as_str) {
|
||
bv.style = match s {
|
||
"outer" => BevelStyle::OuterBevel,
|
||
"emboss" => BevelStyle::Emboss,
|
||
"pillow" => BevelStyle::PillowEmboss,
|
||
"stroke" => BevelStyle::StrokeEmboss,
|
||
_ => BevelStyle::InnerBevel,
|
||
};
|
||
}
|
||
if let Some(t) = p.get("technique").and_then(Value::as_str) {
|
||
bv.technique = match t {
|
||
"chiselHard" => BevelTechnique::ChiselHard,
|
||
"chiselSoft" => BevelTechnique::ChiselSoft,
|
||
_ => BevelTechnique::Smooth,
|
||
};
|
||
}
|
||
if let Some(x) = f_of(p, "depth") {
|
||
bv.depth = x / 100.0;
|
||
}
|
||
if let Some(d) = p.get("direction").and_then(Value::as_str) {
|
||
bv.up = d != "down";
|
||
}
|
||
if let Some(x) = f_of(p, "size") {
|
||
bv.size = x;
|
||
}
|
||
if let Some(x) = f_of(p, "soften") {
|
||
bv.soften = x;
|
||
}
|
||
if let Some(x) = f_of(p, "angle") {
|
||
bv.angle = x;
|
||
}
|
||
if let Some(x) = f_of(p, "altitude") {
|
||
bv.altitude = x;
|
||
}
|
||
if let Some(x) = b_of(p, "useGlobalLight") {
|
||
bv.use_global_light = x;
|
||
}
|
||
set_contour(p, "glossContour", &mut bv.gloss_contour);
|
||
set_bool(p, "glossAntiAlias", &mut bv.gloss_anti_alias);
|
||
// Keep imported PSD shading settings when a command edits other bevel fields.
|
||
if let Some(blend) = p.get("highlightBlend").and_then(Value::as_str).and_then(blend_from_str) {
|
||
bv.highlight.blend = blend;
|
||
}
|
||
if let Some(blend) = p.get("shadowBlend").and_then(Value::as_str).and_then(blend_from_str) {
|
||
bv.shadow.blend = blend;
|
||
}
|
||
set_color(p, "highlightColor", &mut bv.highlight_color);
|
||
set_color(p, "shadowColor", &mut bv.shadow_color);
|
||
if let Some(x) = f_of(p, "highlightOpacity") {
|
||
bv.highlight.opacity = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
if let Some(x) = f_of(p, "shadowOpacity") {
|
||
bv.shadow.opacity = (x / 100.0).clamp(0.0, 1.0);
|
||
}
|
||
// The Contour element: `contour` turns it on/off; its curve, Range and
|
||
// Anti-aliased edit the element in place, so an imported curve survives.
|
||
match b_of(p, "contour") {
|
||
Some(false) => bv.contour = None,
|
||
Some(true) if bv.contour.is_none() => bv.contour = Some(photocraft_doc::BevelContour::default()),
|
||
_ => {}
|
||
}
|
||
if let Some(c) = &mut bv.contour {
|
||
set_contour(p, "contourName", &mut c.contour);
|
||
if let Some(x) = f_of(p, "contourRange") {
|
||
c.range = (x / 100.0).clamp(0.01, 1.0);
|
||
}
|
||
set_bool(p, "contourAntiAlias", &mut c.anti_alias);
|
||
}
|
||
if let Some(t) = p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()) {
|
||
bv.texture = Some(photocraft_doc::BevelTexture {
|
||
name: t.to_string(),
|
||
id: t.to_string(),
|
||
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0),
|
||
depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0),
|
||
invert: b(p, "textureInvert", false),
|
||
link: b(p, "textureLink", true),
|
||
phase: (0.0, 0.0),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Builds an effect of `kind` from JSON params (Photoshop units: opacity,
|
||
/// spread, range in percent; sizes in px; angles in degrees).
|
||
pub fn effect_from_params(kind: &str, p: &Value) -> Option<Effect> {
|
||
crate::commands::check_f32_values("layer.layerStyle", p).ok()?;
|
||
let mut fx = fresh_effect(kind)?;
|
||
overlay_effect(&mut fx, p);
|
||
Some(fx)
|
||
}
|
||
|
||
/// The effect's kind as the commands and dialog name it.
|
||
pub fn kind_of(e: &Effect) -> &'static str {
|
||
match e {
|
||
Effect::DropShadow(_) => "dropShadow",
|
||
Effect::InnerShadow(_) => "innerShadow",
|
||
Effect::OuterGlow(_) => "outerGlow",
|
||
Effect::InnerGlow(_) => "innerGlow",
|
||
Effect::Stroke(_) => "stroke",
|
||
Effect::ColorOverlay { .. } => "colorOverlay",
|
||
Effect::GradientOverlay { .. } => "gradientOverlay",
|
||
Effect::PatternOverlay { .. } => "patternOverlay",
|
||
Effect::Satin(_) => "satin",
|
||
Effect::BevelEmboss(_) => "bevelEmboss",
|
||
}
|
||
}
|
||
|
||
fn same_kind(a: &Effect, b: &Effect) -> bool {
|
||
std::mem::discriminant(a) == std::mem::discriminant(b)
|
||
}
|
||
/// Upper bound on the effects one `layer.layerStyle.replace` call accepts, so an
|
||
/// automation mistake can't ask for an unbounded document change.
|
||
const MAX_EFFECTS: usize = 256;
|
||
|
||
/// Replaces a layer's whole effect list in one history step: the Layer Style dialog's
|
||
/// apply. Each entry builds an effect from `params`, or edits the snapshot it carried
|
||
/// (`fx`: the effect JSON as the dialog loaded it, kept verbatim for everything the
|
||
/// dialog doesn't model) with the params present. Unknown kinds, kind/snapshot
|
||
/// mismatches and malformed snapshots are errors, never panics.
|
||
fn replace_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "layer.layerStyle.replace";
|
||
crate::commands::check_f32_values(CMD, p)?;
|
||
let id = crate::commands::layer_param(s, p)?;
|
||
let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?;
|
||
if entries.len() > MAX_EFFECTS {
|
||
return Err(bad(CMD, format!("at most {MAX_EFFECTS} effects")));
|
||
}
|
||
let mut list = Vec::with_capacity(entries.len());
|
||
let mut pats = Vec::new();
|
||
for e in entries {
|
||
let kind = e.get("kind").and_then(Value::as_str).ok_or_else(|| bad(CMD, "each effect needs a `kind` string"))?;
|
||
let params = match e.get("params") {
|
||
None | Some(Value::Null) => json!({}),
|
||
Some(Value::Object(_)) => e.get("params").cloned().unwrap_or_default(),
|
||
Some(_) => return Err(bad(CMD, "`params` must be an object")),
|
||
};
|
||
let fx = match e.get("fx") {
|
||
Some(fx_json) => {
|
||
let mut fx: Effect = serde_json::from_value(fx_json.clone()).map_err(|err| bad(CMD, format!("bad effect snapshot: {err}")))?;
|
||
if kind_of(&fx) != kind {
|
||
return Err(bad(CMD, format!("effect snapshot is a {}, not `{kind}`", kind_of(&fx))));
|
||
}
|
||
overlay_effect(&mut fx, ¶ms);
|
||
fx
|
||
}
|
||
None => effect_from_params(kind, ¶ms).ok_or_else(|| bad(CMD, format!("unknown effect {kind}")))?,
|
||
};
|
||
let (fx, pat) = crate::pattern_cmds::resolve_effect(s, fx)?;
|
||
if let Some(pat) = pat {
|
||
pats.push(pat);
|
||
}
|
||
list.push(fx);
|
||
}
|
||
let count = list.len();
|
||
let label = if list.is_empty() { "Clear Layer Style" } else { "Edit Layer Style" };
|
||
s.edit(label, |doc, _| {
|
||
for pat in &pats {
|
||
crate::pattern_cmds::ensure_in_doc(doc, pat);
|
||
}
|
||
let mode = doc.mode;
|
||
if !list.is_empty() {
|
||
crate::extra_cmds::background_to_layer(doc, id);
|
||
}
|
||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
// The list's master switch (`layer.setEffectsVisible`, the Layers panel's "Effects" eye)
|
||
// survives the edit: the dialog models per-effect state, not the switch (#3648). A layer
|
||
// with no effects yet still has the default `enabled: true`, so its style shows.
|
||
l.effects.items = list.into_iter().map(|e| e.in_mode(mode)).collect();
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "layer": id.0, "effects": count }))
|
||
}
|
||
|
||
fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||
crate::commands::check_f32_values(&format!("layer.layerStyle.{kind}"), p)?;
|
||
let fx = effect_from_params(kind, p).ok_or_else(|| EngineError::Other(format!("unknown effect {kind}")))?;
|
||
let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?;
|
||
let label = format!("Layer Style: {}", fx.label());
|
||
let add = b(p, "add", false);
|
||
let id = crate::commands::layer_param(s, p)?;
|
||
s.edit(&label, |doc, _| {
|
||
if let Some(pat) = &pattern {
|
||
crate::pattern_cmds::ensure_in_doc(doc, pat);
|
||
}
|
||
// Colours in the document's mode, as Image › Mode keeps them.
|
||
let fx = fx.in_mode(doc.mode);
|
||
crate::extra_cmds::background_to_layer(doc, id);
|
||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
// Adding or replacing one effect leaves the list's master switch alone (#3648); a fresh
|
||
// effects list already has the default `enabled: true`, so a first effect still shows.
|
||
match l.effects.items.iter_mut().find(|e| same_kind(e, &fx)) {
|
||
Some(slot) if !add => *slot = fx,
|
||
_ => l.effects.items.push(fx),
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "layer": id.0 }))
|
||
}
|
||
|
||
/// Stores the user default for one effect kind ("Make Default" in the Layer Style dialog).
|
||
fn make_default(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "layer.layerStyle.makeDefault";
|
||
let kind = req_str_kind(p, CMD)?;
|
||
let params = match p.get("params") {
|
||
Some(v) if v.is_object() => v.clone(),
|
||
_ => return Err(bad(CMD, "`params` must be an object")),
|
||
};
|
||
crate::commands::check_f32_values(CMD, ¶ms)?;
|
||
s.presets.layer_defaults.insert(kind.to_string(), params);
|
||
s.presets_changed();
|
||
Ok(json!({"kind": kind}))
|
||
}
|
||
|
||
/// The user default for one effect kind, else its factory defaults.
|
||
fn default_for(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "layer.layerStyle.defaultFor";
|
||
let kind = req_str_kind(p, CMD)?;
|
||
match s.presets.layer_defaults.get(kind) {
|
||
Some(v) if v.is_object() => Ok(json!({"params": v, "user": true})),
|
||
_ => Ok(json!({"params": effect_defaults(kind), "user": false})),
|
||
}
|
||
}
|
||
|
||
fn req_str_kind<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||
let kind = p.get("kind").and_then(Value::as_str).ok_or_else(|| bad(cmd, "need `kind`"))?;
|
||
if effect_from_params(kind, &Value::Null).is_none() {
|
||
return Err(bad(cmd, format!("unknown effect {kind}")));
|
||
}
|
||
Ok(kind)
|
||
}
|
||
|
||
fn has_layer(s: &Session) -> std::result::Result<(), String> {
|
||
let d = s.active().ok_or("no document open")?;
|
||
d.active_layer.filter(|id| d.doc.layer(*id).is_some()).map(|_| ()).ok_or_else(|| "no active layer".into())
|
||
}
|
||
|
||
macro_rules! style_cmd {
|
||
($kind:literal, $label:literal, $params:literal) => {
|
||
CommandSpec {
|
||
id: concat!("layer.layerStyle.", $kind),
|
||
label: $label,
|
||
menu: &["Layer", "Layer Style"],
|
||
shortcut: None,
|
||
params: $params,
|
||
enabled: has_layer,
|
||
run: |s, p| set_effect(s, p, $kind),
|
||
journal: true,
|
||
}
|
||
};
|
||
}
|
||
|
||
/// The `layer.layerStyle.*` command specs.
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
style_cmd!(
|
||
"dropShadow",
|
||
"Drop Shadow…",
|
||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="multiply","angle":deg=120,"useGlobalLight":bool,"distance":px=5,"spread":0..100,"size":px=5,"contour":"Linear|Cone|Cone - Inverted|Cove - Deep|Cove - Shallow|Gaussian|Half Round|Ring|Ring - Double|Rolling Slope - Descending|Rounded Steps|Sawtooth 1"|{"name":str,"points":[[in 0..1,out 0..1],…],"corners":[bool,…]}="Linear","antiAlias":bool,"noise":0..100,"knocksOut":bool,"add":bool,"layer":id}"##
|
||
),
|
||
style_cmd!(
|
||
"innerShadow",
|
||
"Inner Shadow…",
|
||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str,"angle":deg,"distance":px,"choke":0..100,"size":px,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||
),
|
||
style_cmd!(
|
||
"outerGlow",
|
||
"Outer Glow…",
|
||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","spread":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||
),
|
||
style_cmd!(
|
||
"innerGlow",
|
||
"Inner Glow…",
|
||
r##"{"color":"#rrggbb","opacity":0..100=75,"blend":str="screen","technique":"softer|precise","source":"edge|center","choke":0..100,"size":px,"range":1..100=50,"contour":name|curve,"antiAlias":bool,"noise":0..100,"add":bool}"##
|
||
),
|
||
style_cmd!(
|
||
"stroke",
|
||
"Stroke…",
|
||
r##"{"size":px=1,"position":"inside|outside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"overprint":bool,"add":bool}"##
|
||
),
|
||
style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##),
|
||
style_cmd!(
|
||
"gradientOverlay",
|
||
"Gradient Overlay…",
|
||
r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted on new effects: straight stops; edits retain the recorded method),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"##
|
||
),
|
||
style_cmd!(
|
||
"patternOverlay",
|
||
"Pattern Overlay…",
|
||
r##"{"pattern":id|name?=first library pattern,"opacity":0..100=100,"blend":str,"scale":1..1000=100,"angle":deg=0,"link":bool=true,"phaseX":px,"phaseY":px,"add":bool}"##
|
||
),
|
||
style_cmd!(
|
||
"bevelEmboss",
|
||
"Bevel & Emboss…",
|
||
r##"{"style":"inner|outer|emboss|pillow|stroke","technique":"smooth|chiselHard|chiselSoft","contour":bool,"contourName":name|curve,"contourRange":1..100=50,"contourAntiAlias":bool,"glossContour":name|curve,"glossAntiAlias":bool,"texture":pattern id|name?,"textureScale":1..1000=100,"textureDepth":-1000..1000=100,"textureInvert":bool,"textureLink":bool=true,"depth":1..1000=100,"direction":"up|down","size":px=5,"soften":px,"angle":deg,"altitude":deg,"add":bool}"##
|
||
),
|
||
style_cmd!(
|
||
"satin",
|
||
"Satin…",
|
||
r##"{"color":"#rrggbb","opacity":0..100=50,"blend":str,"angle":deg,"distance":px,"size":px,"contour":name|curve,"antiAlias":bool=true,"invert":bool,"add":bool}"##
|
||
),
|
||
CommandSpec {
|
||
id: "layer.layerStyle.replace",
|
||
label: "Edit Layer Style",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: r##"{"layer":id,"effects":[{"kind":"dropShadow|innerShadow|outerGlow|innerGlow|stroke|colorOverlay|gradientOverlay|patternOverlay|satin|bevelEmboss","params":{…layer.layerStyle.<kind> params…},"fx":<effect snapshot carried through the Layer Style dialog, optional>}]} (replaces the layer's whole effect list; an entry with `fx` edits that effect with the params present, one without builds from params)"##,
|
||
enabled: has_layer,
|
||
run: replace_effects,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "layer.layerStyle.makeDefault",
|
||
label: "Make Layer Style Default",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: r##"{"kind":"stroke|dropShadow|innerShadow|outerGlow|innerGlow|colorOverlay|gradientOverlay|patternOverlay|satin|bevelEmboss","params":{…param set…}} (stores the user default the Layer Style dialog's Reset to Default restores)"##,
|
||
enabled: always,
|
||
run: make_default,
|
||
journal: false,
|
||
},
|
||
CommandSpec {
|
||
id: "layer.layerStyle.defaultFor",
|
||
label: "Layer Style Default",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: r##"{"kind":str} → {"params":{…},"user":bool} (the saved user default, else the factory defaults)"##,
|
||
enabled: always,
|
||
run: default_for,
|
||
journal: false,
|
||
},
|
||
CommandSpec {
|
||
id: "layer.layerStyle.clear",
|
||
label: "Clear Layer Style",
|
||
menu: &["Layer", "Layer Style"],
|
||
shortcut: None,
|
||
params: r##"{"layer":id? (no layer: every selected layer that has a style),"effect":index? (only that effect of "layer", as an effect row dropped on the Layers panel's Delete button)}"##,
|
||
enabled: has_layer,
|
||
run: |s, p| {
|
||
if let Some(effect) = p.get("effect").filter(|v| !v.is_null()) {
|
||
return delete_effect(s, p, effect);
|
||
}
|
||
let ids = crate::extra_cmds::style_targets(s, p, |l| !l.effects.items.is_empty())?;
|
||
s.edit("Clear Layer Style", |doc, _| {
|
||
for id in ids {
|
||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
l.effects = photocraft_doc::Effects { enabled: true, ..Default::default() };
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(Value::Null)
|
||
},
|
||
journal: true,
|
||
},
|
||
]
|
||
}
|
||
|
||
/// `layer.layerStyle.clear` with an `effect` index: deletes that one effect of `layer` (the active
|
||
/// layer without one), as Photoshop does for an effect row dropped on the Delete button.
|
||
fn delete_effect(s: &mut Session, p: &Value, effect: &Value) -> Result<Value> {
|
||
const CMD: &str = "layer.layerStyle.clear";
|
||
let index = effect.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "`effect` must be an effect index"))?;
|
||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||
let id = match p.get("layer").filter(|v| !v.is_null()) {
|
||
Some(v) => photocraft_doc::LayerId(v.as_u64().ok_or_else(|| bad(CMD, "`layer` must be a layer id"))?),
|
||
None => d.active_layer.ok_or_else(|| bad(CMD, "no active layer"))?,
|
||
};
|
||
let count = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.effects.items.len();
|
||
if index >= count {
|
||
return Err(bad(CMD, format!("the layer has {count} effect(s), no effect {index}")));
|
||
}
|
||
s.edit("Delete Effect", |doc, _| {
|
||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
if index < l.effects.items.len() {
|
||
l.effects.items.remove(index);
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn effects(s: &Session) -> Vec<Effect> {
|
||
let d = s.active().unwrap();
|
||
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.items.clone()
|
||
}
|
||
|
||
#[test]
|
||
fn bevel_command_sets_technique_contour_and_texture() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.bevelEmboss", json!({"style": "pillow", "technique": "chiselHard", "contour": true, "contourRange": 70, "texture": "Bubbles", "textureDepth": -200, "textureInvert": true})).unwrap();
|
||
let fx = effects(&s);
|
||
let Effect::BevelEmboss(b) = &fx[0] else { panic!("{fx:?}") };
|
||
assert_eq!((b.style, b.technique), (photocraft_doc::BevelStyle::PillowEmboss, BevelTechnique::ChiselHard));
|
||
assert!((b.contour.as_ref().unwrap().range - 0.7).abs() < 1e-6);
|
||
let t = b.texture.as_ref().unwrap();
|
||
assert_eq!((t.name.as_str(), t.depth, t.invert), ("Bubbles", -2.0, true));
|
||
}
|
||
|
||
/// #6698 review: a glow's Range runs 1..100 %, as its slider and Photoshop's do. A 0 is
|
||
/// held at the 1 % floor, and 100 is kept.
|
||
#[test]
|
||
fn glow_range_is_held_to_one_through_one_hundred() {
|
||
for kind in ["outerGlow", "innerGlow"] {
|
||
for (sent, kept) in [(0.0, 0.01), (-5.0, 0.01), (1.0, 0.01), (50.0, 0.5), (100.0, 1.0), (250.0, 1.0)] {
|
||
let mut s = session();
|
||
s.execute(&format!("layer.layerStyle.{kind}"), json!({"range": sent})).unwrap();
|
||
let range = match &effects(&s)[0] {
|
||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.range,
|
||
other => panic!("{other:?}"),
|
||
};
|
||
assert!((range - kept).abs() < 1e-6, "{kind} range {sent} kept as {range}, not {kept}");
|
||
}
|
||
}
|
||
let doc = |id: &str| specs().into_iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default();
|
||
assert!(doc("layer.layerStyle.outerGlow").contains(r#""range":1..100=50"#));
|
||
assert!(doc("layer.layerStyle.innerGlow").contains(r#""range":1..100=50"#));
|
||
}
|
||
|
||
#[test]
|
||
fn bevel_gloss_anti_alias_is_a_param() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.bevelEmboss", json!({"glossContour": "Ring - Double", "glossAntiAlias": true})).unwrap();
|
||
let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() };
|
||
assert!(b.gloss_anti_alias);
|
||
s.execute("layer.layerStyle.bevelEmboss", json!({"glossAntiAlias": false})).unwrap();
|
||
let Effect::BevelEmboss(b) = &effects(&s)[0] else { panic!() };
|
||
assert!(!b.gloss_anti_alias);
|
||
}
|
||
|
||
#[test]
|
||
fn stroke_overprint_is_a_param() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.stroke", json!({"size": 4, "overprint": true})).unwrap();
|
||
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
|
||
assert!(st.overprint);
|
||
s.execute("layer.layerStyle.stroke", json!({"overprint": false})).unwrap();
|
||
let Effect::Stroke(st) = &effects(&s)[0] else { panic!() };
|
||
assert!(!st.overprint);
|
||
}
|
||
|
||
#[test]
|
||
fn bevel_shading_fields_are_independent_and_preserved_on_partial_edits() {
|
||
let mut s = session();
|
||
s.execute(
|
||
"layer.layerStyle.bevelEmboss",
|
||
json!({
|
||
"highlightBlend": "Normal", "highlightColor": "#ff0000", "highlightOpacity": 35,
|
||
"shadowBlend": "Color Burn", "shadowColor": "#0000ff", "shadowOpacity": 60
|
||
}),
|
||
)
|
||
.unwrap();
|
||
let fx = effects(&s);
|
||
let Effect::BevelEmboss(bevel) = &fx[0] else { panic!("{fx:?}") };
|
||
assert_eq!(bevel.highlight.blend, BlendMode::Normal);
|
||
assert_eq!(bevel.highlight_color, Color::rgb(1.0, 0.0, 0.0));
|
||
assert!((bevel.highlight.opacity - 0.35).abs() < 0.001);
|
||
assert_eq!(bevel.shadow.blend, BlendMode::ColorBurn);
|
||
assert_eq!(bevel.shadow_color, Color::rgb(0.0, 0.0, 1.0));
|
||
assert!((bevel.shadow.opacity - 0.60).abs() < 0.001);
|
||
|
||
let mut changed = fx[0].clone();
|
||
overlay_effect(&mut changed, &json!({"size": 12}));
|
||
let Effect::BevelEmboss(partial) = &changed else { panic!("expected bevel") };
|
||
assert_eq!(partial.highlight, bevel.highlight);
|
||
assert_eq!(partial.highlight_color, bevel.highlight_color);
|
||
assert_eq!(partial.shadow, bevel.shadow);
|
||
assert_eq!(partial.shadow_color, bevel.shadow_color);
|
||
|
||
overlay_effect(&mut changed, &json!({"highlightOpacity": 120, "shadowOpacity": -30}));
|
||
let Effect::BevelEmboss(limits) = &changed else { panic!("expected bevel") };
|
||
assert_eq!((limits.highlight.opacity, limits.shadow.opacity), (1.0, 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn every_style_command_adds_its_effect() {
|
||
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "bevelEmboss", "satin"] {
|
||
let mut s = session();
|
||
s.execute(&format!("layer.layerStyle.{kind}"), json!({})).unwrap();
|
||
let fx = effects(&s);
|
||
assert_eq!(fx.len(), 1, "{kind}");
|
||
assert!(fx[0].enabled());
|
||
}
|
||
}
|
||
|
||
/// #1543: a Satin or Bevel & Emboss size or softness far past Photoshop's range rendered a
|
||
/// blur kernel of 2^62 taps ("capacity overflow"), or ran for minutes at 1e6. The blur width
|
||
/// is capped at the effect reach, so the document still renders, quickly.
|
||
#[test]
|
||
fn oversized_satin_and_bevel_sizes_stay_saveable_and_render() {
|
||
for (cmd, p, accepted) in [
|
||
("satin", json!({"size": 1e308}), false),
|
||
("satin", json!({"size": 1e30}), true),
|
||
("satin", json!({"size": 1_000_000}), true),
|
||
("bevelEmboss", json!({"size": 1e308}), false),
|
||
("bevelEmboss", json!({"soften": 1e308}), false),
|
||
("bevelEmboss", json!({"size": 1e30, "soften": 1e30}), true),
|
||
("bevelEmboss", json!({"size": 1_000_000, "soften": 1_000_000}), true),
|
||
] {
|
||
let mut s = session();
|
||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||
let result = s.execute(&format!("layer.layerStyle.{cmd}"), p.clone());
|
||
if accepted {
|
||
result.unwrap();
|
||
} else {
|
||
assert!(matches!(result, Err(EngineError::BadParams { .. })));
|
||
}
|
||
photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap();
|
||
let start = std::time::Instant::now();
|
||
s.execute("document.pixel", json!({"x": 0, "y": 0})).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
|
||
assert!(start.elapsed().as_secs() < 20, "{cmd} {p}: {:?}", start.elapsed());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn params_are_applied_and_replace_or_add() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.stroke", json!({"size": 7, "position": "inside", "color": "#00ff00", "opacity": 50})).unwrap();
|
||
match &effects(&s)[0] {
|
||
Effect::Stroke(st) => {
|
||
assert_eq!(st.size, 7.0);
|
||
assert_eq!(st.position, StrokePosition::Inside);
|
||
assert_eq!(st.common.opacity, 0.5);
|
||
assert_eq!(st.paint, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)));
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
s.execute("layer.layerStyle.stroke", json!({"size": 2})).unwrap();
|
||
assert_eq!(effects(&s).len(), 1, "replaces");
|
||
s.execute("layer.layerStyle.stroke", json!({"size": 4, "add": true})).unwrap();
|
||
assert_eq!(effects(&s).len(), 2, "adds a second instance");
|
||
s.execute("layer.layerStyle.dropShadow", json!({"blend": "normal", "distance": 12})).unwrap();
|
||
assert!(matches!(&effects(&s)[2], Effect::DropShadow(sh) if sh.common.blend == BlendMode::Normal && sh.distance == 12.0));
|
||
}
|
||
|
||
/// The gradient overlay and the gradient stroke keep their scale in Photoshop's 10-150 %.
|
||
#[test]
|
||
fn gradient_overlay_and_stroke_scale_stay_in_range() {
|
||
let scale = |s: &Session| match &effects(s)[0] {
|
||
Effect::GradientOverlay { gradient, .. } => gradient.scale,
|
||
Effect::Stroke(st) => match &st.paint {
|
||
FxPaint::Gradient(g) => g.scale,
|
||
other => panic!("{other:?}"),
|
||
},
|
||
other => panic!("{other:?}"),
|
||
};
|
||
for (cmd, p, want) in [
|
||
("gradientOverlay", json!({"scale": 1000}), 1.5),
|
||
("gradientOverlay", json!({"scale": 1}), 0.1),
|
||
("gradientOverlay", json!({"scale": 80}), 0.8),
|
||
("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": -50}), 0.1),
|
||
("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": 0}), 0.1),
|
||
("stroke", json!({"from": "#000000", "to": "#ffffff", "scale": 400}), 1.5),
|
||
] {
|
||
let mut s = session();
|
||
s.execute(&format!("layer.layerStyle.{cmd}"), p.clone()).unwrap();
|
||
assert!((scale(&s) - want).abs() < 1e-6, "{cmd} {p}: {}", scale(&s));
|
||
}
|
||
// Beyond f32 range is refused before any edit (commands.rs), not clamped.
|
||
let err = session().execute("layer.layerStyle.gradientOverlay", json!({"scale": 1e39})).unwrap_err();
|
||
assert!(format!("{err:?}").contains("finite 32-bit"), "{err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn clear_and_undo() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("layer.layerStyle.clear", json!({})).unwrap();
|
||
assert!(effects(&s).is_empty());
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(effects(&s).len(), 1);
|
||
}
|
||
|
||
fn pixel(s: &mut Session) -> Vec<f32> {
|
||
serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap()
|
||
}
|
||
|
||
fn list_enabled(s: &Session) -> bool {
|
||
let d = s.active().unwrap();
|
||
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.enabled
|
||
}
|
||
|
||
/// #3648: the Layers panel's "Effects" eye (`layer.setEffectsVisible`) must survive a Layer
|
||
/// Style dialog OK: the dialog's whole-list replace re-creates the items but leaves the
|
||
/// list's master switch alone.
|
||
#[test]
|
||
fn replacing_the_list_keeps_a_disabled_effect_list_disabled() {
|
||
let mut s = session();
|
||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap();
|
||
let shown = pixel(&mut s);
|
||
s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap();
|
||
let hidden = pixel(&mut s);
|
||
assert_ne!(hidden, shown, "the eye really hides the style");
|
||
let r = s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "colorOverlay", "params": {"color": "#00ff00"}}]})).unwrap();
|
||
assert_eq!(r["effects"], 1, "the list itself is updated");
|
||
assert!(!list_enabled(&s), "the whole-list switch survives the edit");
|
||
assert!(effects(&s)[0].enabled(), "the effect's own state is updated");
|
||
assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing");
|
||
s.execute("layer.setEffectsVisible", json!({"visible": true})).unwrap();
|
||
assert_ne!(pixel(&mut s), hidden, "re-showing the list draws the edited effect");
|
||
}
|
||
|
||
/// #3648: adding or replacing one effect (`layer.layerStyle.<kind>`) also leaves the master
|
||
/// switch as the user left it.
|
||
#[test]
|
||
fn set_effect_keeps_a_disabled_effect_list_disabled() {
|
||
let mut s = session();
|
||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#0000ff"})).unwrap();
|
||
s.execute("layer.setEffectsVisible", json!({"visible": false})).unwrap();
|
||
let hidden = pixel(&mut s);
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap();
|
||
assert!(!list_enabled(&s), "the whole-list switch survives the edit");
|
||
assert!(effects(&s)[0].enabled(), "the effect's own state is updated");
|
||
assert_eq!(pixel(&mut s), hidden, "a disabled list still draws nothing");
|
||
}
|
||
|
||
/// A layer that never had effects keeps the default: its first effect shows.
|
||
#[test]
|
||
fn a_first_effect_shows_on_a_layer_that_never_had_effects() {
|
||
let mut s = session();
|
||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||
let plain = pixel(&mut s);
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#00ff00"})).unwrap();
|
||
assert!(list_enabled(&s), "a fresh effects list defaults to enabled");
|
||
assert_ne!(pixel(&mut s), plain, "the first effect renders");
|
||
}
|
||
|
||
/// #3102: an effect row dropped on the Delete button deletes only that effect, in one step.
|
||
#[test]
|
||
fn clear_one_effect_by_index() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap();
|
||
let layer = s.active().unwrap().active_layer.unwrap().0;
|
||
s.execute("layer.layerStyle.clear", json!({"layer": layer, "effect": 0})).unwrap();
|
||
assert!(matches!(effects(&s)[..], [Effect::ColorOverlay { .. }]));
|
||
for bad in [json!({"effect": 5}), json!({"effect": -1}), json!({"effect": "x"}), json!({"effect": 0, "layer": u64::MAX})] {
|
||
assert!(s.execute("layer.layerStyle.clear", bad).is_err());
|
||
}
|
||
assert_eq!(effects(&s).len(), 1, "bad params change nothing");
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert_eq!(effects(&s).len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn color_overlay_renders() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap();
|
||
let px: Vec<f32> = serde_json::from_value(s.execute("document.pixel", json!({"x": 3, "y": 3})).unwrap()).unwrap();
|
||
assert!(px[0] > 0.99 && px[1] < 0.01, "{px:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_styles_parse() {
|
||
assert_eq!(gradient_style("Radial"), GradientStyle::Radial);
|
||
assert_eq!(gradient_style("diamond"), GradientStyle::Diamond);
|
||
assert_eq!(gradient_style("?"), GradientStyle::Linear);
|
||
}
|
||
|
||
fn snapshot(fx: &Effect) -> Value {
|
||
serde_json::to_value(fx).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn every_kind_has_factory_defaults() {
|
||
for kind in
|
||
["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "satin", "bevelEmboss"]
|
||
{
|
||
assert!(effect_defaults(kind).as_object().is_some_and(|o| !o.is_empty()), "{kind}");
|
||
assert!(effect_from_params(kind, &json!({})).is_some(), "{kind}");
|
||
}
|
||
assert!(effect_from_params("nope", &json!({})).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn replace_builds_edits_and_preserves_instances() {
|
||
let mut s = session();
|
||
// Two strokes: the first edits a snapshot (params overlay), the second is fresh.
|
||
let first = Effect::Stroke(StrokeFx {
|
||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
|
||
size: 7.0,
|
||
position: StrokePosition::Outside,
|
||
paint: FxPaint::Gradient(Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::WHITE)], ..Gradient::default() }),
|
||
overprint: false,
|
||
});
|
||
let r = s
|
||
.execute(
|
||
"layer.layerStyle.replace",
|
||
json!({"effects": [
|
||
{"kind": "stroke", "fx": snapshot(&first), "params": {"opacity": 50}},
|
||
{"kind": "stroke", "params": {"size": 2, "color": "#ffffff"}},
|
||
]}),
|
||
)
|
||
.unwrap();
|
||
assert_eq!(r["effects"], 2);
|
||
let fx = effects(&s);
|
||
assert_eq!(fx.len(), 2, "order preserved");
|
||
match (&fx[0], &fx[1]) {
|
||
(Effect::Stroke(a), Effect::Stroke(b)) => {
|
||
assert_eq!(a.common.opacity, 0.5);
|
||
// The overlay must not clobber the gradient paint it doesn't model.
|
||
assert!(matches!(&a.paint, FxPaint::Gradient(g) if g.stops.len() == 2 && g.stops[0].1 == Color::rgb(0.0, 0.0, 1.0)));
|
||
assert_eq!(b.size, 2.0);
|
||
assert_eq!(b.paint, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)));
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
// Replacing with an empty list clears the layer.
|
||
s.execute("layer.layerStyle.replace", json!({"effects": []})).unwrap();
|
||
assert!(effects(&s).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn replace_rejects_bad_entries_gracefully() {
|
||
let mut s = session();
|
||
let stroke = Effect::Stroke(StrokeFx {
|
||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
|
||
size: 3.0,
|
||
position: StrokePosition::Outside,
|
||
paint: FxPaint::Color(Color::BLACK),
|
||
overprint: false,
|
||
});
|
||
for p in [
|
||
json!({}),
|
||
json!({"effects": "nope"}),
|
||
json!({"effects": [{"params": {}}]}),
|
||
json!({"effects": [{"kind": "nope"}]}),
|
||
json!({"effects": [{"kind": "stroke", "fx": {"Bogus": {}}}]}),
|
||
json!({"effects": [{"kind": "stroke", "fx": 3}]}),
|
||
json!({"effects": [{"kind": "dropShadow", "fx": snapshot(&stroke)}]}),
|
||
json!({"effects": [{"kind": "stroke", "fx": snapshot(&stroke), "params": "nope"}]}),
|
||
] {
|
||
assert!(s.execute("layer.layerStyle.replace", p).is_err());
|
||
}
|
||
// A long list is refused, not run.
|
||
let many: Vec<_> = (0..257).map(|_| json!({"kind": "stroke"})).collect();
|
||
assert!(s.execute("layer.layerStyle.replace", json!({"effects": many})).is_err());
|
||
assert!(effects(&s).is_empty(), "nothing was applied");
|
||
}
|
||
|
||
#[test]
|
||
fn contour_and_noise_params_edit_existing_effects() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
|
||
let before = effects(&s);
|
||
let Effect::DropShadow(_) = &before[0] else { panic!("{before:?}") };
|
||
let fx0 = snapshot(&before[0]);
|
||
// Unknown contour names are carried, not clobbered to Linear.
|
||
s.execute("layer.layerStyle.replace", json!({"effects": [{"kind": "dropShadow", "fx": fx0, "params": {"contour": "Cone", "noise": 30}}]})).unwrap();
|
||
let fx = effects(&s);
|
||
let Effect::DropShadow(sh) = &fx[0] else { panic!("{fx:?}") };
|
||
assert_eq!(sh.contour, builtin_contour("Cone").unwrap(), "known preset applies");
|
||
assert!((sh.noise - 0.3).abs() < 1e-6, "noise in percent");
|
||
// An imported contour with an unknown name survives the same round trip.
|
||
let imported = Contour::Custom {
|
||
name: "Photoshop contour".into(),
|
||
points: vec![photocraft_doc::adjust::CurvePoint { input: 0.0, output: 1.0 }, photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 }],
|
||
corners: vec![],
|
||
};
|
||
let mut carried = before[0].clone();
|
||
if let Effect::DropShadow(c) = &mut carried {
|
||
c.contour = imported.clone();
|
||
}
|
||
overlay_effect(&mut carried, &json!({"contour": "Photoshop contour"}));
|
||
let Effect::DropShadow(after) = &carried else { panic!() };
|
||
assert_eq!(after.contour, imported);
|
||
// Linear applies by name.
|
||
let mut back = carried;
|
||
overlay_effect(&mut back, &json!({"contour": "Linear"}));
|
||
let Effect::DropShadow(lin) = &back else { panic!() };
|
||
assert_eq!(lin.contour, Contour::Linear);
|
||
}
|
||
|
||
#[test]
|
||
fn contour_presets_match_photoshop() {
|
||
// Photoshop 27.11, measured through Satin: Cone and Cone − Inverted Satins are identical,
|
||
// and Gaussian reads 0.36 at 0.5 through Satin but 0.64 as the preset curve (glows, gloss).
|
||
let lut = |name: &str| {
|
||
let Some(Contour::Custom { points, corners, .. }) = builtin_contour(name) else { panic!("{name}") };
|
||
photocraft_compose::adjust::contour_curve_lut(&points, &corners)
|
||
};
|
||
let satin = |name: &str| photocraft_compose::effects::satin_lut(&builtin_contour(name).unwrap()).unwrap();
|
||
let at = |l: &[f32], x: f32| l[(x * (l.len() - 1) as f32).round() as usize];
|
||
let (cone, inverted) = (satin("Cone"), satin("Cone - Inverted"));
|
||
for x in [0.1, 0.25, 0.5, 0.8] {
|
||
assert!((at(&cone, x) - at(&inverted, x)).abs() < 0.03, "{x}: {} vs {}", at(&cone, x), at(&inverted, x));
|
||
}
|
||
assert!((at(&satin("Gaussian"), 0.5) - 0.36).abs() < 0.04, "{}", at(&satin("Gaussian"), 0.5));
|
||
assert!((at(&lut("Gaussian"), 0.5) - 0.64).abs() < 0.03, "{}", at(&lut("Gaussian"), 0.5));
|
||
assert!((at(&lut("Cove - Deep"), 0.0) - 0.26).abs() < 0.02, "Cove - Deep starts above zero");
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_overlay_method_matches_photoshop() {
|
||
// Photoshop 27.11, Classic Gradient Overlay #000000 → #ffffff (Smoothness 100 %): 0.037 at
|
||
// t = 1/16 and 0.5 in the middle; straight stops would give 0.0625.
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "method": "classic"})).unwrap();
|
||
let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() };
|
||
let at = |t: f32| {
|
||
let w = gradient.stops.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).unwrap();
|
||
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-6);
|
||
w[0].1.to_rgb()[0] + (w[1].1.to_rgb()[0] - w[0].1.to_rgb()[0]) * u
|
||
};
|
||
assert!((at(1.0 / 16.0) - 0.037).abs() < 0.004, "{}", at(1.0 / 16.0));
|
||
assert!((at(0.5) - 0.5).abs() < 0.004, "{}", at(0.5));
|
||
// Without a method the two stops stay as they are.
|
||
s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "add": true})).unwrap();
|
||
let plain = effects(&s).iter().filter(|e| matches!(e, Effect::GradientOverlay { gradient, .. } if gradient.stops.len() == 2)).count();
|
||
assert_eq!(plain, 1, "the added overlay keeps its two stops");
|
||
}
|
||
|
||
#[test]
|
||
fn contour_presets_and_custom_curves_apply_in_one_undo_step() {
|
||
let mut s = session();
|
||
// Picking Cone: the preset's own curve, its points corners, in one undo step.
|
||
s.execute("layer.layerStyle.dropShadow", json!({"contour": "Cone", "antiAlias": true})).unwrap();
|
||
let Effect::DropShadow(sh) = &effects(&s)[0] else { panic!() };
|
||
assert_eq!(sh.contour, builtin_contour("Cone").unwrap());
|
||
assert!(sh.contour.is_corner(1) && sh.anti_alias);
|
||
let Contour::Custom { points, corners, .. } = &sh.contour else { panic!() };
|
||
let lut = photocraft_compose::adjust::contour_curve_lut(points, corners);
|
||
let at = |x: f32| lut[(x * (lut.len() - 1) as f32).round() as usize];
|
||
// Photoshop's measured Cone: 0 at both ends, a 0.99 peak at 0.5, symmetric.
|
||
assert!(at(0.0).abs() < 1e-3 && at(1.0).abs() < 1e-3 && at(0.5) > 0.98, "a Λ: {} {} {}", at(0.0), at(0.5), at(1.0));
|
||
assert!((at(0.25) - at(0.75)).abs() < 0.03, "symmetric: {} vs {}", at(0.25), at(0.75));
|
||
assert_eq!(contour_param(&sh.contour), json!("Cone"));
|
||
s.execute("edit.undo", json!({})).unwrap();
|
||
assert!(effects(&s).is_empty(), "one undo removes it");
|
||
|
||
// An edited curve (the Contour Editor's param) round trips exactly, in every effect.
|
||
let curve = json!({"name": "Mine", "points": [[0.0, 0.2], [0.4, 0.9], [1.0, 0.1]], "corners": [false, true, false]});
|
||
for kind in ["dropShadow", "innerShadow", "outerGlow", "innerGlow", "satin"] {
|
||
let mut fx = fresh_effect(kind).unwrap();
|
||
overlay_effect(&mut fx, &json!({"contour": curve}));
|
||
let v = values(&fx, "contour");
|
||
assert_eq!(v, curve, "{kind}");
|
||
}
|
||
let mut bevel = fresh_effect("bevelEmboss").unwrap();
|
||
overlay_effect(&mut bevel, &json!({"contour": true, "contourName": curve, "glossContour": "Ring"}));
|
||
let Effect::BevelEmboss(b) = &bevel else { panic!() };
|
||
assert_eq!(contour_param(&b.contour.as_ref().unwrap().contour), curve);
|
||
assert_eq!(b.gloss_contour, builtin_contour("Ring").unwrap());
|
||
// Malformed curves leave the contour alone.
|
||
for bad in [json!({"points": [[0.5, 0.5]]}), json!({"points": [[0.0, "x"], [1.0, 1.0]]}), json!({"points": "no"}), json!(7)] {
|
||
let mut fx = fresh_effect("dropShadow").unwrap();
|
||
overlay_effect(&mut fx, &json!({"contour": bad}));
|
||
assert_eq!(values(&fx, "contour"), json!("Linear"));
|
||
}
|
||
|
||
// `.shc` round trip of a curve with a corner.
|
||
let mine = contour_from_param(&curve).unwrap();
|
||
let back = contours_from_shc(&contour_to_shc(&mine).unwrap()).unwrap();
|
||
let back = contour_from_param(&back[0]).unwrap();
|
||
let Contour::Custom { points, corners, .. } = &back else { panic!() };
|
||
assert_eq!(corners, &vec![false, true, false]);
|
||
assert!((points[1].input - 0.4).abs() < 0.003 && (points[1].output - 0.9).abs() < 0.003, "{points:?}");
|
||
}
|
||
|
||
fn values(fx: &Effect, key: &str) -> Value {
|
||
let c = match fx {
|
||
Effect::DropShadow(s) | Effect::InnerShadow(s) => &s.contour,
|
||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => &g.contour,
|
||
Effect::Satin(s) => &s.contour,
|
||
_ => panic!("{key}: no contour"),
|
||
};
|
||
contour_param(c)
|
||
}
|
||
|
||
#[test]
|
||
fn make_default_then_reset_restores_it() {
|
||
let mut s = session();
|
||
// Factory default first: Reset without a saved default gives the factory set.
|
||
let r = s.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap();
|
||
assert_eq!(r["user"], json!(false));
|
||
assert_eq!(r["params"]["size"], json!(1));
|
||
s.execute(
|
||
"layer.layerStyle.makeDefault",
|
||
json!({"kind": "stroke", "params": {"size": 9, "position": "inside", "blend": "Normal", "opacity": 100, "color": "#000000"}}),
|
||
)
|
||
.unwrap();
|
||
let r = s.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap();
|
||
assert_eq!(r["user"], json!(true));
|
||
assert_eq!(r["params"]["size"], json!(9));
|
||
// Bad kinds and params are graceful errors.
|
||
assert!(s.execute("layer.layerStyle.makeDefault", json!({"kind": "nope", "params": {}})).is_err());
|
||
assert!(s.execute("layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": "x"})).is_err());
|
||
assert!(s.execute("layer.layerStyle.defaultFor", json!({"kind": "nope"})).is_err());
|
||
}
|
||
|
||
/// #7765: a stroke built without params was red, while the dialog's factory default was
|
||
/// black. Both now give Photoshop's Reset to Default: 1 px, inside, Normal, 100 %, black.
|
||
#[test]
|
||
fn fresh_effects_match_the_dialog_factory_defaults() {
|
||
for kind in
|
||
["dropShadow", "innerShadow", "outerGlow", "innerGlow", "stroke", "colorOverlay", "gradientOverlay", "patternOverlay", "satin", "bevelEmboss"]
|
||
{
|
||
let bare = effect_from_params(kind, &json!({})).unwrap();
|
||
let dialog = effect_from_params(kind, &effect_defaults(kind)).unwrap();
|
||
assert_eq!(bare, dialog, "{kind}");
|
||
}
|
||
let Some(Effect::Stroke(st)) = effect_from_params("stroke", &json!({})) else { panic!("no stroke") };
|
||
assert_eq!((st.size, st.position, st.common.blend, st.common.opacity), (1.0, StrokePosition::Inside, BlendMode::Normal, 1.0));
|
||
assert_eq!(st.paint, FxPaint::Color(Color::BLACK));
|
||
}
|
||
|
||
#[test]
|
||
fn layer_style_defaults_persist_with_presets() {
|
||
let mut s = session();
|
||
s.execute("layer.layerStyle.makeDefault", json!({"kind": "stroke", "params": {"size": 9}})).unwrap();
|
||
let blob = s.presets.to_json(&s);
|
||
let mut t = Session::new();
|
||
t.load_presets_json(blob);
|
||
let r = t.execute("layer.layerStyle.defaultFor", json!({"kind": "stroke"})).unwrap();
|
||
assert_eq!(r["user"], json!(true));
|
||
assert_eq!(r["params"]["size"], json!(9));
|
||
}
|
||
|
||
#[test]
|
||
fn double_stroke_bands_render_and_survive_an_edit() {
|
||
// The manga/typesetting case (#157): a 7 px white stroke under a 3 px black one.
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 48, "height": 48})).unwrap();
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
s.execute("select.rect", json!({"x": 16, "y": 16, "width": 16, "height": 16})).unwrap();
|
||
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
|
||
s.execute("select.deselect", json!({})).unwrap();
|
||
s.execute("layer.layerStyle.stroke", json!({"size": 3, "color": "#000000", "position": "outside"})).unwrap();
|
||
s.execute("layer.layerStyle.stroke", json!({"size": 7, "color": "#ffffff", "position": "outside", "add": true})).unwrap();
|
||
let px = |s: &mut Session, x: i32, y: i32| -> [f32; 4] {
|
||
let mut v: Vec<f32> = serde_json::from_value(s.execute("document.pixel", json!({"x": x, "y": y})).unwrap()).unwrap();
|
||
v.resize(4, 1.0);
|
||
[v[0], v[1], v[2], v[3]]
|
||
};
|
||
// Bands from the square edge outward: 0..3 px black (the first stroke renders on top),
|
||
// 3..7 px white, then the white background.
|
||
assert!(px(&mut s, 24, 24)[0] < 0.1, "the square is black");
|
||
assert!(px(&mut s, 24, 14)[0] < 0.1, "the first stroke covers the wider one");
|
||
assert!(px(&mut s, 24, 11)[0] > 0.9, "the second stroke shows outside it");
|
||
assert!(px(&mut s, 24, 5)[0] > 0.9, "background beyond both");
|
||
// A dialog round trip (replace with the same snapshots) keeps both bands.
|
||
let before = px(&mut s, 24, 14);
|
||
let st = s.active().unwrap();
|
||
let id = st.active_layer.unwrap();
|
||
let items = st.doc.layer(id).unwrap().effects.items.clone();
|
||
let entries: Vec<Value> = items.iter().map(|fx| json!({"kind": "stroke", "fx": serde_json::to_value(fx).unwrap(), "params": {}})).collect();
|
||
s.execute("layer.layerStyle.replace", json!({"layer": id.0, "effects": entries})).unwrap();
|
||
assert_eq!(px(&mut s, 24, 14), before, "both strokes render identically after the round trip");
|
||
}
|
||
}
|