photocraft/crates/engine/src/layer_style.rs

339 lines
16 KiB
Rust

//! `layer.layerStyle.*` commands: add/replace layer effects on a layer.
use photocraft_color::{BlendMode, Color};
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::{EngineError, Result, Session};
/// 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 color(p: &Value, k: &str, d: [f32; 3]) -> Color {
let c = match p.get(k) {
Some(Value::Array(a)) if a.len() >= 3 => [0, 1, 2].map(|i| a[i].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);
if s.len() >= 6 { [h(0), h(2), h(4)] } else { d }
}
_ => d,
};
Color::rgb(c[0], c[1], c[2])
}
fn common(p: &Value, blend: BlendMode, opacity: f32) -> FxCommon {
FxCommon {
enabled: b(p, "enabled", true),
blend: p.get("blend").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(blend),
opacity: (f(p, "opacity", opacity * 100.0) / 100.0).clamp(0.0, 1.0),
}
}
fn gradient(p: &Value) -> Gradient {
Gradient {
stops: vec![(0.0, color(p, "from", [0.0; 3])), (1.0, 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,
reverse: b(p, "reverse", false),
..Gradient::default()
}
}
/// 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> {
let shadow = |blend: BlendMode, inner: bool| Shadow {
common: common(p, blend, 0.75),
color: color(p, "color", [0.0; 3]),
angle: f(p, "angle", 120.0),
use_global_light: b(p, "useGlobalLight", true),
distance: f(p, "distance", 5.0),
spread: f(p, if inner { "choke" } else { "spread" }, 0.0) / 100.0,
size: f(p, "size", 5.0),
contour: Contour::Linear,
anti_alias: false,
noise: f(p, "noise", 0.0) / 100.0,
knocks_out: !inner && b(p, "knocksOut", true),
};
let glow = |inner: bool| Glow {
common: common(p, BlendMode::Screen, 0.75),
paint: FxPaint::Color(color(p, "color", [1.0, 1.0, 190.0 / 255.0])),
technique: if p.get("technique").and_then(Value::as_str) == Some("precise") { GlowTechnique::Precise } else { GlowTechnique::Softer },
spread: f(p, if inner { "choke" } else { "spread" }, 0.0) / 100.0,
size: f(p, "size", 5.0),
contour: Contour::Linear,
anti_alias: false,
range: f(p, "range", 50.0) / 100.0,
jitter: 0.0,
noise: 0.0,
source: if p.get("source").and_then(Value::as_str) == Some("center") { GlowSource::Center } else { GlowSource::Edge },
};
Some(match kind {
"dropShadow" => Effect::DropShadow(shadow(BlendMode::Multiply, false)),
"innerShadow" => Effect::InnerShadow(shadow(BlendMode::Multiply, true)),
"outerGlow" => Effect::OuterGlow(glow(false)),
"innerGlow" => Effect::InnerGlow(glow(true)),
"stroke" => Effect::Stroke(StrokeFx {
common: common(p, BlendMode::Normal, 1.0),
size: f(p, "size", 3.0),
position: match p.get("position").and_then(Value::as_str) {
Some("inside") => StrokePosition::Inside,
Some("center") => StrokePosition::Center,
_ => StrokePosition::Outside,
},
paint: if p.get("from").is_some() { FxPaint::Gradient(gradient(p)) } else { FxPaint::Color(color(p, "color", [1.0, 0.0, 0.0])) },
}),
"colorOverlay" => Effect::ColorOverlay { common: common(p, BlendMode::Normal, 1.0), color: color(p, "color", [1.0, 0.0, 0.0]) },
"gradientOverlay" => Effect::GradientOverlay { common: common(p, BlendMode::Normal, 1.0), gradient: gradient(p), dither: false },
// `name` carries the requested pattern key; `set_effect` resolves it (pattern_cmds).
"patternOverlay" => Effect::PatternOverlay {
common: common(p, BlendMode::Normal, 1.0),
name: p.get("pattern").and_then(Value::as_str).unwrap_or("").to_string(),
id: String::new(),
scale: (f(p, "scale", 100.0) / 100.0).clamp(0.01, 10.0),
angle: f(p, "angle", 0.0),
link: b(p, "link", true),
phase: (f(p, "phaseX", 0.0), f(p, "phaseY", 0.0)),
},
"satin" => Effect::Satin(Satin {
common: common(p, BlendMode::Multiply, 0.5),
color: color(p, "color", [0.0; 3]),
angle: f(p, "angle", 19.0),
distance: f(p, "distance", 11.0),
size: f(p, "size", 14.0),
contour: Contour::Linear,
anti_alias: true,
invert: b(p, "invert", true),
}),
"bevelEmboss" => Effect::BevelEmboss(Bevel {
enabled: b(p, "enabled", true),
style: match p.get("style").and_then(Value::as_str) {
Some("outer") => BevelStyle::OuterBevel,
Some("emboss") => BevelStyle::Emboss,
Some("pillow") => BevelStyle::PillowEmboss,
Some("stroke") => BevelStyle::StrokeEmboss,
_ => BevelStyle::InnerBevel,
},
technique: match p.get("technique").and_then(Value::as_str) {
Some("chiselHard") => BevelTechnique::ChiselHard,
Some("chiselSoft") => BevelTechnique::ChiselSoft,
_ => BevelTechnique::Smooth,
},
depth: f(p, "depth", 100.0) / 100.0,
up: p.get("direction").and_then(Value::as_str) != Some("down"),
size: f(p, "size", 5.0),
soften: f(p, "soften", 0.0),
angle: f(p, "angle", 120.0),
altitude: f(p, "altitude", 30.0),
use_global_light: b(p, "useGlobalLight", true),
gloss_contour: Contour::Linear,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
shadow_color: Color::BLACK,
contour: b(p, "contour", false).then(|| photocraft_doc::BevelContour { contour: Contour::Linear, range: (f(p, "contourRange", 50.0) / 100.0).clamp(0.01, 1.0), anti_alias: false }),
texture: p.get("texture").and_then(Value::as_str).filter(|t| !t.is_empty()).map(|t| 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),
}),
}),
_ => return None,
})
}
fn same_kind(a: &Effect, b: &Effect) -> bool {
std::mem::discriminant(a) == std::mem::discriminant(b)
}
fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
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 = match p.get("layer").and_then(Value::as_u64) {
Some(id) => photocraft_doc::LayerId(id),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?,
};
s.edit(&label, |doc, _| {
if let Some(pat) = &pattern {
crate::pattern_cmds::ensure_in_doc(doc, pat);
}
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
l.effects.enabled = true;
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 }))
}
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,"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,"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":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,"add":bool}"##),
style_cmd!("stroke", "Stroke…", r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"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","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,"contourRange":1..100=50,"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,"invert":bool,"add":bool}"##),
CommandSpec {
id: "layer.layerStyle.clear",
label: "Clear Layer Style",
menu: &["Layer", "Layer Style"],
shortcut: None,
params: r##"{"layer":id}"##,
enabled: has_layer,
run: |s, p| {
let id = match p.get("layer").and_then(Value::as_u64) {
Some(id) => photocraft_doc::LayerId(id),
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?,
};
s.edit("Clear Layer Style", |doc, _| {
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,
},
]
}
#[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));
}
#[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());
}
}
#[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));
}
#[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);
}
#[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);
}
}