Files
photocraft/crates/engine/src/channel_cmds.rs
T
David Mann 7b79f70787 Merge PR #3297: Smart Filters: paint the filter mask directly, with undo (#3290)
Merged with our fix-up pr-3297-fixup (1de1e9eb58) on top of the author's head 053e1e0d03.
Conflicts resolved: channel_cmds.rs routed() keeps the PR's PAINT_IDS list plus the batch's
paint.artHistoryBrush; paint_cmds.rs Paint Bucket params keep the batch's "mode" and the PR's
"filterMask" target; docs/scorecard.md takes the batch's numbers with FILE-215-3 done.
2026-10-25 15:42:51 +01:00

2086 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Channels: alpha and spot channels, Select › Save / Load Selection, Quick Mask, channel
//! targeting and visibility (the Channels panel), Image › Apply Image / Calculations, and the
//! Channels panel menu (Split / Merge Channels, Merge Spot Channel).
//!
//! Alpha channels live in [`Document::channels`] (white = selected). Quick Mask is a temporary
//! channel in [`Document::quick_mask`], so entering and leaving it are history steps like in
//! Photoshop. Which channel is targeted and which are visible is view state on
//! [`DocState::channel_view`] (not history), like the active layer.
//!
//! Pixel commands (`paint.*`, `filter.*`, `image.adjustments.*`, `edit.fill`) take
//! `"target": {"channel": i}` or `"target": "quickMask"`; [`Session::execute`] fills that in from the
//! targeted channel when the caller didn't pass a target. Targeting a single colour channel keeps
//! those commands from touching the other colour channels of the layer ([`restrict_to_color`]).
use photocraft_algo::selection::{self as sel, SelectionMode};
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, blend::blend_channel};
use photocraft_doc::{AlphaChannel, ColorIndicates, Document, Layer, LayerContent, LayerId};
use photocraft_geom::{Rect, TileCoord};
use photocraft_raster::{Surface, from_rgba, to_rgba};
use serde::Serialize;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{DocState, EngineError, Result, Session};
#[cfg(test)]
mod tests;
// ---------------------------------------------------------------------------------------------
// View state
/// What pixel commands edit (Channels panel click / ⌘2…⌘9).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "index")]
pub enum ChannelTarget {
/// The layers (all colour channels). In Quick Mask mode this edits the Quick Mask.
#[default]
Composite,
/// One colour channel of the active layer (index into the pixel format's colour channels).
Color(usize),
/// An alpha or spot channel (index into [`Document::channels`]).
Alpha(usize),
/// The active layer's layer mask.
LayerMask,
/// The active layer's vector mask.
VectorMask,
}
/// Per-document Channels panel state: the target and the eye toggles. Not part of history.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelView {
pub target: ChannelTarget,
/// Hidden colour channels (missing entries are visible).
pub color_hidden: Vec<bool>,
/// Visible alpha channels (missing entries are hidden).
pub alpha_visible: Vec<bool>,
pub quick_mask_hidden: bool,
/// ⌥-click view of the active layer's mask (#196); see [`crate::mask_view_cmds`].
pub layer_mask: Option<crate::mask_view_cmds::LayerMaskView>,
}
impl ChannelView {
pub fn color_visible(&self, k: usize) -> bool {
!self.color_hidden.get(k).copied().unwrap_or(false)
}
pub fn alpha_shown(&self, i: usize) -> bool {
self.alpha_visible.get(i).copied().unwrap_or(false)
}
pub fn set_color_visible(&mut self, k: usize, v: bool) {
if self.color_hidden.len() <= k {
self.color_hidden.resize(k + 1, false);
}
self.color_hidden[k] = !v;
}
pub fn set_alpha_visible(&mut self, i: usize, v: bool) {
if self.alpha_visible.len() <= i {
self.alpha_visible.resize(i + 1, false);
}
self.alpha_visible[i] = v;
}
/// Number of visible colour channels out of `colors`.
pub fn visible_colors(&self, colors: usize) -> usize {
(0..colors).filter(|k| self.color_visible(*k)).count()
}
/// True when the canvas shows just the normal composite (nothing extra to draw).
pub fn is_plain(&self, doc: &Document) -> bool {
let colors = color_count(doc);
self.visible_colors(colors) == colors
&& !(0..doc.channels.len()).any(|i| self.alpha_shown(i))
&& (doc.quick_mask.is_none() || self.quick_mask_hidden)
&& self.shown_layer_mask(doc).is_none()
}
/// The layer mask shown on the canvas (#196) and how, if its layer still has a mask.
pub fn shown_layer_mask<'a>(&self, doc: &'a Document) -> Option<(&'a photocraft_doc::LayerMask, crate::mask_view_cmds::MaskViewMode)> {
let v = self.layer_mask?;
Some((crate::mask_view_cmds::viewed_mask(doc.layer(v.layer)?, v.filter)?, v.mode))
}
}
/// Session-wide Quick Mask Options (double-click the Quick Mask button in Photoshop).
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QuickMaskOptions {
pub color: Color,
pub opacity: f32,
pub indicates: ColorIndicates,
}
impl Default for QuickMaskOptions {
fn default() -> Self {
Self { color: AlphaChannel::DEFAULT_COLOR, opacity: 0.5, indicates: ColorIndicates::MaskedAreas }
}
}
/// Keep the view valid after the document changed (undo, deleted channels, mode changes).
pub(crate) fn fix_view(st: &mut DocState) {
let n = st.doc.channels.len();
let colors = color_count(&st.doc);
let quick = st.doc.quick_mask.is_some();
{
let v = &mut st.channel_view;
v.alpha_visible.truncate(n);
v.color_hidden.truncate(colors);
match v.target {
ChannelTarget::Alpha(i) if i >= n => v.target = ChannelTarget::Composite,
ChannelTarget::Color(k) if k >= colors || colors < 2 => v.target = ChannelTarget::Composite,
ChannelTarget::LayerMask if st.active_layer.and_then(|id| st.doc.layer(id)).is_none_or(|l| l.mask.is_none()) => {
v.target = ChannelTarget::Composite;
}
ChannelTarget::VectorMask if st.active_layer.and_then(|id| st.doc.layer(id)).is_none_or(|l| l.vector_mask.is_none()) => {
v.target = ChannelTarget::Composite;
}
_ => {}
}
}
crate::mask_view_cmds::fix(st);
// An alpha/spot eye, Quick Mask, or the active layer's mask can be the only thing on the
// canvas (so R, G and B can all be off). Otherwise the colour channels come back.
let active = st.active_layer;
let shown = {
let doc = &st.doc;
let v = &st.channel_view;
(0..n).any(|i| v.alpha_shown(i)) || (quick && !v.quick_mask_hidden) || active.is_some_and(|id| doc.layer(id).is_some_and(|l| l.mask.is_some()))
};
if st.channel_view.visible_colors(colors) == 0 && !shown {
st.channel_view.color_hidden.clear();
}
}
/// Bump the revision for a view-only change without dirtying a clean document.
fn touch(st: &mut DocState) {
let clean = st.saved_revision == st.revision;
st.revision += 1;
st.view_revision += 1;
if clean {
st.saved_revision = st.revision;
}
st.last_damage = None;
}
// ---------------------------------------------------------------------------------------------
// Channel names and formats
/// Colour channel names of a document mode (the rows under the composite).
pub fn color_names(mode: ColorMode) -> &'static [&'static str] {
match mode {
ColorMode::Cmyk => &["Cyan", "Magenta", "Yellow", "Black"],
ColorMode::Lab => &["Lightness", "a", "b"],
ColorMode::Grayscale => &["Gray"],
_ => &["Red", "Green", "Blue"],
}
}
/// Name of the composite row.
pub fn composite_name(mode: ColorMode) -> &'static str {
match mode {
ColorMode::Cmyk => "CMYK",
ColorMode::Lab => "Lab",
ColorMode::Grayscale => "Gray",
_ => "RGB",
}
}
/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a
/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IsolatedCurveChannel {
/// Channels-panel name, including a custom alpha name or "{layer} Mask".
pub name: String,
/// Curves param key: empty = composite `points`; otherwise `red`/`green`/…
pub key: &'static str,
/// Index into the tone-space histograms (composite = 0 for RGB/CMYK).
pub hist: usize,
/// Colour components keep their curve tint; masks and alphas do not.
pub tint: bool,
/// Built-in labels (colour channels, Quick Mask) follow the interface language; a custom
/// alpha name or a "{layer} Mask" keeps the layer name, so it stays exactly as entered (#5335).
pub translate: bool,
/// The gray plane this target edits, whatever the document's colour mode; its surface is
/// what the dialog's histogram must describe (#5335). None for colour components.
pub gray: Option<GraySurface>,
}
/// A grayscale plane Curves can target: the dialog parses and previews its curves in gray levels
/// even in Lab and CMYK documents (#5335).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GraySurface {
/// The active layer's layer mask.
LayerMask,
/// A saved channel, as an index into [`Document::channels`].
Alpha(usize),
/// The Quick Mask.
QuickMask,
/// The active smart object's filter mask (FILE-215-3).
FilterMask,
}
/// Isolated Curves channel for the active document, matching [`inject_target`].
pub fn isolated_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
isolated_curve_channel_for(st, false)
}
/// [`isolated_curve_channel`] with the Layers panel's mask target added: clicking a mask
/// thumbnail targets it without touching the Channels panel, and both ways of selecting the mask
/// must give the same Curves dropdown (#5335).
pub fn isolated_curve_channel_for(st: &DocState, layers_panel_mask: bool) -> Option<IsolatedCurveChannel> {
let doc = &st.doc;
let mode = edit_mode(doc);
match st.channel_view.target {
ChannelTarget::Color(k) => {
let names = color_names(mode);
let keys = crate::adjust_params::channel_keys(crate::adjust_params::default_space(mode));
let name = names.get(k)?.to_string();
let key = *keys.get(k)?;
let hist = if matches!(mode, ColorMode::Lab) { k } else { k.saturating_add(1) };
Some(IsolatedCurveChannel { name, key, hist, tint: true, translate: true, gray: None })
}
ChannelTarget::Alpha(i) => doc.channels.get(i).map(|c| IsolatedCurveChannel {
name: c.name.clone(),
key: "",
hist: 0,
tint: false,
translate: false,
gray: Some(GraySurface::Alpha(i)),
}),
ChannelTarget::Composite if doc.quick_mask.is_some() => {
Some(IsolatedCurveChannel { name: "Quick Mask".into(), key: "", hist: 0, tint: false, translate: true, gray: Some(GraySurface::QuickMask) })
}
ChannelTarget::LayerMask => layer_mask_curve_channel(st),
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => filter_mask_curve_channel(st),
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() || layers_panel_mask => layer_mask_curve_channel(st),
_ => None,
}
}
fn layer_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
layer.mask.as_ref()?;
let name = format!("{} Mask", layer.name);
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::LayerMask) })
}
/// The Curves target when the active smart object's filter mask is targeted (its thumbnail on the
/// Smart Filters row, FILE-215-3): a gray plane, though the mask may not exist until the first edit.
pub fn filter_mask_curve_channel(st: &DocState) -> Option<IsolatedCurveChannel> {
let layer = st.active_layer.and_then(|id| st.doc.layer(id))?;
crate::smart_cmds::filter_mask_paintable(layer).ok()?;
let name = format!("{} Filter Mask", layer.name);
Some(IsolatedCurveChannel { name, key: "", hist: 0, tint: false, translate: false, gray: Some(GraySurface::FilterMask) })
}
fn edit_mode(doc: &Document) -> ColorMode {
doc.pixel_format().mode
}
pub fn color_count(doc: &Document) -> usize {
edit_mode(doc).color_channels()
}
/// Pixel format of alpha channels: grayscale at the document depth, no transparency.
pub fn channel_format(doc: &Document) -> PixelFormat {
PixelFormat::new(ColorMode::Grayscale, doc.depth, false)
}
/// Next free "`base` N" name (Alpha 1, Spot Color 2, …).
pub fn next_alpha_name(doc: &Document, base: &str) -> String {
// `channels.len() + 1` candidates always include a free one.
(1..=doc.channels.len() + 1).map(|n| format!("{base} {n}")).find(|n| !doc.channels.iter().any(|c| &c.name == n)).unwrap_or_else(|| base.to_string())
}
// ---------------------------------------------------------------------------------------------
// Planes: one grayscale value per canvas pixel
fn read_plane(s: &Surface, r: Rect) -> Vec<f32> {
let mut v = Vec::new();
s.read_region_into(r, &mut v);
let ch = s.channels();
if ch > 1 { v.chunks_exact(ch).map(|p| p[0]).collect() } else { v }
}
fn plane_surface(vals: &[f32], r: Rect, fmt: PixelFormat) -> Surface {
let mut s = Surface::new(fmt);
let clamped: Vec<f32> = vals.iter().map(|v| v.clamp(0.0, 1.0)).collect();
s.write_region(r, &clamped);
s.prune();
s
}
/// Make every canvas pixel of `s` explicit so filters and adjustments that work on the content
/// bounds also see the untouched (default-valued) parts of a channel.
fn materialize(s: &mut Surface, r: Rect) {
let v = s.read_region(r);
s.write_region(r, &v);
}
fn luminosity(rgba: [f32; 4]) -> f32 {
0.299 * rgba[0] + 0.587 * rgba[1] + 0.114 * rgba[2]
}
// ---------------------------------------------------------------------------------------------
// Channel references
/// A channel named in params: `"composite"`, a colour name (`"red"`, `"cyan"`, `"lightness"`…),
/// an alpha index (number) or name, `"quickMask"`, `"selection"`, `"transparency"`, `"mask"`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChanRef {
Composite,
Color(usize),
Alpha(usize),
QuickMask,
Selection,
Transparency,
LayerMask,
/// The layer's vector mask, rasterized (⌘-click its thumbnail, #196).
VectorMask,
}
pub fn parse_ref(v: &Value, doc: &Document) -> Option<ChanRef> {
let mode = edit_mode(doc);
match v {
Value::Number(n) => n.as_u64().map(|i| ChanRef::Alpha(i as usize)),
Value::Object(o) => {
if let Some(k) = o.get("color").and_then(Value::as_u64) {
Some(ChanRef::Color(k as usize))
} else {
o.get("alpha").and_then(Value::as_u64).map(|i| ChanRef::Alpha(i as usize))
}
}
Value::String(s) => {
let l = s.to_ascii_lowercase();
match l.as_str() {
"composite" | "merged" => return Some(ChanRef::Composite),
"quickmask" | "quick mask" => return Some(ChanRef::QuickMask),
"selection" => return Some(ChanRef::Selection),
"transparency" => return Some(ChanRef::Transparency),
"mask" | "layermask" => return Some(ChanRef::LayerMask),
"vectormask" | "vector mask" => return Some(ChanRef::VectorMask),
_ => {}
}
if l == composite_name(mode).to_ascii_lowercase() {
return Some(ChanRef::Composite);
}
if let Some(k) = color_names(mode).iter().position(|n| n.eq_ignore_ascii_case(&l)) {
return Some(if color_names(mode).len() == 1 { ChanRef::Composite } else { ChanRef::Color(k) });
}
doc.channels.iter().position(|c| c.name == *s).map(ChanRef::Alpha)
}
_ => None,
}
}
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn doc_index(s: &Session, p: &Value, key: &str) -> Result<usize> {
match crate::commands::int(p, key).and_then(|v| u64::try_from(v).ok()) {
Some(i) if (i as usize) < s.documents().len() => Ok(i as usize),
Some(i) => Err(EngineError::Other(format!("no open document {i}"))),
None => s.active_index().ok_or(EngineError::NoDocument),
}
}
/// Alpha channel index from `p[key]` (number or name), checked against the document.
fn alpha_index(doc: &Document, p: &Value, key: &str, cmd: &str) -> Result<usize> {
match p.get(key).and_then(|v| parse_ref(v, doc)) {
Some(ChanRef::Alpha(i)) if i < doc.channels.len() => Ok(i),
Some(ChanRef::Alpha(i)) => Err(bad(cmd, format!("no alpha channel {i} (document has {})", doc.channels.len()))),
_ => Err(bad(cmd, format!("`{key}` must name an alpha channel (index or name)"))),
}
}
// ---------------------------------------------------------------------------------------------
// Sources (Load Selection, Apply Image, Calculations)
/// Native pixel values of a layer or of the merged image over the canvas.
fn native_pixels(doc: &Document, layer: Option<LayerId>) -> Result<(Vec<f32>, usize)> {
let r = doc.bounds();
let fmt = doc.pixel_format();
let n = fmt.channels();
match layer {
Some(id) => {
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let s = l.surface().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no pixels", l.name)))?;
if s.format() == fmt {
return Ok((s.read_region(r), n));
}
let raw = s.read_region(r);
let sf = s.format();
Ok((raw.chunks_exact(sf.channels()).flat_map(|p| from_rgba(&fmt, to_rgba(&sf, p))).collect(), n))
}
None => {
let buf = photocraft_compose::render(doc, r);
Ok((buf.px.iter().flat_map(|p| from_rgba(&fmt, *p)).collect(), n))
}
}
}
/// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded
/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error.
fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
match p.get("layer") {
Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)),
Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc
.walk()
.into_iter()
.rev()
.find(|(_, _, l)| l.name == *name)
.map(|(_, _, l)| Some(l.id))
.ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))),
_ => Ok(None),
}
}
/// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one
/// plane per colour channel; everything else gives one plane.
fn ref_planes(doc: &Document, layer: Option<LayerId>, active: Option<LayerId>, r: ChanRef) -> Result<Vec<Vec<f32>>> {
let area = doc.bounds();
let colors = color_count(doc);
Ok(match r {
ChanRef::Composite | ChanRef::Color(_) => {
let (px, n) = native_pixels(doc, layer)?;
let planes: Vec<Vec<f32>> = (0..colors).map(|c| px.chunks_exact(n).map(|p| p[c]).collect()).collect();
match r {
ChanRef::Color(k) => vec![planes.get(k).cloned().ok_or_else(|| EngineError::Other(format!("no colour channel {k}")))?],
_ => planes,
}
}
ChanRef::Alpha(i) => vec![read_plane(&doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i}")))?.surface, area)],
ChanRef::QuickMask => vec![read_plane(&doc.quick_mask.as_ref().ok_or_else(|| EngineError::Other("not in Quick Mask mode".into()))?.surface, area)],
ChanRef::Selection => vec![sel::mask_from_surface(doc.selection.as_ref(), area)],
ChanRef::Transparency => {
let id = layer.or(active).ok_or_else(|| EngineError::Other("transparency needs a layer".into()))?;
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
match l.surface() {
Some(s) => {
let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
s.read_rgba_into(area, &mut px);
vec![px.iter().map(|p| p[3]).collect()]
}
// A shape can have no pixel cache yet (for example after loading a document).
// Use its existing renderer rather than treating it as a canvas-wide fill.
None => match &l.content {
LayerContent::Shape(shape) => vec![
photocraft_vector::CompiledShape::new(shape, photocraft_vector::DEFAULT_TOLERANCE, area)
.render_rgba(area)
.into_iter()
.map(|p| p[3])
.collect(),
],
// Adjustment and fill layers cover the whole canvas.
_ => vec![vec![1.0; area.width() as usize * area.height() as usize]],
},
}
}
ChanRef::LayerMask => {
let id = layer.or(active).ok_or_else(|| EngineError::Other("layer mask needs a layer".into()))?;
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no mask", l.name)))?;
vec![read_plane(&m.surface, area)]
}
ChanRef::VectorMask => {
let id = layer.or(active).ok_or_else(|| EngineError::Other("vector mask needs a layer".into()))?;
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let m = l.vector_mask.as_ref().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no vector mask", l.name)))?;
// The path's shape, whether or not the mask is enabled (as Photoshop loads it).
let shape = photocraft_doc::VectorMask { enabled: true, density: 1.0, feather: 0.0, ..m.clone() };
vec![photocraft_vector::vector_mask_values(&shape, area)]
}
})
}
/// One grayscale plane (composites reduce to luminosity, as in Calculations).
fn gray_plane(doc: &Document, layer: Option<LayerId>, active: Option<LayerId>, r: ChanRef) -> Result<Vec<f32>> {
let mut planes = ref_planes(doc, layer, active, r)?;
if planes.len() == 1
&& let Some(only) = planes.pop()
{
return Ok(only);
}
let fmt = doc.pixel_format();
let n = planes.first().map_or(0, Vec::len);
let mut px = vec![0.0f32; fmt.channels()];
Ok((0..n)
.map(|i| {
for (c, p) in planes.iter().enumerate() {
px[c] = p[i];
}
if fmt.alpha {
px[planes.len()] = 1.0;
}
luminosity(to_rgba(&fmt, &px))
})
.collect())
}
/// A source spec `{document?, layer?, channel, invert?}`, reduced to planes over the canvas of
/// the active document (sizes must match, as in Photoshop).
fn source(s: &Session, p: &Value, cmd: &str, gray: bool) -> Result<Vec<Vec<f32>>> {
let di = doc_index(s, p, "document")?;
let st = &s.documents()[di];
let target = s.active().ok_or(EngineError::NoDocument)?;
if st.doc.size != target.doc.size {
return Err(bad(cmd, format!("source document \"{}\" is {}×{}; it must match the target size", st.doc.name, st.doc.size.width, st.doc.size.height)));
}
let r = p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, &st.doc)).ok_or_else(|| bad(cmd, "unknown `channel`"))?;
let layer = source_layer(&st.doc, p, cmd)?;
let mut planes = if gray { vec![gray_plane(&st.doc, layer, st.active_layer, r)?] } else { ref_planes(&st.doc, layer, st.active_layer, r)? };
// Guard every caller (`.remove(0)` / `src[0]`) against an empty source.
if planes.is_empty() {
return Err(bad(cmd, "the source has no channels"));
}
if p.get("invert").and_then(Value::as_bool).unwrap_or(false) {
for pl in &mut planes {
for v in pl.iter_mut() {
*v = 1.0 - *v;
}
}
}
Ok(planes)
}
// ---------------------------------------------------------------------------------------------
// Blending (Apply Image / Calculations)
/// Apply Image / Calculations blending: the separable layer modes plus Add and Subtract with
/// scale and offset.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Blending {
Mode(BlendMode),
Add { scale: f32, offset: f32 },
Subtract { scale: f32, offset: f32 },
}
impl Blending {
pub fn parse(p: &Value) -> std::result::Result<Self, String> {
let name = p.get("blending").and_then(Value::as_str).unwrap_or("multiply");
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(1.0, 2.0) as f32;
let offset = p.get("offset").and_then(Value::as_f64).unwrap_or(0.0).clamp(-255.0, 255.0) as f32 / 255.0;
match name.to_ascii_lowercase().as_str() {
"add" => Ok(Blending::Add { scale, offset }),
"subtract" => Ok(Blending::Subtract { scale, offset }),
other => match crate::commands::blend_from_str(other) {
Some(m) if m.is_separable() && !matches!(m, BlendMode::Dissolve | BlendMode::PassThrough) => Ok(Blending::Mode(m)),
_ => Err(format!("unsupported blending `{other}`")),
},
}
}
/// Blend `top` (the source) onto `base` (the target).
pub fn apply(self, base: f32, top: f32) -> f32 {
match self {
Blending::Mode(m) => blend_channel(m, base, top),
Blending::Add { scale, offset } => (base + top) / scale + offset,
Blending::Subtract { scale, offset } => (base - top) / scale + offset,
}
.clamp(0.0, 1.0)
}
}
/// A source spec from `p[key]`, or from flat `<prefix>Document` / `<prefix>Layer` /
/// `<prefix>Channel` / `<prefix>Invert` keys (what the generic dialog form sends).
pub(crate) fn source_spec(p: &Value, key: &str, prefix: &str) -> Option<Value> {
if let Some(v) = p.get(key) {
return Some(v.clone());
}
let mut m = serde_json::Map::new();
for (k, field) in [("document", "Document"), ("layer", "Layer"), ("channel", "Channel"), ("invert", "Invert")] {
match p.get(format!("{prefix}{field}")) {
None | Some(Value::Null) => {}
Some(Value::String(s)) if s.is_empty() || (k == "layer" && s == "merged") => {}
Some(v) => {
m.insert(k.into(), v.clone());
}
}
}
(!m.is_empty()).then_some(Value::Object(m))
}
fn mask_plane(s: &Session, p: &Value, cmd: &str) -> Result<Option<Vec<f32>>> {
match source_spec(p, "mask", "mask") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(m) if m.get("channel").is_none_or(|c| c == "none") => Ok(None),
Some(m) => Ok(Some(source(s, &m, cmd, true)?.remove(0))),
}
}
// ---------------------------------------------------------------------------------------------
// Targets for pixel commands
/// Where a pixel command writes, from its `"target"` param.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Target {
Pixels,
Mask,
/// A smart object's filter mask (`"target":"filterMask"`): where it is black the smart
/// filters don't show (FILE-215-3).
FilterMask,
Alpha(usize),
QuickMask,
}
pub(crate) fn target_of(p: &Value) -> Target {
match p.get("target") {
Some(Value::String(s)) => match s.as_str() {
"mask" => Target::Mask,
"filterMask" => Target::FilterMask,
"quickMask" => Target::QuickMask,
_ => Target::Pixels,
},
Some(Value::Object(o)) => o.get("channel").and_then(Value::as_u64).map_or(Target::Pixels, |i| Target::Alpha(i as usize)),
_ => Target::Pixels,
}
}
/// The target is an alpha channel or the Quick Mask (no layer involved).
pub(crate) fn is_channel_target(p: &Value) -> bool {
matches!(target_of(p), Target::Alpha(_) | Target::QuickMask)
}
/// The surface a pixel command writes to and whether the layer's transparency is locked.
pub(crate) fn target_surface<'a>(doc: &'a mut Document, layer: Option<LayerId>, p: &Value) -> Result<(&'a mut Surface, bool)> {
match target_of(p) {
Target::Alpha(i) => {
let n = doc.channels.len();
doc.channels.get_mut(i).map(|c| (&mut c.surface, false)).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {n})")))
}
Target::QuickMask => doc.quick_mask.as_mut().map(|c| (&mut c.surface, false)).ok_or_else(|| EngineError::Other("not in Quick Mask mode".into())),
t => {
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let lock = doc.effective_locks(id).transparency && t == Target::Pixels;
Ok((crate::commands::paint_surface(doc, id, p)?, lock))
}
}
}
pub(crate) fn read_target_surface<'a>(doc: &'a Document, layer: Option<LayerId>, p: &Value) -> Result<&'a Surface> {
match target_of(p) {
Target::Alpha(i) => {
let n = doc.channels.len();
doc.channels.get(i).map(|c| &c.surface).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {n})")))
}
Target::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface).ok_or_else(|| EngineError::Other("not in Quick Mask mode".into())),
Target::Pixels => {
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
doc.layer(id).and_then(Layer::surface).ok_or(EngineError::NoLayer(id))
}
Target::Mask => {
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
doc.layer(id).and_then(|l| l.mask.as_ref()).map(|m| &m.surface).ok_or(EngineError::NoLayer(id))
}
Target::FilterMask => {
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
doc.layer(id)
.and_then(|l| crate::mask_view_cmds::viewed_mask(l, true))
.map(|m| &m.surface)
.ok_or_else(|| EngineError::Other("the layer has no filter mask".into()))
}
}
}
/// The grayscale surface filters and adjustments edit instead of the layer's pixels (made explicit
/// over the canvas), if the params target one: an alpha channel, the Quick Mask, or the mask of
/// `layer`.
pub(crate) fn channel_surface_for_filter<'a>(doc: &'a mut Document, layer: Option<LayerId>, p: &Value) -> Result<Option<&'a mut Surface>> {
if target_of(p) == Target::Pixels {
return Ok(None);
}
let b = doc.bounds();
let (surf, _) = target_surface(doc, layer, p)?;
materialize(surf, b);
Ok(Some(surf))
}
/// Commands that edit a targeted layer mask in place of the layer: the Image › Adjustments table,
/// the filters and the fills (Edit › Fill and its keys). A mask target enables them on any layer
/// with a mask, so ⌘I inverts an adjustment layer's mask (#780) and Fill fills it (#2456) as in
/// Photoshop.
fn edits_mask(id: &str) -> bool {
id.strip_prefix("image.adjustments.").is_some_and(|kind| crate::adjust_params::default_for(kind, ColorMode::Rgb).is_ok())
|| crate::filters::params_for(id, &Value::Null).is_some()
|| id == "edit.fill"
// Copying a targeted mask (#2928).
|| id == "edit.copy"
|| crate::fill_key_cmds::IDS.contains(&id)
}
/// The precondition of `id` when `p` targets the active layer's mask (`"target":"mask"`): the
/// layer needs a mask. None when another precondition applies.
pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<std::result::Result<(), String>> {
match target_of(p) {
// The commands that paint, fill, filter or adjust the filter mask need a smart object with
// smart filters, not a pixel layer. Paste keeps its own check (something to paste).
Target::FilterMask if follows_filter_mask(id) && !PASTE_IDS.contains(&id) => {
Some(crate::active_layer_of(s).and_then(crate::smart_cmds::filter_mask_paintable))
}
Target::Mask if edits_mask(id) => {
Some(crate::active_layer_of(s).and_then(|l| if l.mask.is_some() { Ok(()) } else { Err("the active layer has no layer mask".into()) }))
}
_ => None,
}
}
/// Whether command `id` edits the targeted channel or mask when its params name no `"target"`
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
pub fn follows_target(id: &str) -> bool {
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
routed(id) || id == "select.float" || id == "layer.translate"
}
/// Whether command `id` edits a smart object's targeted filter mask (`"target":"filterMask"`):
/// every routed command but those that act on the smart object itself while its filter mask is
/// targeted (Edit › Transform, Apply Image, Convert for Smart Filters, Last Filter, Liquify, the
/// lens filters and Vanishing Point, which add a smart filter, the render filters that make a new
/// layer, and Desaturate), so neither the shell nor the engine sends those the filter mask.
pub fn follows_filter_mask(id: &str) -> bool {
routed(id)
&& !id.starts_with("edit.transform.")
&& !matches!(
id,
"image.applyImage"
| "image.adjustments.desaturate"
| "filter.vanishingPoint"
| "filter.render.flame"
| "filter.render.tree"
| "filter.render.pictureFrame"
| "filter.convertForSmartFilters"
| "filter.lastFilter"
| crate::distort_cmds::LIQUIFY
| crate::lens_cmds::LENS
| crate::lens_cmds::WIDE
| crate::lens_cmds::RAW
)
}
/// The paint and retouch tools that follow the targeted channel or mask.
const PAINT_IDS: &[&str] = &[
"paint.stroke",
"paint.pencil",
"paint.bucket",
"paint.gradient",
"paint.mixerBrush",
"paint.cloneStamp",
"paint.patternStamp",
"paint.healingBrush",
"paint.spotHealing",
"paint.dodge",
"paint.burn",
"paint.sponge",
"paint.blur",
"paint.sharpen",
"paint.smudge",
"paint.historyBrush",
"paint.artHistoryBrush",
"paint.redEye",
"paint.remove",
];
/// Pasting into a targeted mask or channel (#1035).
const PASTE_IDS: &[&str] = &["edit.paste", "edit.pasteSpecial.pasteInPlace", "edit.pasteSpecial.pasteInto", "edit.pasteSpecial.pasteOutside"];
/// Commands whose target follows the Channels panel.
pub(crate) fn routed(id: &str) -> bool {
id.starts_with("filter.")
|| id == "plugin.run"
|| id.starts_with("image.adjustments.")
|| matches!(id, "edit.fill" | "image.applyImage")
|| crate::fill_key_cmds::IDS.contains(&id)
|| PAINT_IDS.contains(&id)
// Pasting into a targeted mask or channel (#1035); Copy takes a targeted mask (#2928).
|| id == "edit.copy"
|| PASTE_IDS.contains(&id)
// Edit › Transform › Rotate/Flip turn a targeted mask or channel (#2835).
|| matches!(
id,
"edit.transform.rotate180"
| "edit.transform.rotate90Cw"
| "edit.transform.rotate90Ccw"
| "edit.transform.flipHorizontal"
| "edit.transform.flipVertical"
)
}
/// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none.
pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
if !routed(id) || params.get("target").is_some() {
return params;
}
let Some(st) = s.active() else { return params };
let t = match st.channel_view.target {
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }),
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
ChannelTarget::LayerMask => json!("mask"),
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
// A shown filter mask takes only the commands that edit it; the rest act on the smart object.
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some_and(|v| v.filter) => {
if !follows_filter_mask(id) {
return params;
}
json!("filterMask")
}
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
_ => return params,
};
match params {
Value::Object(mut m) => {
m.insert("target".into(), t);
Value::Object(m)
}
_ => json!({ "target": t }),
}
}
/// The smart object whose filter mask command `id` with `params` edits (`"target":"filterMask"`):
/// its `"layer"` param, else the active layer. None for a command that doesn't edit the filter
/// mask, so moving the smart object doesn't re-render its filters.
pub(crate) fn filter_mask_layer(s: &Session, id: &str, params: &Value) -> Option<LayerId> {
if target_of(params) != Target::FilterMask || !follows_filter_mask(id) {
return None;
}
params.get("layer").and_then(Value::as_u64).map(LayerId).or_else(|| s.active()?.active_layer)
}
/// The colour channel pixel commands are limited to, if a single colour channel is targeted.
pub(crate) fn color_restriction(s: &Session, id: &str, params: &Value) -> Option<usize> {
if !routed(id) || is_channel_target(params) || crate::commands::is_mask_target(params) {
return None;
}
match s.active()?.channel_view.target {
ChannelTarget::Color(k) => Some(k),
_ => None,
}
}
/// After a pixel edit with colour channel `k` targeted, put the other channels (and the layer's
/// transparency) of layer `id` back as they were. Only changed tiles are touched.
pub(crate) fn restrict_to_color(before: &Document, after: &mut Document, id: LayerId, k: usize) {
let Some(old) = before.layer(id).and_then(Layer::surface) else { return };
let Some(new) = after.layer_mut(id).and_then(Layer::surface_mut) else { return };
if old.format() != new.format() {
return;
}
let mut coords: Vec<TileCoord> = new.tiles().filter(|(c, t)| old.tile(**c).is_none_or(|o| !std::sync::Arc::ptr_eq(o, t))).map(|(c, _)| *c).collect();
coords.extend(old.tiles().filter(|(c, _)| new.tile(**c).is_none()).map(|(c, _)| *c));
let n = new.channels();
for c in coords {
let r = c.rect();
let o = old.read_region(r);
let mut v = new.read_region(r);
for (pn, po) in v.chunks_exact_mut(n).zip(o.chunks_exact(n)) {
for ch in 0..n {
if ch != k {
pn[ch] = po[ch];
}
}
}
new.write_region(r, &v);
}
new.prune();
}
/// The colour channel Clear is limited to: the Channels panel targets a single one.
pub(crate) fn targeted_color(s: &Session) -> Option<usize> {
match s.active()?.channel_view.target {
ChannelTarget::Color(k) => Some(k),
_ => None,
}
}
/// Clear (Delete) with colour channel `k` of layer `id` targeted (#2720): fill the selected area
/// (or the canvas) of that channel only with the background colour's value for it, as Photoshop
/// does. A channel has no transparency of its own, so the other channels and the layer's alpha
/// stay; partly selected pixels blend.
pub(crate) fn clear_color_channel(doc: &mut Document, id: LayerId, k: usize, background: [f32; 4]) -> Result<()> {
let canvas = doc.bounds();
let sel = doc.selection.clone();
let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let fmt = surf.format();
let n = fmt.channels();
// The background colour in the layer's colour model, so `k` picks the right component.
let Some(v) = from_rgba(&fmt, [background[0], background[1], background[2], 1.0]).get(k).copied().filter(|_| k < n) else {
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
};
if area.is_empty() {
return Ok(());
}
let cover =
sel.as_ref().map(|m| crate::allocation::read_region(m, area, "clearing a channel selection").map(|v| (v, m.format().channels().max(1)))).transpose()?;
let mut px = crate::allocation::read_region(surf, area, "clearing a channel")?;
for (i, p) in px.chunks_exact_mut(n).enumerate() {
let w = cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
if let Some(c) = p.get_mut(k) {
*c += (v - *c) * w.clamp(0.0, 1.0);
}
}
surf.try_write_region(area, &px)?;
surf.prune();
Ok(())
}
/// The alpha channel or Quick Mask the Channels panel targets, as `"target"` params: Clear
/// (Delete) fills it instead of the layer (#2734).
pub(crate) fn targeted_channel(s: &Session) -> Option<Value> {
let st = s.active()?;
let t = match st.channel_view.target {
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }),
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
_ => return None,
};
Some(json!({ "target": t }))
}
/// Clear (Delete) with an alpha channel or the Quick Mask targeted (`target` from
/// [`targeted_channel`]): fill the selected area (or the canvas) of it with the background colour,
/// as gray, as Photoshop does. Partly selected pixels blend.
pub(crate) fn clear_channel(doc: &mut Document, target: &Value, background: [f32; 4]) -> Result<()> {
let canvas = doc.bounds();
let sel = doc.selection.clone();
let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
if area.is_empty() {
return Ok(());
}
let (surf, _) = target_surface(doc, None, target)?;
crate::fill_cmds::fill_color(surf, area, [background[0], background[1], background[2], 1.0], sel.as_ref())
}
/// The active document targets an alpha channel or is in Quick Mask mode, so pixel commands
/// work without a pixel layer.
pub(crate) fn edits_channel(s: &Session) -> bool {
s.active().is_some_and(|st| {
st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite
|| matches!(st.channel_view.target, ChannelTarget::Alpha(i) if i < st.doc.channels.len())
})
}
// ---------------------------------------------------------------------------------------------
// Enabled predicates
fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
fn has_channels(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| !d.doc.channels.is_empty()) { Ok(()) } else { Err("the document has no alpha channels".into()) }
}
fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.doc.selection.is_some()) { Ok(()) } else { Err("no selection".into()) }
}
fn has_spot(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.doc.channels.iter().any(|c| c.spot.is_some())) { Ok(()) } else { Err("the document has no spot channels".into()) }
}
fn can_split(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
if color_count(&d.doc) + d.doc.channels.len() < 2 { Err("only one channel to split".into()) } else { Ok(()) }
}
fn can_merge(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if mergeable(s).len() >= 2 { Ok(()) } else { Err("Merge Channels needs at least two open grayscale documents of the same size".into()) }
}
// ---------------------------------------------------------------------------------------------
// Save / Load Selection
fn operation<'a>(p: &'a Value, key: &str) -> &'a str {
p.get(key).and_then(Value::as_str).unwrap_or("new")
}
fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "select.saveSelection";
let op = p.get("operation").and_then(Value::as_str).unwrap_or("new").to_string();
if !matches!(op.as_str(), "new" | "replace" | "add" | "subtract" | "intersect") {
return Err(bad(cmd, format!("unknown operation `{op}`")));
}
let di = doc_index(s, p, "document")?;
let src = s.active().ok_or(EngineError::NoDocument)?;
let area = src.doc.bounds();
let selection = sel::mask_from_surface(src.doc.selection.as_ref(), area);
if s.documents()[di].doc.size != src.doc.size {
return Err(bad(cmd, "the destination document must have the same size"));
}
let channel = p.get("channel").cloned().unwrap_or(Value::Null);
let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let prev = s.active_index();
s.set_active(di);
let r = s.edit("Save Selection", |doc, _| {
let fmt = channel_format(doc);
let existing = match &channel {
Value::Null => None,
Value::String(x) if x == "new" => None,
v => match parse_ref(v, doc) {
Some(ChanRef::Alpha(i)) if i < doc.channels.len() => Some(i),
_ => return Err(bad(cmd, "`channel` must be \"new\" or an existing alpha channel")),
},
};
match (existing, op.as_str()) {
(None, _) | (_, "new") => {
let name = name.clone().unwrap_or_else(|| next_alpha_name(doc, "Alpha"));
doc.channels.push(AlphaChannel::new(name, plane_surface(&selection, area, fmt)));
Ok(doc.channels.len() - 1)
}
(Some(i), op) => {
let old = read_plane(&doc.channels[i].surface, area);
let vals: Vec<f32> = match op {
"add" => old.iter().zip(&selection).map(|(a, b)| a.max(*b)).collect(),
"subtract" => old.iter().zip(&selection).map(|(a, b)| (a - b).max(0.0)).collect(),
"intersect" => old.iter().zip(&selection).map(|(a, b)| a.min(*b)).collect(),
_ => selection.clone(),
};
doc.channels[i].surface = plane_surface(&vals, area, fmt);
if let Some(n) = &name {
doc.channels[i].name = n.clone();
}
Ok(i)
}
}
});
if let Some(i) = prev {
s.set_active(i);
}
let i = r?;
let name = s.documents()[di].doc.channels[i].name.clone();
Ok(json!({ "channel": i, "name": name, "document": di }))
}
fn load_selection(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "select.loadSelection";
let mode = match operation(p, "operation") {
"new" | "replace" => SelectionMode::Replace,
"add" => SelectionMode::Add,
"subtract" => SelectionMode::Subtract,
"intersect" => SelectionMode::Intersect,
other => return Err(bad(cmd, format!("unknown operation `{other}`"))),
};
if p.get("channel").is_none() {
return Err(bad(cmd, "missing `channel`"));
}
// `"targeted"`: the Channels panel's target, as a Photoshop action's "load the target
// channel" step records it.
let mut targeted = p.clone();
if p.get("channel").and_then(Value::as_str) == Some("targeted") {
let st = s.active().ok_or(EngineError::NoDocument)?;
targeted["channel"] = match st.channel_view.target {
ChannelTarget::Composite => json!("composite"),
ChannelTarget::Color(k) => json!({ "color": k }),
ChannelTarget::Alpha(i) => json!(i),
ChannelTarget::LayerMask => json!("mask"),
ChannelTarget::VectorMask => json!("vectorMask"),
};
}
let p = &targeted;
// Composite and colour channels load their luminosity / values, as ⌘-clicking them does.
let plane = source(s, p, cmd, true)?.into_iter().next().ok_or_else(|| bad(cmd, "the channel has no pixels"))?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let label = match p.get("channel").and_then(|v| parse_ref(v, doc)) {
Some(ChanRef::Transparency) => "Load Transparency",
Some(ChanRef::LayerMask) => "Load Layer Mask",
_ => "Load Selection",
};
let selected = s.edit(label, |doc, _| {
doc.selection = sel::combine(doc.selection.as_ref(), &plane, doc.bounds(), mode);
Ok(doc.selection.is_some())
})?;
Ok(json!({ "selected": selected }))
}
// ---------------------------------------------------------------------------------------------
// Quick Mask
/// Quick Mask channel for the current selection: white where selected. No selection is an empty
/// mask that shows no colour: white (nothing masked, as in Photoshop) when the colour indicates
/// masked areas, black (nothing selected) when it indicates selected areas. White and "no
/// selection" are told apart by [`Document::quick_mask_unselected`].
fn quick_mask_from(doc: &Document, o: &QuickMaskOptions) -> AlphaChannel {
let area = doc.bounds();
let fmt = channel_format(doc);
let surface = match &doc.selection {
Some(s) => plane_surface(&sel::mask_from_surface(Some(s), area), area, fmt),
None => unselected_surface(area, fmt, o.indicates),
};
AlphaChannel { color: o.color, opacity: o.opacity, indicates: o.indicates, ..AlphaChannel::new("Quick Mask", surface) }
}
/// The colourless, nothing-selected Quick Mask for `indicates`.
fn unselected_surface(area: Rect, fmt: PixelFormat, indicates: ColorIndicates) -> Surface {
let mut s = Surface::new(fmt);
if indicates != ColorIndicates::SelectedAreas {
s.fill_rect(area, &[1.0]);
}
s
}
/// Every pixel of `vals` reads as `target` on the 8-bit grid.
fn uniform(vals: &[f32], target: f32) -> bool {
vals.iter().all(|v| (v - target).abs() <= 0.5 / 255.0)
}
/// The selection a Quick Mask channel turns back into. All white is Select All, unless Quick
/// Mask was entered with no selection and the white is its untouched masked-areas start.
fn selection_from(doc: &Document, ch: &AlphaChannel) -> Option<Surface> {
let area = doc.bounds();
let vals = read_plane(&ch.surface, area);
if doc.quick_mask_unselected && ch.indicates != ColorIndicates::SelectedAreas && uniform(&vals, 1.0) {
return None;
}
sel::combine(None, &vals, area, SelectionMode::Replace)
}
fn quick_mask(s: &mut Session, p: &Value) -> Result<Value> {
let on = s.active().ok_or(EngineError::NoDocument)?.doc.quick_mask.is_some();
let want = p.get("on").and_then(Value::as_bool).unwrap_or(!on);
if want == on {
return Ok(json!({ "quickMask": on }));
}
let opts = s.quick_mask_options;
if want {
s.edit("Edit in Quick Mask", |doc, _| {
doc.quick_mask = Some(quick_mask_from(doc, &opts));
doc.quick_mask_unselected = doc.selection.is_none();
doc.selection = None;
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.channel_view.target = ChannelTarget::Composite;
st.channel_view.quick_mask_hidden = false;
}
} else {
s.edit("Exit Quick Mask", |doc, _| {
if let Some(q) = doc.quick_mask.take() {
doc.selection = selection_from(doc, &q);
}
Ok(())
})?;
}
Ok(json!({ "quickMask": want }))
}
// ---------------------------------------------------------------------------------------------
// Channel management
fn indicates_param(v: Option<&Value>) -> Option<ColorIndicates> {
match v?.as_str()? {
"masked" | "maskedAreas" => Some(ColorIndicates::MaskedAreas),
"selected" | "selectedAreas" => Some(ColorIndicates::SelectedAreas),
_ => None,
}
}
fn color_value(cmd: &str, p: &Value, key: &str) -> Result<Option<Color>> {
if p.get(key).is_none() {
return Ok(None);
}
let c = crate::commands::color_param(cmd, p, key, [1.0, 0.0, 0.0, 1.0])?;
Ok(Some(Color::rgb(c[0], c[1], c[2])))
}
fn new_channel(s: &mut Session, p: &Value) -> Result<Value> {
let fill = p.get("fill").and_then(Value::as_str).unwrap_or("black").to_string();
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let indicates = indicates_param(p.get("indicates"));
let color = color_value("channel.new", p, "color")?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
let i = s.edit("New Channel", |doc, _| {
let area = doc.bounds();
let fmt = channel_format(doc);
let surface = match fill.as_str() {
"white" => plane_surface(&vec![1.0; area.width() as usize * area.height() as usize], area, fmt),
"selection" => plane_surface(&sel::mask_from_surface(doc.selection.as_ref(), area), area, fmt),
_ => Surface::new(fmt),
};
let mut ch = AlphaChannel::new(name.clone().unwrap_or_else(|| next_alpha_name(doc, "Alpha")), surface);
if let Some(v) = indicates {
ch.indicates = v;
}
if let Some(c) = color {
ch.color = c;
}
if let Some(o) = opacity {
ch.opacity = o;
}
doc.channels.push(ch);
Ok(doc.channels.len() - 1)
})?;
// Photoshop targets and shows a new channel on its own.
target(s, ChannelTarget::Alpha(i));
Ok(json!({ "channel": i }))
}
fn new_spot(s: &mut Session, p: &Value) -> Result<Value> {
let color = color_value("channel.newSpot", p, "color")?.unwrap_or(Color::rgb(1.0, 0.0, 0.0));
let solidity = p.get("solidity").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0) as f32 / 100.0;
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true);
let i = s.edit("New Spot Channel", |doc, _| {
let area = doc.bounds();
let fmt = channel_format(doc);
let surface = if from_sel && doc.selection.is_some() {
plane_surface(&sel::mask_from_surface(doc.selection.as_ref(), area), area, fmt)
} else {
Surface::new(fmt)
};
let name = name.clone().unwrap_or_else(|| next_alpha_name(doc, "Spot Color"));
doc.channels.push(AlphaChannel { spot: Some((color, solidity)), ..AlphaChannel::new(name, surface) });
Ok(doc.channels.len() - 1)
})?;
if let Some(st) = s.active_mut() {
st.channel_view.set_alpha_visible(i, true);
touch(st);
}
Ok(json!({ "channel": i }))
}
fn duplicate_channel(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.duplicate";
let src = s.active().ok_or(EngineError::NoDocument)?;
let area = src.doc.bounds();
let r = p.get("channel").and_then(|v| parse_ref(v, &src.doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?;
let mut ch = match r {
ChanRef::Alpha(i) => src.doc.channels.get(i).cloned().ok_or_else(|| bad(cmd, format!("no alpha channel {i}")))?,
ChanRef::QuickMask => src.doc.quick_mask.clone().ok_or_else(|| bad(cmd, "not in Quick Mask mode"))?,
other => {
let plane = gray_plane(&src.doc, None, src.active_layer, other)?;
AlphaChannel::new(String::new(), plane_surface(&plane, area, channel_format(&src.doc)))
}
};
let base = match r {
ChanRef::Alpha(_) | ChanRef::QuickMask => ch.name.clone(),
ChanRef::Color(k) => color_names(edit_mode(&src.doc))[k].to_string(),
_ => composite_name(edit_mode(&src.doc)).to_string(),
};
ch.name = p.get("name").and_then(Value::as_str).map_or_else(|| format!("{base} copy"), str::to_string);
if p.get("invert").and_then(Value::as_bool).unwrap_or(false) {
let v: Vec<f32> = read_plane(&ch.surface, area).into_iter().map(|v| 1.0 - v).collect();
ch.surface = plane_surface(&v, area, ch.surface.format());
}
match p.get("document") {
Some(Value::String(x)) if x == "new" => {
// Photoshop makes a multichannel document; ours is grayscale with the channel as pixels.
let doc = gray_document(&ch.name, &src.doc, &read_plane(&ch.surface, area));
let i = s.add_document(doc, None);
Ok(json!({ "document": i }))
}
_ => {
let di = doc_index(s, p, "document")?;
if s.documents()[di].doc.size != src.doc.size {
return Err(bad(cmd, "the destination document must have the same size"));
}
let prev = s.active_index();
s.set_active(di);
let r = s.edit("Duplicate Channel", |doc, _| {
let fmt = channel_format(doc);
if ch.surface.format() != fmt {
ch.surface = ch.surface.convert(fmt);
}
doc.channels.push(ch);
Ok(doc.channels.len() - 1)
});
if let Some(i) = prev {
s.set_active(i);
}
Ok(json!({ "channel": r?, "document": di }))
}
}
}
/// A one-layer grayscale document whose pixels are `plane` (Split Channels, Duplicate Channel
/// to a new document).
fn gray_document(name: &str, like: &Document, plane: &[f32]) -> Document {
let mut d = Document::new(name, like.size, ColorMode::Grayscale, like.depth);
d.resolution_dpi = like.resolution_dpi;
let fmt = d.pixel_format();
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
if let Some(s) = bg.surface_mut() {
let data: Vec<f32> = plane.iter().flat_map(|v| [v.clamp(0.0, 1.0), 1.0]).collect();
s.write_region(like.bounds(), &data);
s.prune();
}
d.layers.push(bg);
d
}
fn delete_channel(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.delete";
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let i = match p.get("channel") {
Some(_) => alpha_index(doc, p, "channel", cmd)?,
None => match s.active().ok_or(EngineError::NoDocument)?.channel_view.target {
ChannelTarget::Alpha(i) => i,
_ => return Err(bad(cmd, "missing `channel`")),
},
};
// `edit` validates the view against the shorter channel list before we can remap it.
// Keep the original indices and eyes so a surviving final channel is not discarded.
let view = s.active().ok_or(EngineError::NoDocument)?.channel_view.clone();
s.edit("Delete Channel", |doc, _| {
doc.channels.remove(i);
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.channel_view = view;
let v = &mut st.channel_view;
if i < v.alpha_visible.len() {
v.alpha_visible.remove(i);
}
v.target = match v.target {
ChannelTarget::Alpha(t) if t == i => {
v.color_hidden.clear();
ChannelTarget::Composite
}
ChannelTarget::Alpha(t) if t > i => ChannelTarget::Alpha(t - 1),
t => t,
};
fix_view_now(st);
}
Ok(Value::Null)
}
fn fix_view_now(st: &mut DocState) {
fix_view(st);
touch(st);
}
fn rename_channel(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.rename";
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let i = alpha_index(doc, p, "channel", cmd)?;
let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))?.to_string();
s.edit("Rename Channel", |doc, _| {
doc.channels[i].name = name;
Ok(())
})?;
Ok(Value::Null)
}
fn move_channel(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.move";
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let n = doc.channels.len();
let i = alpha_index(doc, p, "channel", cmd)?;
let to = p.get("to").and_then(Value::as_u64).map(|v| (v as usize).min(n - 1)).ok_or_else(|| bad(cmd, "missing `to`"))?;
if to == i {
return Ok(json!({ "channel": i }));
}
s.edit("Channel Order", |doc, _| {
let c = doc.channels.remove(i);
doc.channels.insert(to, c);
Ok(())
})?;
if let Some(st) = s.active_mut() {
let v = &mut st.channel_view;
v.alpha_visible.resize(n, false);
let vis = v.alpha_visible.remove(i);
v.alpha_visible.insert(to, vis);
let remap = |t: usize| {
if t == i {
to
} else if i < t && t <= to {
t - 1
} else if to <= t && t < i {
t + 1
} else {
t
}
};
if let ChannelTarget::Alpha(t) = v.target {
v.target = ChannelTarget::Alpha(remap(t));
}
touch(st);
}
Ok(json!({ "channel": to }))
}
/// Make `t` the edit target and show it the way Photoshop does when its row is clicked.
fn target(s: &mut Session, t: ChannelTarget) {
if let Some(st) = s.active_mut() {
let colors = color_count(&st.doc);
let n = st.doc.channels.len();
let v = &mut st.channel_view;
v.target = t;
match t {
// Like clicking RGB in Photoshop: the alpha overlays go away with their eyes.
ChannelTarget::Composite => {
v.color_hidden.clear();
v.alpha_visible.clear();
}
ChannelTarget::LayerMask | ChannelTarget::VectorMask => v.color_hidden.clear(),
ChannelTarget::Color(k) => {
v.color_hidden = (0..colors).map(|c| c != k).collect();
v.alpha_visible.clear();
}
ChannelTarget::Alpha(i) => {
v.color_hidden = vec![true; colors];
v.alpha_visible = (0..n).map(|c| c == i).collect();
}
}
fix_view_now(st);
}
s.tools.target_mask(t == ChannelTarget::LayerMask);
}
fn parse_target(s: &Session, p: &Value, cmd: &str) -> Result<ChannelTarget> {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
match p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, doc)) {
Some(ChanRef::Composite) => Ok(ChannelTarget::Composite),
Some(ChanRef::Color(k)) if k < color_count(doc) && color_count(doc) > 1 => Ok(ChannelTarget::Color(k)),
Some(ChanRef::Alpha(i)) if i < doc.channels.len() => Ok(ChannelTarget::Alpha(i)),
Some(ChanRef::LayerMask) => {
let active = crate::active_layer_of(s).map_err(|e| bad(cmd, e))?;
if active.mask.is_none() {
return Err(bad(cmd, "the active layer has no layer mask"));
}
Ok(ChannelTarget::LayerMask)
}
Some(ChanRef::VectorMask) => {
let active = crate::active_layer_of(s).map_err(|e| bad(cmd, e))?;
if active.vector_mask.is_none() {
return Err(bad(cmd, "the active layer has no vector mask"));
}
Ok(ChannelTarget::VectorMask)
}
_ => Err(bad(cmd, "`channel` must be \"composite\", \"mask\", \"vectorMask\", a colour channel name or an alpha channel")),
}
}
fn target_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let t = parse_target(s, p, "channel.target")?;
target(s, t);
Ok(list(s))
}
/// The channel shortcuts' modifier as the platform writes it, for error messages (`⌘4`, `Ctrl+4`).
const SLOT_KEY: &str = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" };
/// ⌘2 composite, ⌘3… colour channels then alpha channels (Photoshop CC defaults).
fn target_slot(s: &mut Session, slot: usize) -> Result<Value> {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let colors = color_count(doc);
let shown_colors = if colors > 1 { colors } else { 0 };
let t = match slot {
2 => ChannelTarget::Composite,
n if n - 3 < shown_colors => ChannelTarget::Color(n - 3),
n if n - 3 - shown_colors < doc.channels.len() => ChannelTarget::Alpha(n - 3 - shown_colors),
_ => return Err(EngineError::Other(format!("no channel for {SLOT_KEY}{slot}"))),
};
target(s, t);
Ok(list(s))
}
fn set_visible(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.setVisible";
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?;
let colors = color_count(doc);
let n = doc.channels.len();
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
let v = &mut st.channel_view;
let visible = |cur: bool| p.get("visible").and_then(Value::as_bool).unwrap_or(!cur);
match r {
ChanRef::Composite => {
let all = v.visible_colors(colors) == colors;
let on = visible(all);
v.color_hidden = vec![!on; colors];
}
ChanRef::Color(k) if k < colors => {
let on = visible(v.color_visible(k));
v.set_color_visible(k, on);
}
ChanRef::Alpha(i) if i < n => {
let on = visible(v.alpha_shown(i));
v.set_alpha_visible(i, on);
}
ChanRef::QuickMask => v.quick_mask_hidden = !visible(!v.quick_mask_hidden),
_ => return Err(bad(cmd, "that channel has no visibility")),
}
fix_view_now(st);
Ok(list(s))
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.options";
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let r = p.get("channel").and_then(|v| parse_ref(v, doc)).ok_or_else(|| bad(cmd, "missing or unknown `channel`"))?;
let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let color = color_value(cmd, p, "color")?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
let solidity = p.get("solidity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0));
let kind = p.get("indicates").and_then(Value::as_str).map(str::to_string);
let display_only = name.is_none() && kind.is_none() && solidity.is_none();
let apply = move |ch: &mut AlphaChannel| -> Result<()> {
if let Some(n) = &name {
ch.name = n.clone();
}
match kind.as_deref() {
Some("spot") => {
let (c, sol) = ch.spot.unwrap_or((ch.color, 0.0));
ch.spot = Some((color.unwrap_or(c), solidity.unwrap_or(sol)));
return Ok(());
}
Some(k) => {
ch.indicates = indicates_param(Some(&json!(k))).ok_or_else(|| bad(cmd, format!("unknown `indicates` {k}")))?;
ch.spot = None;
}
None => {}
}
match &mut ch.spot {
Some((c, sol)) => {
*c = color.unwrap_or(*c);
*sol = solidity.or(opacity).unwrap_or(*sol);
}
None => {
ch.color = color.unwrap_or(ch.color);
ch.opacity = opacity.unwrap_or(ch.opacity);
}
}
Ok(())
};
match r {
ChanRef::Alpha(i) if i < doc.channels.len() => {
s.edit("Channel Options", |doc, _| apply(&mut doc.channels[i]))?;
}
ChanRef::QuickMask => {
// Quick Mask Options persist for the session; the live Quick Mask follows them.
let mut probe = AlphaChannel {
color: s.quick_mask_options.color,
opacity: s.quick_mask_options.opacity,
indicates: s.quick_mask_options.indicates,
..AlphaChannel::new("Quick Mask", Surface::new(PixelFormat::GRAY8))
};
apply(&mut probe)?;
if probe.spot.is_some() {
return Err(bad(cmd, "Quick Mask can't be a spot channel"));
}
s.quick_mask_options = QuickMaskOptions { color: probe.color, opacity: probe.opacity, indicates: probe.indicates };
if s.active().is_some_and(|d| d.doc.quick_mask.is_some()) {
let o = s.quick_mask_options;
s.edit("Quick Mask Options", |doc, _| {
let area = doc.bounds();
let fmt = channel_format(doc);
let unselected = doc.quick_mask_unselected;
if let Some(q) = &mut doc.quick_mask {
// An untouched no-selection mask stays colourless (nothing selected)
// when the colour switches between masked and selected areas.
if unselected && q.indicates != o.indicates {
let empty = if q.indicates == ColorIndicates::SelectedAreas { 0.0 } else { 1.0 };
if uniform(&read_plane(&q.surface, area), empty) {
q.surface = unselected_surface(area, fmt, o.indicates);
}
}
q.color = o.color;
q.opacity = o.opacity;
q.indicates = o.indicates;
}
Ok(())
})?;
}
}
ChanRef::LayerMask => {
// Layer Mask Display Options: only the rubylith's colour and opacity, for every mask.
if !display_only {
return Err(bad(cmd, "a layer mask's options are only `color` and `opacity`"));
}
// A preference, kept across restarts; not an undo step.
if color.is_some() || opacity.is_some() {
s.edit_prefs(|prefs| {
let o = &mut prefs.layer_mask_overlay;
if let Some(c) = color {
o.color = crate::adjust_params::hex(c.to_rgb());
}
if let Some(a) = opacity {
o.opacity = (a * 100.0).round();
}
});
}
let o = serde_json::to_value(&s.prefs().layer_mask_overlay).unwrap_or(Value::Null);
let mut out = list(s);
if let Some(m) = out.as_object_mut() {
m.insert("layerMaskOverlay".into(), o);
}
return Ok(out);
}
_ => return Err(bad(cmd, "`channel` must be an alpha/spot channel, \"quickMask\" or \"mask\"")),
}
Ok(list(s))
}
/// Merge Spot Channel: flattens the image and prints the spot ink into the colour channels.
fn merge_spot(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.mergeSpot";
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let i = match p.get("channel") {
Some(_) => alpha_index(doc, p, "channel", cmd)?,
None => match s.active().ok_or(EngineError::NoDocument)?.channel_view.target {
ChannelTarget::Alpha(i) => i,
_ => doc.channels.iter().position(|c| c.spot.is_some()).ok_or_else(|| bad(cmd, "no spot channel"))?,
},
};
let (ink, solidity) = doc.channels[i].spot.ok_or_else(|| bad(cmd, "not a spot channel"))?;
s.edit("Merge Spot Channel", |doc, active| {
let area = doc.bounds();
let fmt = doc.pixel_format();
let comp = photocraft_compose::render(doc, area);
let k = read_plane(&doc.channels[i].surface, area);
let ink = ink.to_rgb();
// Ink over the image: opaque at 100% solidity, multiplied (transparent ink) at 0%.
let data: Vec<f32> = comp
.px
.iter()
.zip(&k)
.flat_map(|(c, &v)| {
let rgb: [f32; 3] = std::array::from_fn(|j| {
let over = solidity * ink[j] + (1.0 - solidity) * c[j] * ink[j];
c[j] + (over - c[j]) * v.clamp(0.0, 1.0)
});
from_rgba(&fmt, [rgb[0], rgb[1], rgb[2], 1.0])
})
.collect();
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
if let Some(sf) = bg.surface_mut() {
sf.write_region(area, &data);
sf.prune();
}
let id = bg.id;
doc.layers = vec![bg];
doc.channels.remove(i);
*active = Some(id);
Ok(())
})?;
if let Some(st) = s.active_mut() {
st.channel_view = ChannelView::default();
fix_view_now(st);
}
Ok(Value::Null)
}
/// Split Channels: one grayscale document per colour and alpha channel; closes the original.
fn split(s: &mut Session, p: &Value) -> Result<Value> {
let idx = s.active_index().ok_or(EngineError::NoDocument)?;
let doc = s.documents()[idx].doc.clone();
let area = doc.bounds();
let (px, n) = native_pixels(&doc, None)?;
let mode = edit_mode(&doc);
let names = color_names(mode);
let short = |name: &str| name.chars().next().map(|c| c.to_ascii_uppercase().to_string()).unwrap_or_default();
let mut docs: Vec<Document> = Vec::new();
for (c, name) in names.iter().enumerate() {
let plane: Vec<f32> = px.chunks_exact(n).map(|p| p[c]).collect();
docs.push(gray_document(&format!("{}_{}", doc.name, short(name)), &doc, &plane));
}
for ch in &doc.channels {
docs.push(gray_document(&format!("{}_{}", doc.name, ch.name), &doc, &read_plane(&ch.surface, area)));
}
if p.get("closeOriginal").and_then(Value::as_bool).unwrap_or(true) {
s.close(idx);
}
let made: Vec<usize> = docs.into_iter().map(|d| s.add_document(d, None)).collect();
Ok(json!({ "documents": made }))
}
/// Open grayscale documents with the active document's size, active first.
fn mergeable(s: &Session) -> Vec<usize> {
let Some(a) = s.active() else { return Vec::new() };
let ok = |d: &DocState| d.doc.mode == ColorMode::Grayscale && d.doc.size == a.doc.size;
let mut v: Vec<usize> = s.active_index().into_iter().filter(|_| ok(a)).collect();
v.extend(s.documents().iter().enumerate().filter(|(i, d)| Some(*i) != s.active_index() && ok(d)).map(|(i, _)| i));
v
}
/// Merge Channels: grayscale documents of one size become the channels of an RGB, CMYK or Lab
/// document (extra documents become alpha channels); the sources close.
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "channel.merge";
let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") {
"rgb" => ColorMode::Rgb,
"cmyk" => ColorMode::Cmyk,
"lab" => ColorMode::Lab,
other => return Err(bad(cmd, format!("unknown mode `{other}`"))),
};
let srcs: Vec<usize> = match p.get("documents").and_then(Value::as_array) {
Some(a) => a
.iter()
.map(|v| v.as_u64().map(|i| i as usize).filter(|i| *i < s.documents().len()).ok_or_else(|| bad(cmd, "bad document index")))
.collect::<Result<_>>()?,
None => mergeable(s).into_iter().take(mode.color_channels()).collect(),
};
if srcs.len() < mode.color_channels() {
return Err(bad(cmd, format!("{mode:?} needs {} grayscale documents, got {}", mode.color_channels(), srcs.len())));
}
// Each source closes by its *current* index, so a repeated index would shift the later
// removals onto a document that was never named (#934). Refuse it rather than guess.
let mut seen = std::collections::HashSet::new();
if let Some(dup) = srcs.iter().find(|i| !seen.insert(**i)) {
return Err(bad(cmd, format!("document {dup} is listed more than once")));
}
let Some(first) = srcs.first().and_then(|&i| s.documents().get(i)).map(|d| d.doc.clone()) else {
return Err(bad(cmd, "bad document index"));
};
let mut planes = Vec::new();
for &i in &srcs {
let d = &s.documents()[i].doc;
if d.size != first.size || d.mode != ColorMode::Grayscale {
return Err(bad(cmd, format!("\"{}\" is not a grayscale document of the same size", d.name)));
}
planes.push(gray_plane(d, None, None, ChanRef::Composite)?);
}
let mut out = Document::new(p.get("name").and_then(Value::as_str).unwrap_or("Untitled"), first.size, mode, first.depth);
out.resolution_dpi = first.resolution_dpi;
let fmt = out.pixel_format();
let cc = mode.color_channels();
let npx = planes[0].len();
let mut data = Vec::with_capacity(npx * fmt.channels());
for i in 0..npx {
for pl in &planes[..cc] {
data.push(pl[i].clamp(0.0, 1.0));
}
data.push(1.0);
}
let mut bg = Layer::raster("Background", fmt);
bg.locks.transparency = true;
bg.locks.position = true;
if let Some(sf) = bg.surface_mut() {
sf.write_region(out.bounds(), &data);
sf.prune();
}
out.layers.push(bg);
let area = out.bounds();
let cf = channel_format(&out);
for (k, pl) in planes[cc..].iter().enumerate() {
out.channels.push(AlphaChannel::new(format!("Alpha {}", k + 1), plane_surface(pl, area, cf)));
}
let mut close: Vec<usize> = srcs.clone();
close.sort_unstable();
for i in close.into_iter().rev() {
s.close(i);
}
let i = s.add_document(out, None);
Ok(json!({ "document": i }))
}
// ---------------------------------------------------------------------------------------------
// Apply Image / Calculations
fn apply_image(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.applyImage";
let blending = Blending::parse(p).map_err(|e| bad(cmd, e))?;
let opacity = p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) as f32 / 100.0;
let preserve = p.get("preserveTransparency").and_then(Value::as_bool).unwrap_or(false);
let src_spec = source_spec(p, "source", "source").unwrap_or_else(|| json!({}));
let src = source(s, &src_spec, cmd, false)?;
let mask = mask_plane(s, p, cmd)?;
let layer = s.active().and_then(|d| d.active_layer);
s.edit("Apply Image", |doc, _| {
let area = doc.bounds();
let selection = doc.selection.as_ref().map(|m| sel::mask_from_surface(Some(m), area));
let weight = |i: usize| opacity * mask.as_ref().map_or(1.0, |m| m[i]) * selection.as_ref().map_or(1.0, |m| m[i]);
let mask_target = target_of(p) == Target::Mask;
if is_channel_target(p) || mask_target {
// A grayscale target gets the luminosity of a colour source (one plane).
let srcp =
if src.len() == 1 { src[0].clone() } else { (0..src[0].len()).map(|i| src.iter().map(|pl| pl[i]).sum::<f32>() / src.len() as f32).collect() };
let (surf, _) = target_surface(doc, layer, p)?;
let base = read_plane(surf, area);
let out: Vec<f32> = base.iter().enumerate().map(|(i, &b)| b + (blending.apply(b, srcp[i]) - b) * weight(i)).collect();
if mask_target {
// A layer mask keeps its default (reveal or hide) outside the canvas.
if surf.channels() != 1 {
return Err(EngineError::Other("the layer mask is not a grayscale surface".into()));
}
surf.write_region(area, &out);
surf.prune();
} else {
*surf = plane_surface(&out, area, surf.format());
}
return Ok(());
}
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let fmt = doc.pixel_format();
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let sf = surf.format();
if sf != fmt {
return Err(EngineError::Other("the target layer's format doesn't match the document".into()));
}
let n = sf.channels();
let colors = sf.mode.color_channels();
let mut v = surf.read_region(area);
for (i, px) in v.chunks_exact_mut(n).enumerate() {
let base_a = if sf.alpha { px[colors].clamp(0.0, 1.0) } else { 1.0 };
let mut w = weight(i);
if preserve {
w *= base_a;
}
if w <= 0.0 {
continue;
}
// Painted over the layer like a brush: the blend only acts where the layer has
// pixels (where it is transparent the source shows as is), and the alpha grows by
// source-over, so applying to an empty layer copies the source in any mode.
let out_a = if preserve { base_a } else { w + base_a * (1.0 - w) };
for (c, b) in px[..colors].iter_mut().enumerate() {
let top = src.get(c).unwrap_or(&src[0])[i];
let blended = top + (blending.apply(*b, top) - top) * base_a;
*b = if preserve { *b + (blended - *b) * w } else { (w * blended + base_a * (1.0 - w) * *b) / out_a };
}
if sf.alpha {
px[colors] = out_a;
}
}
surf.write_region(area, &v);
surf.prune();
Ok(())
})?;
Ok(Value::Null)
}
fn calculations(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.calculations";
let blending = Blending::parse(p).map_err(|e| bad(cmd, e))?;
let opacity = p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0).clamp(0.0, 100.0) as f32 / 100.0;
let s1 = source(s, &source_spec(p, "source1", "source1").unwrap_or_else(|| json!({})), cmd, true)?.remove(0);
let s2 = source(s, &source_spec(p, "source2", "source2").unwrap_or_else(|| json!({})), cmd, true)?.remove(0);
let mask = mask_plane(s, p, cmd)?;
// Source 1 is blended onto Source 2.
let out: Vec<f32> =
s2.iter().zip(&s1).enumerate().map(|(i, (&b, &t))| b + (blending.apply(b, t) - b) * opacity * mask.as_ref().map_or(1.0, |m| m[i])).collect();
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
match p.get("result").and_then(Value::as_str).unwrap_or("newChannel") {
"newChannel" => {
let i = s.edit("Calculations", |doc, _| {
let name = name.clone().unwrap_or_else(|| next_alpha_name(doc, "Alpha"));
let area = doc.bounds();
let fmt = channel_format(doc);
doc.channels.push(AlphaChannel::new(name, plane_surface(&out, area, fmt)));
Ok(doc.channels.len() - 1)
})?;
Ok(json!({ "channel": i }))
}
"newDocument" => {
let like = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let d = gray_document(name.as_deref().unwrap_or("Untitled"), &like, &out);
let i = s.add_document(d, None);
Ok(json!({ "document": i }))
}
"selection" => {
let selected = s.edit("Calculations", |doc, _| {
doc.selection = sel::combine(None, &out, doc.bounds(), SelectionMode::Replace);
Ok(doc.selection.is_some())
})?;
Ok(json!({ "selected": selected }))
}
other => Err(bad(cmd, format!("unknown result `{other}` (newChannel|newDocument|selection)"))),
}
}
// ---------------------------------------------------------------------------------------------
// State as JSON
/// The Channels panel rows and view state of a document (also part of `inspect`).
pub fn channels_json(st: &DocState) -> Value {
let doc = &st.doc;
let mode = edit_mode(doc);
let v = &st.channel_view;
let colors = color_count(doc);
json!({
"composite": composite_name(mode),
"colors": color_names(mode).iter().enumerate().map(|(k, n)| json!({ "name": n, "visible": v.color_visible(k) })).collect::<Vec<_>>(),
"alpha": doc.channels.iter().enumerate().map(|(i, c)| json!({
"index": i,
"name": c.name,
"visible": v.alpha_shown(i),
"spot": c.spot.map(|(col, sol)| json!({ "color": col.to_rgb(), "solidity": sol })),
"color": c.color.to_rgb(),
"opacity": c.opacity,
"indicates": c.indicates,
})).collect::<Vec<_>>(),
"quickMask": doc.quick_mask.as_ref().map(|q| json!({ "visible": !v.quick_mask_hidden, "color": q.color.to_rgb(), "opacity": q.opacity, "indicates": q.indicates })),
"target": v.target,
"compositeVisible": v.visible_colors(colors) == colors,
"layerMaskView": crate::mask_view_cmds::view_json(st),
})
}
fn list(s: &Session) -> Value {
s.active().map(channels_json).unwrap_or(Value::Null)
}
// ---------------------------------------------------------------------------------------------
// Registry
macro_rules! spec {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"select.saveSelection",
"Save Selection…",
["Select"],
None,
r##"{"name":str?,"channel":"new"|index|name="new","document":index?,"operation":"new|replace|add|subtract|intersect"="new"}"##,
has_selection,
save_selection
),
spec!(
"select.loadSelection",
"Load Selection…",
["Select"],
None,
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection"|"targeted" (the Channels panel's target),"layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
has_doc,
load_selection
),
spec!("select.editInQuickMaskMode", "Edit in Quick Mask Mode", ["Select"], Some("Q"), r##"{"on":bool?=toggle}"##, has_doc, quick_mask),
spec!(
"image.applyImage",
"Apply Image…",
["Image"],
None,
r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##,
has_apply_target,
apply_image
),
spec!(
"image.calculations",
"Calculations…",
["Image"],
None,
r##"{"source1":{"document","layer","channel","invert"},"source2":{…},"blending":"multiply|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"mask":{…}?,"result":"newChannel|newDocument|selection"="newChannel","name":str?}"##,
has_doc,
calculations
),
spec!(
"channel.new",
"New Channel…",
[],
None,
r##"{"name":str?,"fill":"black|white|selection"="black","color":"#rrggbb"="#ff0000","opacity":0..100=50,"indicates":"masked|selected"="masked"}"##,
has_doc,
new_channel
),
spec!(
"channel.newSpot",
"New Spot Channel…",
[],
None,
r##"{"name":str?,"color":"#rrggbb"="#ff0000","solidity":0..100=0,"fromSelection":bool=true}"##,
has_doc,
new_spot
),
spec!(
"channel.duplicate",
"Duplicate Channel…",
[],
None,
r##"{"channel":index|name|"red|…"|"composite"|"quickMask","name":str?,"document":index|"new"?=active,"invert":bool=false}"##,
has_doc,
duplicate_channel
),
spec!("channel.delete", "Delete Channel", [], None, r##"{"channel":index|name?=targeted}"##, has_channels, delete_channel),
spec!("channel.rename", "Rename Channel", [], None, r##"{"channel":index|name,"name":str}"##, has_channels, rename_channel),
spec!("channel.move", "Reorder Channel", [], None, r##"{"channel":index|name,"to":index}"##, has_channels, move_channel),
spec!(
"channel.target",
"Target Channel",
[],
None,
r##"{"channel":"composite"|"mask"|"vectorMask"|"red|green|blue|…"|index|name="composite"}"##,
has_doc,
target_cmd
),
spec!(
"channel.setVisible",
"Channel Visibility",
[],
None,
r##"{"channel":"composite"|"red|…"|index|name|"quickMask","visible":bool?=toggle}"##,
has_doc,
set_visible
),
spec!(
"channel.options",
"Channel Options…",
[],
None,
r##"{"channel":index|name|"quickMask"|"mask","name":str?,"indicates":"masked|selected|spot"?,"color":"#rrggbb"?,"opacity":0..100?,"solidity":0..100?} ("mask": Layer Mask Display Options, the rubylith's color and opacity for every layer mask, kept in the preferences as layerMaskOverlay; not an undo step; returns them as layerMaskOverlay)"##,
has_doc,
options
),
spec!("channel.mergeSpot", "Merge Spot Channel", [], None, r##"{"channel":index|name?=targeted or first spot}"##, has_spot, merge_spot),
spec!("channel.split", "Split Channels", [], None, r##"{"closeOriginal":bool=true}"##, can_split, split),
spec!(
"channel.merge",
"Merge Channels…",
[],
None,
r##"{"mode":"rgb|cmyk|lab"="rgb","documents":[index,…]?=open grayscale docs of the active size,"name":str?}"##,
can_merge,
merge
),
CommandSpec {
id: "channel.list",
label: "Channels",
menu: &[],
shortcut: None,
params: "{}",
enabled: has_doc,
run: |s, _| Ok(list(s)),
journal: false,
},
spec!("channel.target.composite", "Target Composite Channel", [], Some("Cmd+2"), "{}", has_doc, |s, _| target_slot(s, 2)),
// ⌘\ targets the active layer's mask, as clicking its thumbnail does.
spec!("channel.target.layerMask", "Target Layer Mask", [], Some("Cmd+\\"), "{}", has_layer_mask, |s, _| target_cmd(
s,
&serde_json::json!({"channel": "mask"})
)),
// ⌘⌥2 loads the image's luminosity as a selection (⌘-click on the composite channel).
spec!("select.loadLuminosity", "Load Luminosity", [], Some("Cmd+Alt+2"), "{}", has_doc, |s, _| load_selection(
s,
&serde_json::json!({"channel": "composite"})
)),
spec!("channel.target.slot3", "Target Channel 3", [], Some("Cmd+3"), "{}", slot_ok::<3>, |s, _| target_slot(s, 3)),
spec!("channel.target.slot4", "Target Channel 4", [], Some("Cmd+4"), "{}", slot_ok::<4>, |s, _| target_slot(s, 4)),
spec!("channel.target.slot5", "Target Channel 5", [], Some("Cmd+5"), "{}", slot_ok::<5>, |s, _| target_slot(s, 5)),
spec!("channel.target.slot6", "Target Channel 6", [], Some("Cmd+6"), "{}", slot_ok::<6>, |s, _| target_slot(s, 6)),
spec!("channel.target.slot7", "Target Channel 7", [], Some("Cmd+7"), "{}", slot_ok::<7>, |s, _| target_slot(s, 7)),
spec!("channel.target.slot8", "Target Channel 8", [], Some("Cmd+8"), "{}", slot_ok::<8>, |s, _| target_slot(s, 8)),
spec!("channel.target.slot9", "Target Channel 9", [], Some("Cmd+9"), "{}", slot_ok::<9>, |s, _| target_slot(s, 9)),
]
}
/// Target Layer Mask: the active layer has a mask.
fn has_layer_mask(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("no active layer")?;
layer.mask.as_ref().map(|_| ()).ok_or_else(|| "the active layer has no layer mask".to_string())
}
fn slot_ok<const N: usize>(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
let colors = color_count(&d.doc);
let shown = if colors > 1 { colors } else { 0 };
if N - 3 < shown + d.doc.channels.len() { Ok(()) } else { Err(format!("no channel for {SLOT_KEY}{N}")) }
}
fn has_apply_target(s: &Session) -> std::result::Result<(), String> {
if edits_channel(s) {
return Ok(());
}
let d = s.active().ok_or("no document open")?;
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
if matches!(l.content, LayerContent::Raster(_)) {
Ok(())
} else {
Err(format!("Apply Image needs a pixel layer (active layer is {} {} layer)", l.content.article(), l.content.kind_name()))
}
}