mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 02:02:04 +01:00
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667) Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment. * New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order * Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal * Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied * Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute * Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
238 lines
11 KiB
Rust
238 lines
11 KiB
Rust
//! Layer › New › Layer: Photoshop's New Layer options (name, Use Previous Layer to Create Clipping
|
||
//! Mask, color label, mode, opacity, and Fill with neutral color), applied in one history step.
|
||
|
||
use photocraft_doc::{BlendMode, LabelColor, Layer};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
const CMD: &str = "layer.new.layer";
|
||
|
||
fn bad(msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
||
}
|
||
|
||
/// The color that leaves everything below unchanged in `mode` (Photoshop's "Fill with
|
||
/// Multiply-neutral color (white)"), as RGB, and its name. `None` for modes that have none.
|
||
pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> {
|
||
use BlendMode::*;
|
||
match mode {
|
||
Darken | Multiply | ColorBurn | LinearBurn | DarkerColor | Divide => Some(([1.0; 3], "white")),
|
||
Lighten | Screen | ColorDodge | LinearDodge | LighterColor | Difference | Exclusion | Subtract => Some(([0.0; 3], "black")),
|
||
Overlay | SoftLight | HardLight | VividLight | LinearLight | PinLight => Some(([0.5; 3], "50% gray")),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// A New Layer call's params, checked.
|
||
pub(crate) struct NewLayer {
|
||
clip: bool,
|
||
mode: BlendMode,
|
||
opacity: f32,
|
||
label: LabelColor,
|
||
neutral: Option<[f32; 3]>,
|
||
below: bool,
|
||
name: Option<String>,
|
||
}
|
||
|
||
/// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the
|
||
/// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in.
|
||
pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?;
|
||
let id = s.edit("New Layer", |doc, active| {
|
||
let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer"));
|
||
let mut layer = Layer::raster(name, doc.pixel_format());
|
||
layer.blend = mode;
|
||
layer.opacity = opacity;
|
||
layer.label = label;
|
||
if let Some(rgb) = neutral {
|
||
let bounds = doc.bounds();
|
||
let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?;
|
||
let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]);
|
||
surface.fill_rect(bounds, &px);
|
||
}
|
||
// Nothing may sit under the Background: below it the layer lands above it instead.
|
||
let id = match *active {
|
||
Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer),
|
||
_ => doc.insert_above(*active, layer),
|
||
};
|
||
crate::clip_group::settle(doc, &[id], &Default::default(), clip);
|
||
*active = Some(id);
|
||
Ok(id)
|
||
})?;
|
||
crate::layer_multi_cmds::note_inert_insert(s, id);
|
||
Ok(json!({ "layer": id.0 }))
|
||
}
|
||
|
||
/// Check a New Layer call's params without running it (Free Transform applies its box before a
|
||
/// new layer only when the new layer can be made).
|
||
pub(crate) fn parse(p: &Value) -> Result<NewLayer> {
|
||
let flag = |k: &str| match p.get(k) {
|
||
None | Some(Value::Null) => Ok(false),
|
||
Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))),
|
||
};
|
||
let clip = flag("clipToPrevious")?;
|
||
let fill_neutral = flag("fillNeutral")?;
|
||
let mode = match p.get("mode") {
|
||
None | Some(Value::Null) => BlendMode::Normal,
|
||
Some(v) => {
|
||
let name = v.as_str().ok_or_else(|| bad("`mode` must be a blend mode name"))?;
|
||
crate::commands::blend_from_str(name).filter(|m| *m != BlendMode::PassThrough).ok_or_else(|| bad(format!("unknown layer blend mode `{name}`")))?
|
||
}
|
||
};
|
||
let opacity = match p.get("opacity") {
|
||
None | Some(Value::Null) => 1.0,
|
||
Some(v) => v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad("`opacity` must be a number from 0 to 1"))?.clamp(0.0, 1.0) as f32,
|
||
};
|
||
let label = match p.get("color") {
|
||
None | Some(Value::Null) => LabelColor::None,
|
||
Some(v) => v.as_str().and_then(LabelColor::from_id).ok_or_else(|| bad(format!("`color` must be a layer color id, not {v}")))?,
|
||
};
|
||
let neutral =
|
||
if fill_neutral { Some(neutral_color(mode).ok_or_else(|| bad(format!("{} has no neutral color to fill with", mode.label())))?.0) } else { None };
|
||
let below = match p.get("position") {
|
||
None | Some(Value::Null) => false,
|
||
Some(v) => match v.as_str() {
|
||
Some("above") => false,
|
||
Some("below") => true,
|
||
_ => return Err(bad("`position` must be above or below")),
|
||
},
|
||
};
|
||
let name = match p.get("name") {
|
||
None | Some(Value::Null) => None,
|
||
Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()),
|
||
};
|
||
Ok(NewLayer { clip, mode, opacity, label, neutral, below, name })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use photocraft_doc::{BlendMode, LabelColor};
|
||
use photocraft_geom::Rect;
|
||
use serde_json::json;
|
||
|
||
use super::neutral_color;
|
||
use crate::Session;
|
||
|
||
/// A document with a busy Background: every pixel a different colour.
|
||
fn busy(mode: &str, depth: u32) -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", json!({"width": 32, "height": 32, "mode": mode, "depth": depth})).unwrap();
|
||
s.edit("busy", |doc, active| {
|
||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||
let fmt = surf.format();
|
||
for y in 0..32 {
|
||
for x in 0..32 {
|
||
let rgba = [x as f32 / 31.0, y as f32 / 31.0, ((x * 7 + y * 3) % 32) as f32 / 31.0, 1.0];
|
||
surf.fill_rect(Rect::new(x, y, x + 1, y + 1), &photocraft_raster::from_rgba(&fmt, rgba));
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
s
|
||
}
|
||
|
||
fn max_diff(a: &photocraft_compose::Buffer, b: &photocraft_compose::Buffer) -> f32 {
|
||
a.px.iter().zip(&b.px).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0, f32::max)
|
||
}
|
||
|
||
/// Fill with neutral color leaves the composite unchanged in every mode that offers it, at
|
||
/// every depth; the wrong neutral (gray for Multiply) does change it.
|
||
#[test]
|
||
fn neutral_fills_leave_the_image_unchanged() {
|
||
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("gray", 16)] {
|
||
let s0 = busy(mode, depth);
|
||
let doc0 = s0.active().unwrap().doc.clone();
|
||
let shown = photocraft_compose::render(&doc0, doc0.bounds());
|
||
for m in BlendMode::LAYER_MODES.into_iter().filter(|m| neutral_color(*m).is_some()) {
|
||
let mut s = busy(mode, depth);
|
||
s.execute("layer.new.layer", json!({"mode": m.label(), "fillNeutral": true})).unwrap();
|
||
let doc = &s.active().unwrap().doc;
|
||
let top = doc.layers.last().unwrap();
|
||
assert_eq!(top.blend, m);
|
||
assert!(top.surface().unwrap().tile_count() > 0, "{m:?} filled");
|
||
let d = max_diff(&photocraft_compose::render(doc, doc.bounds()), &shown);
|
||
// 8-bit stores 50% gray as 128 (0.502), which can move a pixel by one level.
|
||
let tolerance = if depth == 8 { 1.0 / 255.0 + 1e-6 } else { 1e-3 };
|
||
assert!(d <= tolerance, "{mode}/{depth}: {m:?} changed the image by {d}");
|
||
}
|
||
let mut s = busy(mode, depth);
|
||
s.execute("layer.new.layer", json!({"mode": "Multiply"})).unwrap();
|
||
s.execute("edit.fill", json!({"contents": "gray"})).unwrap();
|
||
let doc = &s.active().unwrap().doc;
|
||
assert!(max_diff(&photocraft_compose::render(doc, doc.bounds()), &shown) > 0.1, "{mode}/{depth}: the control changes it");
|
||
}
|
||
for m in [BlendMode::Normal, BlendMode::Dissolve, BlendMode::HardMix, BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] {
|
||
assert!(neutral_color(m).is_none(), "{m:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn options_apply_in_one_step() {
|
||
let mut s = busy("rgb", 8);
|
||
let steps = s.active().unwrap().history.past_len();
|
||
let id =
|
||
s.execute("layer.new.layer", json!({"name": "Shade", "mode": "Overlay", "opacity": 0.4, "color": "blue", "fillNeutral": true})).unwrap()["layer"]
|
||
.as_u64()
|
||
.unwrap();
|
||
let st = s.active().unwrap();
|
||
assert_eq!(st.history.past_len(), steps + 1);
|
||
let l = st.doc.layer(photocraft_doc::LayerId(id)).unwrap();
|
||
assert_eq!((l.name.as_str(), l.blend, l.opacity, l.label), ("Shade", BlendMode::Overlay, 0.4, LabelColor::Blue));
|
||
assert_eq!(st.active_layer, Some(l.id));
|
||
// Defaults: what the Layers panel button makes.
|
||
s.execute("layer.new.layer", json!({})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let l = st.doc.layers.last().unwrap();
|
||
assert_eq!((l.blend, l.opacity, l.label, l.clipped), (BlendMode::Normal, 1.0, LabelColor::None, false));
|
||
assert_eq!(l.surface().unwrap().tile_count(), 0);
|
||
}
|
||
|
||
/// `position: below` (⌘/Ctrl-click on the panel button) lands under the active layer; the
|
||
/// Background stays at the bottom, so below it the layer lands above it.
|
||
#[test]
|
||
fn position_below_lands_under_the_active_layer() {
|
||
let mut s = busy("rgb", 8);
|
||
let names = |s: &Session| s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>();
|
||
s.execute("layer.new.layer", json!({"name": "A"})).unwrap();
|
||
let below = s.execute("layer.new.layer", json!({"name": "Below", "position": "below"})).unwrap()["layer"].as_u64().unwrap();
|
||
assert_eq!(names(&s), ["Background", "Below", "A"]);
|
||
assert_eq!(s.active().unwrap().active_layer, Some(photocraft_doc::LayerId(below)));
|
||
s.execute("layer.new.layer", json!({"name": "Above", "position": "above"})).unwrap();
|
||
assert_eq!(names(&s), ["Background", "Below", "Above", "A"]);
|
||
let bg = s.active().unwrap().doc.layers[0].id.0;
|
||
s.execute("layer.select", json!({"layer": bg})).unwrap();
|
||
s.execute("layer.new.layer", json!({"name": "OverBg", "position": "below"})).unwrap();
|
||
assert_eq!(names(&s), ["Background", "OverBg", "Below", "Above", "A"]);
|
||
}
|
||
|
||
/// Bad params are errors, never panics, and change nothing.
|
||
#[test]
|
||
fn bad_params_are_errors() {
|
||
let mut s = busy("rgb", 8);
|
||
let n = s.active().unwrap().doc.layers.len();
|
||
for p in [
|
||
json!({"mode": "Pass Through"}),
|
||
json!({"mode": "Sparkle"}),
|
||
json!({"mode": 3}),
|
||
json!({"opacity": "half"}),
|
||
json!({"color": "plaid"}),
|
||
json!({"color": 1}),
|
||
json!({"fillNeutral": true}),
|
||
json!({"fillNeutral": true, "mode": "Hue"}),
|
||
json!({"fillNeutral": "yes"}),
|
||
json!({"clipToPrevious": 1}),
|
||
json!({"name": 7}),
|
||
json!({"position": "sideways"}),
|
||
json!({"position": 1}),
|
||
] {
|
||
assert!(s.execute("layer.new.layer", p.clone()).is_err(), "{p}");
|
||
}
|
||
assert_eq!(s.active().unwrap().doc.layers.len(), n);
|
||
// Out-of-range opacity is clamped, as layer.setProps does.
|
||
s.execute("layer.new.layer", json!({"opacity": 7})).unwrap();
|
||
assert_eq!(s.active().unwrap().doc.layers.last().unwrap().opacity, 1.0);
|
||
}
|
||
}
|