mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
# Conflicts: # crates/ui-egui/src/i18n/cs.tsv # crates/ui-egui/src/i18n/de.tsv # crates/ui-egui/src/i18n/el.tsv # crates/ui-egui/src/i18n/es.tsv # crates/ui-egui/src/i18n/fr.tsv # crates/ui-egui/src/i18n/hu.tsv # crates/ui-egui/src/i18n/id.tsv # crates/ui-egui/src/i18n/it.tsv # crates/ui-egui/src/i18n/ja.tsv # crates/ui-egui/src/i18n/ko.tsv # crates/ui-egui/src/i18n/nl.tsv # crates/ui-egui/src/i18n/pl.tsv # crates/ui-egui/src/i18n/pt-br.tsv # crates/ui-egui/src/i18n/ru.tsv # crates/ui-egui/src/i18n/sr.tsv # crates/ui-egui/src/i18n/tr.tsv # crates/ui-egui/src/i18n/uk.tsv # crates/ui-egui/src/i18n/vi.tsv # crates/ui-egui/src/i18n/zh-hans.tsv # crates/ui-egui/src/i18n/zh-hant.tsv # crates/ui-egui/src/layer_shortcut_tests.rs
1607 lines
74 KiB
Rust
1607 lines
74 KiB
Rust
//! The rest of Photoshop's Edit menu: Fade, Purge, Content-Aware Fill and Scale, Define Brush
|
||
//! Preset / Custom Shape, Find and Replace Text, the Preset Manager and preset export/import.
|
||
//! Auto-Align and Auto-Blend Layers live in [`crate::align_cmds`].
|
||
//!
|
||
//! Check Spelling is not implemented: it needs a word list per language, and no permissively
|
||
//! licensed dictionary small enough to bundle (or a pure-Rust checker with one) was available.
|
||
|
||
use photocraft_color::{BlendMode, PixelFormat};
|
||
use photocraft_doc::vector::Path;
|
||
use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId};
|
||
use photocraft_geom::Rect;
|
||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::channel_cmds::Target;
|
||
use crate::commands::{CommandSpec, blend_from_str, int, layer_param};
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
// ------------------------------------------------------------------ state
|
||
|
||
/// What Edit › Fade would fade: the result of the last filter, adjustment or paint command.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct FadeSource {
|
||
pub doc: DocId,
|
||
/// Document revision right after the command; any later change disables Fade.
|
||
pub revision: u64,
|
||
pub target: FadeTarget,
|
||
pub label: String,
|
||
}
|
||
|
||
/// What the faded command changed, and so what Fade blends back.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum FadeTarget {
|
||
/// A layer's pixels.
|
||
Pixels(LayerId),
|
||
/// A layer's layer mask.
|
||
Mask(LayerId),
|
||
/// An alpha or spot channel (index into [`Document::channels`]).
|
||
Channel(usize),
|
||
/// The Quick Mask.
|
||
QuickMask,
|
||
}
|
||
|
||
/// A custom shape (Edit › Define Custom Shape), usable by the Custom Shape tool.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct CustomShape {
|
||
pub name: String,
|
||
pub path: Path,
|
||
}
|
||
|
||
/// Edit-menu state kept by the session.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct EditState {
|
||
pub fade: Option<FadeSource>,
|
||
/// Custom shape library (the vector tools read it).
|
||
pub custom_shapes: Vec<CustomShape>,
|
||
/// Find and Replace Text position: (layer, byte offset after the current match).
|
||
pub find_cursor: Option<(LayerId, usize, usize)>,
|
||
}
|
||
|
||
/// Commands whose result Edit › Fade can blend back against the previous state.
|
||
fn fadeable(id: &str) -> bool {
|
||
id.starts_with("filter.")
|
||
|| id.starts_with("image.adjustments.")
|
||
|| id.starts_with("paint.")
|
||
|| matches!(id, "edit.fill" | "edit.stroke" | "edit.contentAwareFill" | "image.autoTone" | "image.autoContrast" | "image.autoColor")
|
||
}
|
||
|
||
/// Called after every successful command: remember a fadeable result.
|
||
pub(crate) fn after_command(s: &mut Session, id: &str, params: &Value) {
|
||
if !fadeable(id) {
|
||
return;
|
||
}
|
||
let Some(st) = s.active() else { return };
|
||
let label = st.history.undo_label().unwrap_or(id).to_string();
|
||
// An alpha channel or the Quick Mask took the edit instead of the layer when the command ran
|
||
// on it (#7382): its explicit `target`, or the one the Channels panel gave it. Only commands
|
||
// routed to channels edit them; the others edit the layer whatever `target` says.
|
||
let run = crate::channel_cmds::inject_target(s, id, params.clone());
|
||
let channel = match crate::channel_cmds::target_of(&run) {
|
||
_ if !crate::channel_cmds::routed(id) => None,
|
||
Target::Alpha(i) if i < st.doc.channels.len() => Some(FadeTarget::Channel(i)),
|
||
Target::QuickMask if st.doc.quick_mask.is_some() => Some(FadeTarget::QuickMask),
|
||
_ => None,
|
||
};
|
||
if let Some(target) = channel {
|
||
s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, target, label });
|
||
return;
|
||
}
|
||
let Some(layer) = st.active_layer else { return };
|
||
let mask = st.doc.layer(layer).is_some_and(|l| l.mask.is_some())
|
||
&& st.history.state(st.history.past_len().wrapping_sub(1)).is_some_and(|prev| {
|
||
// The mask changed and the pixels didn't: the command painted the mask.
|
||
let now = st.doc.layer(layer);
|
||
let was = prev.layer(layer);
|
||
let surf_eq = |a: Option<&Surface>, b: Option<&Surface>| match (a, b) {
|
||
(Some(a), Some(b)) => a.format() == b.format() && a.default_pixel() == b.default_pixel() && crate_fingerprint(a) == crate_fingerprint(b),
|
||
(None, None) => true,
|
||
_ => false,
|
||
};
|
||
surf_eq(now.and_then(|l| l.surface()), was.and_then(|l| l.surface()))
|
||
&& !surf_eq(now.and_then(|l| l.mask.as_ref().map(|m| &m.surface)), was.and_then(|l| l.mask.as_ref().map(|m| &m.surface)))
|
||
});
|
||
let target = if mask { FadeTarget::Mask(layer) } else { FadeTarget::Pixels(layer) };
|
||
s.edit_state.fade = Some(FadeSource { doc: st.doc.id, revision: st.revision, target, label });
|
||
}
|
||
|
||
/// Tile identity of a surface (pointer equality of its COW tiles).
|
||
fn crate_fingerprint(s: &Surface) -> u64 {
|
||
s.tiles().fold(s.tile_count() as u64, |h, (c, t)| {
|
||
(h ^ std::sync::Arc::as_ptr(t) as usize as u64 ^ ((c.tx as u64) << 32 | c.ty as u32 as u64)).wrapping_mul(0x100_0000_01b3)
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------------ helpers
|
||
|
||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||
}
|
||
|
||
fn f32_or(p: &Value, k: &str, d: f32) -> f32 {
|
||
p.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32)
|
||
}
|
||
|
||
fn str_or<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str {
|
||
p.get(k).and_then(Value::as_str).unwrap_or(d)
|
||
}
|
||
|
||
fn bool_or(p: &Value, k: &str, d: bool) -> bool {
|
||
p.get(k).and_then(Value::as_bool).unwrap_or(d)
|
||
}
|
||
|
||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||
Ok(())
|
||
}
|
||
|
||
fn pixel_layer(s: &Session) -> std::result::Result<LayerId, String> {
|
||
let d = s.active().ok_or("no document open")?;
|
||
let id = d.active_layer.ok_or("no active layer")?;
|
||
pixel_layer_id(s, id)
|
||
}
|
||
|
||
/// `id` if it is an unlocked pixel layer of the active document.
|
||
fn pixel_layer_id(s: &Session, id: LayerId) -> std::result::Result<LayerId, String> {
|
||
let d = s.active().ok_or("no document open")?;
|
||
let l = d.doc.layer(id).ok_or("no active layer")?;
|
||
if !matches!(l.content, LayerContent::Raster(_)) {
|
||
return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()));
|
||
}
|
||
let locks = d.doc.effective_locks(id);
|
||
if locks.all || locks.pixels {
|
||
return Err(format!("the layer \"{}\" is locked", l.name));
|
||
}
|
||
Ok(id)
|
||
}
|
||
|
||
fn writable_surface(doc: &mut Document, id: LayerId) -> Result<&mut Surface> {
|
||
let locks = doc.effective_locks(id);
|
||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||
if locks.all || locks.pixels {
|
||
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
|
||
}
|
||
l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Fade
|
||
|
||
fn can_fade(s: &Session) -> std::result::Result<(), String> {
|
||
let st = s.active().ok_or("no document open")?;
|
||
let f = s.edit_state.fade.as_ref().ok_or("nothing to fade")?;
|
||
if f.doc != st.doc.id || f.revision != st.revision || !st.history.can_undo() {
|
||
return Err("Fade is only available right after a filter, adjustment or painting step".into());
|
||
}
|
||
if surface_of(&st.doc, f.target).is_none() {
|
||
return Err(match f.target {
|
||
FadeTarget::Pixels(_) | FadeTarget::Mask(_) => "the faded layer no longer exists".into(),
|
||
FadeTarget::Channel(_) | FadeTarget::QuickMask => "the faded channel no longer exists".into(),
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn surface_of(doc: &Document, target: FadeTarget) -> Option<&Surface> {
|
||
match target {
|
||
FadeTarget::Pixels(id) => doc.layer(id)?.surface(),
|
||
FadeTarget::Mask(id) => doc.layer(id)?.mask.as_ref().map(|m| &m.surface),
|
||
FadeTarget::Channel(i) => doc.channels.get(i).map(|c| &c.surface),
|
||
FadeTarget::QuickMask => doc.quick_mask.as_ref().map(|c| &c.surface),
|
||
}
|
||
}
|
||
|
||
fn surface_of_mut(doc: &mut Document, target: FadeTarget) -> Option<&mut Surface> {
|
||
match target {
|
||
FadeTarget::Pixels(id) => doc.layer_mut(id)?.surface_mut(),
|
||
FadeTarget::Mask(id) => doc.layer_mut(id)?.mask.as_mut().map(|m| &mut m.surface),
|
||
FadeTarget::Channel(i) => doc.channels.get_mut(i).map(|c| &mut c.surface),
|
||
FadeTarget::QuickMask => doc.quick_mask.as_mut().map(|c| &mut c.surface),
|
||
}
|
||
}
|
||
|
||
/// Blend `after` back towards `before` over `area`: `before + opacity × (B(before, after) − before)`
|
||
/// with blend mode `mode` (premultiplied, so alpha changes fade too).
|
||
pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: BlendMode, opacity: f32) -> Result<()> {
|
||
let fmt = after.format();
|
||
let n = fmt.channels();
|
||
if area.is_empty() || n == 0 {
|
||
return Ok(());
|
||
}
|
||
let b = crate::allocation::read_region(before, area, "fading pixels")?;
|
||
let mut a = crate::allocation::read_region(after, area, "fading pixels")?;
|
||
let k = opacity.clamp(0.0, 1.0);
|
||
let lerp_premul = |pb: &[f32], pa: &[f32], out: &mut [f32], alpha: bool| {
|
||
let m = out.len();
|
||
if !alpha {
|
||
for c in 0..m {
|
||
out[c] = pb[c] + (pa[c] - pb[c]) * k;
|
||
}
|
||
return;
|
||
}
|
||
let (ab, aa) = (pb[m - 1], pa[m - 1]);
|
||
let ao = ab + (aa - ab) * k;
|
||
for c in 0..m - 1 {
|
||
let pm = pb[c] * ab + (pa[c] * aa - pb[c] * ab) * k;
|
||
out[c] = if ao > 1e-9 { pm / ao } else { 0.0 };
|
||
}
|
||
out[m - 1] = ao;
|
||
};
|
||
let mut tmp = crate::allocation::filled(n, 0.0f32, "fading pixels")?;
|
||
for (pa, pb) in a.chunks_exact_mut(n).zip(b.chunks_exact(n)) {
|
||
if mode == BlendMode::Normal || n == 1 {
|
||
lerp_premul(pb, pa, &mut tmp, fmt.alpha);
|
||
pa.copy_from_slice(&tmp);
|
||
} else {
|
||
let rb = to_rgba(&fmt, pb);
|
||
let ra = to_rgba(&fmt, pa);
|
||
let blended = photocraft_color::blend::composite(mode, rb, ra, 1.0);
|
||
let mut o = [0.0f32; 4];
|
||
lerp_premul(&rb, &blended, &mut o, true);
|
||
let mut enc = [0.0f32; 8];
|
||
from_rgba_into(&fmt, o, &mut enc);
|
||
pa.copy_from_slice(&enc[..n]);
|
||
}
|
||
}
|
||
after.try_write_region(area, &a)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn fade(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "edit.fade";
|
||
let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?;
|
||
let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0;
|
||
let mode = blend_from_str(str_or(p, "mode", "normal")).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let prev = st.history.past_len().checked_sub(1).and_then(|i| st.history.state(i)).ok_or_else(|| bad(cmd, "no previous state"))?;
|
||
let before = surface_of(&prev, src.target).cloned().ok_or_else(|| EngineError::Other("the layer or channel did not exist before this step".into()))?;
|
||
let now = surface_of(&st.doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?;
|
||
if now.format() != before.format() {
|
||
return Err(EngineError::Other("can't fade a step that changed the layer's pixel format".into()));
|
||
}
|
||
// A uniform mask can change every pixel without storing a single tile. In that case
|
||
// the content bounds are empty, but Fade must still blend across the canvas.
|
||
let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) };
|
||
let label = format!("Fade {}", src.label);
|
||
s.edit(&label, |doc, _| {
|
||
let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?;
|
||
fade_surface(&before, surf, area, mode, opacity)?;
|
||
surf.prune();
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()}))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Purge
|
||
|
||
fn history_bytes(s: &Session, all: bool) -> usize {
|
||
let docs: Vec<&crate::DocState> = if all { s.documents().iter().collect() } else { s.active().into_iter().collect() };
|
||
// Purge keeps the snapshots, so the tiles they share with the states aren't freed.
|
||
docs.iter()
|
||
.map(|d| {
|
||
let snaps: Vec<&photocraft_doc::Document> = d.snapshots.iter().map(|s| &*s.doc).collect();
|
||
d.history.unique_bytes_besides(&d.doc, &snaps)
|
||
})
|
||
.sum()
|
||
}
|
||
|
||
fn clip_bytes(s: &Session) -> usize {
|
||
s.clipboard.as_ref().map_or(0, |c| c.surface.tiles().map(|(_, t)| t.bytes().len()).sum())
|
||
}
|
||
|
||
fn purge(s: &mut Session, what: &str) -> Result<Value> {
|
||
let mut freed = 0usize;
|
||
let mut items: Vec<&str> = Vec::new();
|
||
if matches!(what, "undo" | "all") && s.active().is_some_and(|d| d.history.can_undo()) {
|
||
let before = history_bytes(s, false);
|
||
if let Some(st) = s.active_mut() {
|
||
st.history.purge_last();
|
||
}
|
||
freed += before.saturating_sub(history_bytes(s, false));
|
||
items.push("undo");
|
||
}
|
||
if matches!(what, "clipboard" | "all") && (s.clipboard.is_some() || s.style_clipboard.is_some()) {
|
||
freed += clip_bytes(s);
|
||
s.clipboard = None;
|
||
s.style_clipboard = None;
|
||
items.push("clipboard");
|
||
}
|
||
if matches!(what, "histories" | "all") {
|
||
let b = history_bytes(s, true);
|
||
if s.documents().iter().any(|d| d.history.can_undo() || d.history.can_redo()) {
|
||
for st in &mut s.docs {
|
||
st.history.clear();
|
||
st.coalesce = None;
|
||
}
|
||
freed += b;
|
||
items.push("histories");
|
||
}
|
||
}
|
||
if what == "all" {
|
||
let fx = photocraft_compose::purge_effect_cache();
|
||
if fx > 0 {
|
||
freed += fx;
|
||
items.push("effect cache");
|
||
}
|
||
let masks = photocraft_compose::masks::purge_cache();
|
||
if masks > 0 {
|
||
freed = freed.saturating_add(masks);
|
||
items.push("mask cache");
|
||
}
|
||
let stages = crate::smart_cmds::purge_stack_cache();
|
||
if stages > 0 {
|
||
freed = freed.saturating_add(stages);
|
||
items.push("smart filter cache");
|
||
}
|
||
let strokes = photocraft_compose::shape_split::purge_cache();
|
||
if strokes > 0 {
|
||
freed = freed.saturating_add(strokes);
|
||
items.push("shape stroke cache");
|
||
}
|
||
}
|
||
s.edit_state.fade = None;
|
||
let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) };
|
||
Ok(json!({"purged": items, "bytes": freed, "message": msg}))
|
||
}
|
||
|
||
fn can_purge_undo(s: &Session) -> std::result::Result<(), String> {
|
||
s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "nothing to purge".into())
|
||
}
|
||
fn can_purge_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||
if s.clipboard.is_some() || s.style_clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
|
||
}
|
||
fn can_purge_histories(s: &Session) -> std::result::Result<(), String> {
|
||
if s.documents().iter().any(|d| d.history.can_undo() || d.history.can_redo()) { Ok(()) } else { Err("no history to purge".into()) }
|
||
}
|
||
fn can_purge_all(s: &Session) -> std::result::Result<(), String> {
|
||
can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| {
|
||
if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 {
|
||
Ok(())
|
||
} else {
|
||
Err("nothing to purge".into())
|
||
}
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Content-Aware Fill
|
||
|
||
fn can_caf(s: &Session) -> std::result::Result<(), String> {
|
||
pixel_layer(s)?;
|
||
let d = s.active().ok_or("no document open")?;
|
||
if d.doc.selection.as_ref().is_none_or(|m| m.content_bounds().is_empty()) {
|
||
return Err("make a selection around what to remove".into());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Mask surface (gray) of an alpha channel by index or name.
|
||
pub(crate) fn channel_mask<'a>(doc: &'a Document, v: &Value) -> Option<&'a Surface> {
|
||
match v {
|
||
Value::Number(n) => doc.channels.get(n.as_u64()? as usize).map(|c| &c.surface),
|
||
Value::String(name) => doc.channels.iter().find(|c| c.name == *name).map(|c| &c.surface),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// `key` as an `[x, y, w, h]` rectangle. The saturating float→int casts bound each component;
|
||
/// the additions saturate too — `area: [1e30, 0, 1e30, 10]` is a whole-canvas window, not an
|
||
/// overflow (it panicked in debug builds before the `saturating_add`).
|
||
fn rect_param(p: &Value, key: &str, cmd: &str) -> Result<Rect> {
|
||
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be [x, y, w, h]")))?;
|
||
let n = |i: usize| {
|
||
a.get(i)
|
||
.and_then(Value::as_f64)
|
||
.filter(|f| f.is_finite())
|
||
.map(|v| v.round() as i32)
|
||
.ok_or_else(|| bad(cmd, format!("`{key}` must be four finite numbers")))
|
||
};
|
||
let (x, y, w, h) = (n(0)?, n(1)?, n(2)?, n(3)?);
|
||
Ok(Rect::new(x, y, x.saturating_add(w.max(0)), y.saturating_add(h.max(0))))
|
||
}
|
||
|
||
fn content_aware_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||
content_aware_fill_as(s, p, "edit.contentAwareFill", "Content-Aware Fill")
|
||
}
|
||
|
||
/// Delete and Fill Selection (#1286): Photoshop's one-click removal from the selection-tool
|
||
/// context menu. Content-Aware Fill into the layer, no dialog, sampling as Edit › Fill does (the
|
||
/// whole window, measured on Photoshop 25.4) with the default colour adaptation.
|
||
fn delete_and_fill(s: &mut Session, _: &Value) -> Result<Value> {
|
||
content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection")
|
||
}
|
||
|
||
/// Everything one Content-Aware Fill needs, worked out from the document and the command's
|
||
/// params. The Content-Aware Fill workspace runs exactly this for its preview, so what it shows
|
||
/// is what OK and Apply write.
|
||
pub struct CafPlan {
|
||
pub layer: LayerId,
|
||
/// The rectangle worked on: the sampling window, and any sampling-brush strokes or custom
|
||
/// area beyond it.
|
||
pub rect: Rect,
|
||
/// Photoshop's sampling window around the fill area (`content_aware::sampling_window`).
|
||
pub window: Rect,
|
||
pub fmt: PixelFormat,
|
||
/// What is sampled over `rect`: the target's pixels, or with Sample All Layers the visible
|
||
/// composite in the target's encoding.
|
||
pub img: Vec<f32>,
|
||
/// The target's own pixels over `rect` when they differ from `img` (Sample All Layers); see
|
||
/// [`CafPlan::layer_pixels`].
|
||
pub layer_px: Option<Vec<f32>>,
|
||
/// The fill area (selection coverage, after the workspace's lasso edits) over `rect`.
|
||
pub cover: Vec<f32>,
|
||
pub hole: Vec<bool>,
|
||
/// What may be copied from: the sampling option's area before the brush strokes, and after.
|
||
pub base: Vec<bool>,
|
||
pub source: Vec<bool>,
|
||
pub opts: photocraft_algo::content_aware::FillOptions,
|
||
pub output: String,
|
||
}
|
||
|
||
impl CafPlan {
|
||
/// The target layer's pixels over `rect`.
|
||
pub fn layer_pixels(&self) -> &[f32] {
|
||
self.layer_px.as_deref().unwrap_or(&self.img)
|
||
}
|
||
}
|
||
|
||
/// The workspace's lasso edits of the fill area: `{"op": "new|add|subtract|intersect", "points":
|
||
/// [[x, y], ...]}` or `{"op": "expand|contract", "by": px}`, applied in order.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum FillEdit {
|
||
Lasso { op: SelOp, points: Vec<[f64; 2]> },
|
||
Grow(i32),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum SelOp {
|
||
New,
|
||
Add,
|
||
Subtract,
|
||
Intersect,
|
||
}
|
||
|
||
/// Most `fillEdits` and `samplingStrokes` one fill takes, and most points in all of them
|
||
/// together: the strokes and lassos are painted pixel by pixel, segment by segment.
|
||
pub const MAX_CAF_EDITS: usize = 1_000;
|
||
pub const MAX_CAF_POINTS: usize = 200_000;
|
||
|
||
fn points_param(v: &Value, cmd: &str, what: &str, budget: &mut usize) -> Result<Vec<[f64; 2]>> {
|
||
let a = v.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{what} needs `points` [[x, y], ...]")))?;
|
||
*budget = budget.checked_sub(a.len()).ok_or_else(|| bad(cmd, format!("at most {MAX_CAF_POINTS} points in all fill edits and sampling strokes")))?;
|
||
a.iter()
|
||
.map(|p| {
|
||
let x = p.get(0).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7);
|
||
let y = p.get(1).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7);
|
||
match (x, y) {
|
||
(Some(x), Some(y)) => Ok([x, y]),
|
||
_ => Err(bad(cmd, format!("{what} points must be pairs of finite numbers"))),
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// `p[key]` as an array of at most [`MAX_CAF_EDITS`] entries (empty when absent).
|
||
fn edits_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a [Value]> {
|
||
let Some(a) = p.get(key) else { return Ok(&[]) };
|
||
let a = a.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be an array")))?;
|
||
if a.len() > MAX_CAF_EDITS {
|
||
return Err(bad(cmd, format!("`{key}` takes at most {MAX_CAF_EDITS} entries")));
|
||
}
|
||
Ok(a)
|
||
}
|
||
|
||
fn fill_edits(p: &Value, cmd: &str, budget: &mut usize) -> Result<Vec<FillEdit>> {
|
||
edits_param(p, "fillEdits", cmd)?
|
||
.iter()
|
||
.map(|e| {
|
||
let op = e.get("op").and_then(Value::as_str).unwrap_or("");
|
||
Ok(match op {
|
||
"expand" | "contract" => {
|
||
let by = e
|
||
.get("by")
|
||
.and_then(Value::as_f64)
|
||
.filter(|v| v.is_finite() && (0.0..=1000.0).contains(v))
|
||
.ok_or_else(|| bad(cmd, "`by` must be 0..1000 px"))?;
|
||
FillEdit::Grow(if op == "expand" { by.round() as i32 } else { -(by.round() as i32) })
|
||
}
|
||
"new" | "add" | "subtract" | "intersect" => FillEdit::Lasso {
|
||
op: match op {
|
||
"new" => SelOp::New,
|
||
"add" => SelOp::Add,
|
||
"subtract" => SelOp::Subtract,
|
||
_ => SelOp::Intersect,
|
||
},
|
||
points: points_param(e, cmd, "a lasso edit", budget)?,
|
||
},
|
||
o => return Err(bad(cmd, format!("unknown fill edit `{o}` (new|add|subtract|intersect|expand|contract)"))),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn sampling_strokes(p: &Value, cmd: &str, budget: &mut usize) -> Result<Vec<photocraft_algo::content_aware::SamplingStroke>> {
|
||
edits_param(p, "samplingStrokes", cmd)?
|
||
.iter()
|
||
.map(|e| {
|
||
let add = match e.get("mode").and_then(Value::as_str).unwrap_or("add") {
|
||
"add" => true,
|
||
"subtract" => false,
|
||
o => return Err(bad(cmd, format!("unknown stroke mode `{o}` (add|subtract)"))),
|
||
};
|
||
let size = e
|
||
.get("size")
|
||
.and_then(Value::as_f64)
|
||
.filter(|v| v.is_finite() && (1.0..=5000.0).contains(v))
|
||
.ok_or_else(|| bad(cmd, "a stroke's `size` must be 1..5000 px"))?;
|
||
Ok(photocraft_algo::content_aware::SamplingStroke { add, size, points: points_param(e, cmd, "a sampling stroke", budget)? })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// A Content-Aware Fill's params, checked against the document before any pixel is read.
|
||
struct CafParams<'a> {
|
||
sel: &'a Surface,
|
||
/// The selection's bounds on the canvas.
|
||
sb: Rect,
|
||
edits: Vec<FillEdit>,
|
||
strokes: Vec<photocraft_algo::content_aware::SamplingStroke>,
|
||
sampling: String,
|
||
custom_mask: Option<&'a Surface>,
|
||
custom_rect: Option<Rect>,
|
||
opts: photocraft_algo::content_aware::FillOptions,
|
||
output: String,
|
||
surf: &'a Surface,
|
||
}
|
||
|
||
fn caf_params<'a>(doc: &'a Document, id: LayerId, p: &Value, cmd: &str) -> Result<CafParams<'a>> {
|
||
use photocraft_algo::content_aware::{FillOptions, color_level, rotation_level};
|
||
let sel = doc.selection.as_ref().ok_or_else(|| bad(cmd, "no selection"))?;
|
||
let sb = sel.content_bounds().intersect(&doc.bounds());
|
||
if sb.is_empty() {
|
||
return Err(bad(cmd, "the selection is outside the canvas"));
|
||
}
|
||
let mut budget = MAX_CAF_POINTS;
|
||
let edits = fill_edits(p, cmd, &mut budget)?;
|
||
let strokes = sampling_strokes(p, cmd, &mut budget)?;
|
||
let sampling = str_or(p, "sampling", "auto").to_string();
|
||
let custom_mask: Option<&Surface> = p.get("channel").and_then(|v| channel_mask(doc, v));
|
||
let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None };
|
||
match sampling.as_str() {
|
||
"auto" | "rectangular" => {}
|
||
// The workspace's Custom: only what the Sampling Brush adds.
|
||
"custom" if custom_rect.is_some() || custom_mask.is_some() || strokes.iter().any(|s| s.add) => {}
|
||
"custom" => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h], `channel` (alpha channel index or name) or `samplingStrokes`")),
|
||
other => return Err(bad(cmd, format!("unknown sampling `{other}` (auto|rectangular|custom)"))),
|
||
}
|
||
let opts = FillOptions {
|
||
color_adaptation: color_level(str_or(p, "colorAdaptation", "default")),
|
||
rotations: rotation_level(str_or(p, "rotationAdaptation", "none")),
|
||
scale: bool_or(p, "scale", false),
|
||
mirror: bool_or(p, "mirror", false),
|
||
seed: p.get("seed").and_then(Value::as_u64).unwrap_or(1),
|
||
};
|
||
let output = str_or(p, "output", "current").to_string();
|
||
if !matches!(output.as_str(), "current" | "new" | "duplicate") {
|
||
return Err(bad(cmd, "`output` must be current|new|duplicate"));
|
||
}
|
||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
|
||
Ok(CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf })
|
||
}
|
||
|
||
/// Work out a Content-Aware Fill (see [`CafPlan`]) on layer `id` of `doc`.
|
||
pub fn content_aware_plan(doc: &Document, id: LayerId, p: &Value, cmd: &str) -> Result<CafPlan> {
|
||
content_aware_plan_with(doc, id, p, cmd, &photocraft_raster::Interrupt::NONE)
|
||
}
|
||
|
||
/// [`content_aware_plan`], asking `ctl` row by row whether to stop ([`EngineError::Cancelled`]).
|
||
pub fn content_aware_plan_with(doc: &Document, id: LayerId, p: &Value, cmd: &str, ctl: &photocraft_raster::Interrupt) -> Result<CafPlan> {
|
||
use photocraft_algo::content_aware::{auto_sampling_with, grow_mask, paint_strokes, polygon_mask};
|
||
use rayon::prelude::*;
|
||
|
||
use crate::fill_cmds::{read_window, read_window_mask};
|
||
let CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf } = caf_params(doc, id, p, cmd)?;
|
||
let canvas = doc.bounds();
|
||
let cancel = || ctl.cancelled();
|
||
let stop = || if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) };
|
||
// The fill area: the selection, then the lasso edits, over a rectangle that holds them all.
|
||
let grow: i32 = edits.iter().map(|e| if let FillEdit::Grow(b) = e { b.max(&0) } else { &0 }).sum();
|
||
let reach = edits.iter().fold(sb, |r, e| match e {
|
||
FillEdit::Lasso { points, .. } => {
|
||
points.iter().fold(r, |r, q| r.union(&Rect::new(q[0].floor() as i32, q[1].floor() as i32, q[0].floor() as i32 + 1, q[1].floor() as i32 + 1)))
|
||
}
|
||
FillEdit::Grow(_) => r,
|
||
});
|
||
let er = reach.inflate(grow.saturating_add(1)).intersect(&canvas);
|
||
let (ew, eh) = crate::fill_cmds::window_size(er)?;
|
||
let ecover = read_window_mask(sel, er, &cancel).ok_or(EngineError::Cancelled)?;
|
||
let mut ehole: Vec<bool> = ecover.par_iter().map(|c| *c > 0.0).collect();
|
||
for e in &edits {
|
||
stop()?;
|
||
match e {
|
||
FillEdit::Lasso { op, points } => {
|
||
let m = polygon_mask(er, points);
|
||
for (h, v) in ehole.iter_mut().zip(&m) {
|
||
*h = match op {
|
||
SelOp::New => *v,
|
||
SelOp::Add => *h || *v,
|
||
SelOp::Subtract => *h && !*v,
|
||
SelOp::Intersect => *h && *v,
|
||
};
|
||
}
|
||
}
|
||
FillEdit::Grow(by) => ehole = grow_mask(&ehole, ew, eh, *by),
|
||
}
|
||
}
|
||
let (bx0, by0, bx1, by1) = mask_bounds(ew, eh, &ehole);
|
||
if bx1 <= bx0 {
|
||
return Err(bad(cmd, "the fill area is empty"));
|
||
}
|
||
let hb = Rect::new(er.x0 + bx0 as i32, er.y0 + by0 as i32, er.x0 + bx1 as i32, er.y0 + by1 as i32);
|
||
// Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin`
|
||
// asks for a rectangle that far around the fill area instead.
|
||
let window = match sampling.as_str() {
|
||
"rectangular" => match int(p, "margin") {
|
||
Some(m) => hb.inflate(m.clamp(0, 100_000) as i32),
|
||
None => crate::fill_cmds::sampling_window(hb, canvas),
|
||
},
|
||
"custom" => match (custom_rect, custom_mask) {
|
||
(Some(r), _) => r,
|
||
(None, Some(m)) => m.content_bounds(),
|
||
(None, None) => hb,
|
||
}
|
||
.union(&hb)
|
||
.inflate(4),
|
||
_ => crate::fill_cmds::sampling_window(hb, canvas),
|
||
}
|
||
.intersect(&canvas);
|
||
// Strokes that add to the sampling area may reach past the window.
|
||
let rect = strokes.iter().filter(|s| s.add).fold(window, |r, s| r.union(&s.bounds())).intersect(&canvas);
|
||
let fmt = surf.format();
|
||
// Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active
|
||
// layer has no pixels in the window there is nothing to sample, so that case uses the
|
||
// visible composite (#4026). An explicit false still reads only this layer.
|
||
let sample_all = match p.get("sampleAllLayers").and_then(Value::as_bool) {
|
||
Some(v) => v,
|
||
None => surf.content_bounds().intersect(&window).is_empty(),
|
||
};
|
||
let n = fmt.channels();
|
||
// Refuse a window too large to read before allocating for it, whatever the sampling (#963).
|
||
let (w, h) = crate::fill_cmds::window_size(rect)?;
|
||
let pixels = read_window(surf, rect, &cancel).ok_or(EngineError::Cancelled)?;
|
||
let (img, layer_px) = if sample_all {
|
||
stop()?;
|
||
(crate::retouch_cmds::composite_region(doc, Some(id), crate::retouch_cmds::SampleLayers::All, rect, fmt).data, Some(pixels))
|
||
} else {
|
||
(pixels, None)
|
||
};
|
||
stop()?;
|
||
let mut cover = vec![0.0f32; w * h];
|
||
let mut hole = vec![false; w * h];
|
||
cover.par_chunks_mut(w.max(1)).zip(hole.par_chunks_mut(w.max(1))).enumerate().try_for_each(|(y, (crow, hrow))| {
|
||
stop()?;
|
||
let dy = rect.y0 + y as i32;
|
||
if dy < er.y0 || dy >= er.y1 {
|
||
return Ok(());
|
||
}
|
||
for (x, (c, hl)) in crow.iter_mut().zip(hrow.iter_mut()).enumerate() {
|
||
let dx = rect.x0 + x as i32;
|
||
if dx >= er.x0 && dx < er.x1 {
|
||
let j = (dy - er.y0) as usize * ew + (dx - er.x0) as usize;
|
||
let (eh, ec) = (ehole.get(j).copied().unwrap_or(false), ecover.get(j).copied().unwrap_or(0.0));
|
||
*hl = eh;
|
||
// Pixels the lasso added are filled outright; selected ones by their coverage.
|
||
*c = if eh { if ec > 0.0 { ec } else { 1.0 } } else { 0.0 };
|
||
}
|
||
}
|
||
Ok::<(), EngineError>(())
|
||
})?;
|
||
drop((ecover, ehole));
|
||
// The sampling option's area: Auto keeps what looks like the surroundings within the window,
|
||
// Rectangular the whole window, Custom the given area (or nothing, for the brush to add to).
|
||
let mut base = vec![false; w * h];
|
||
match sampling.as_str() {
|
||
"auto" | "rectangular" => {
|
||
let (ww, wh) = (window.width() as usize, window.height() as usize);
|
||
let (ox, oy) = ((window.x0 - rect.x0) as usize, (window.y0 - rect.y0) as usize);
|
||
let area: Vec<bool> = match sampling.as_str() {
|
||
"auto" if window == rect => auto_sampling_with(w, h, n, &img, &hole, ctl).map_err(|_| EngineError::Cancelled)?,
|
||
"auto" => {
|
||
let mut wimg = vec![0.0f32; ww * wh * n];
|
||
let mut whole = vec![false; ww * wh];
|
||
for y in 0..wh {
|
||
let s = ((oy + y) * w + ox) * n;
|
||
wimg[y * ww * n..(y + 1) * ww * n].copy_from_slice(&img[s..s + ww * n]);
|
||
whole[y * ww..(y + 1) * ww].copy_from_slice(&hole[(oy + y) * w + ox..(oy + y) * w + ox + ww]);
|
||
}
|
||
auto_sampling_with(ww, wh, n, &wimg, &whole, ctl).map_err(|_| EngineError::Cancelled)?
|
||
}
|
||
_ => vec![true; ww * wh],
|
||
};
|
||
for y in 0..wh {
|
||
base[(oy + y) * w + ox..(oy + y) * w + ox + ww].copy_from_slice(&area[y * ww..(y + 1) * ww]);
|
||
}
|
||
}
|
||
_ => match (custom_rect, custom_mask) {
|
||
(Some(r), _) => base.par_iter_mut().enumerate().for_each(|(i, b)| *b = r.contains(rect.x0 + (i % w) as i32, rect.y0 + (i / w) as i32)),
|
||
(None, Some(m)) => {
|
||
let v = read_window_mask(m, rect, &cancel).ok_or(EngineError::Cancelled)?;
|
||
base.par_iter_mut().zip(&v).for_each(|(b, v)| *b = *v > 0.5);
|
||
}
|
||
(None, None) => {}
|
||
},
|
||
}
|
||
stop()?;
|
||
base.par_iter_mut().zip(&hole).for_each(|(b, h)| *b &= !*h);
|
||
let mut source = base.clone();
|
||
paint_strokes(&mut source, rect, &strokes);
|
||
source.par_iter_mut().zip(&hole).for_each(|(s, h)| *s &= !*h);
|
||
Ok(CafPlan { layer: id, rect, window, fmt, img, layer_px, cover, hole, base, source, opts, output })
|
||
}
|
||
|
||
fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result<Value> {
|
||
use photocraft_algo::content_aware::fill_owned;
|
||
// The workspace names the layer it opened on: after an Apply to a new layer that one is
|
||
// active, but the fill still samples the original.
|
||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||
Some(l) => pixel_layer_id(s, LayerId(l)),
|
||
None => pixel_layer(s),
|
||
}
|
||
.map_err(EngineError::Other)?;
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let doc = st.doc.clone();
|
||
// Check the params before the job starts.
|
||
caf_params(&doc, id, p, cmd)?;
|
||
let p = p.clone();
|
||
// A background job when started with `Session::start` (#210): working out the fill area and
|
||
// the sampling area, reading the pixels and the PatchMatch fill run on a worker against the
|
||
// document snapshot, cancellable per row band.
|
||
crate::jobs::run(
|
||
s,
|
||
label,
|
||
true,
|
||
move |ctx| {
|
||
use rayon::prelude::*;
|
||
ctx.progress(0.0, label);
|
||
let cancel = || ctx.cancelled();
|
||
let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?;
|
||
let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan;
|
||
// Patches come only from the sampling area outside the fill area. With none (a
|
||
// Rectangular `margin` of 0, a Custom area inside the selection or off the canvas)
|
||
// the fill would be black (#6532), so refuse instead. The workspace's preview builds
|
||
// the same plan and may show an empty area while the Sampling Brush adds to it.
|
||
if !source.iter().any(|s| *s) {
|
||
return Err(bad(cmd, "nothing to sample from: the sampling area has no pixels outside the fill area"));
|
||
}
|
||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||
let n = fmt.channels();
|
||
// The fill changes only the fill area: keep the layer's pixels and the coverage over
|
||
// its bounds for the apply step, and fill the sampled pixels themselves.
|
||
let (bx0, by0, bx1, by1) = mask_bounds(w, h, &hole);
|
||
let own = layer_px.as_deref().unwrap_or(&img);
|
||
let original: Vec<f32> = (by0..by1).flat_map(|y| own[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect();
|
||
let cover: Vec<f32> = (by0..by1).flat_map(|y| cover[y * w + bx0..y * w + bx1].iter().copied()).collect();
|
||
let n_hole = cover.iter().filter(|c| **c > 0.0).count();
|
||
ctx.check()?;
|
||
let mut filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?;
|
||
// Sampled from all layers: outside the fill area the window keeps the layer's pixels.
|
||
if let Some(own) = layer_px {
|
||
filled.par_chunks_mut(n.max(1)).zip(own.par_chunks(n.max(1))).zip(hole.par_iter()).for_each(|((f, o), h)| {
|
||
if !*h {
|
||
f.copy_from_slice(o);
|
||
}
|
||
});
|
||
}
|
||
Ok((layer, rect, fmt, output, FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }))
|
||
},
|
||
move |s, (layer, rect, fmt, output, done)| apply_content_aware_fill(s, label, layer, &output, fmt, rect, done),
|
||
)
|
||
}
|
||
|
||
/// A Content-Aware Fill's result for its apply step: the filled window (the same as the
|
||
/// original outside the selected pixels) and, over the bounds of the selected pixels (in window
|
||
/// coordinates), the original pixels and the selection's coverage.
|
||
struct FilledWindow {
|
||
filled: Vec<f32>,
|
||
bounds: (usize, usize, usize, usize),
|
||
original: Vec<f32>,
|
||
cover: Vec<f32>,
|
||
n_hole: usize,
|
||
}
|
||
|
||
/// `f(window index, index over the bounds, coverage)` for each selected pixel of a
|
||
/// [`FilledWindow`] (`w` wide).
|
||
fn each_selected(w: usize, (bx0, by0, bx1, by1): (usize, usize, usize, usize), cover: &[f32], mut f: impl FnMut(usize, usize, f32)) {
|
||
let bw = bx1.saturating_sub(bx0);
|
||
for y in by0..by1 {
|
||
for x in bx0..bx1 {
|
||
let k = (y - by0) * bw + (x - bx0);
|
||
if let Some(&c) = cover.get(k)
|
||
&& c > 0.0
|
||
{
|
||
f(y * w + x, k, c);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Bounds `(x0, y0, x1, y1)` of the set cells of a `w × h` mask (all zero when there are none).
|
||
fn mask_bounds(w: usize, h: usize, m: &[bool]) -> (usize, usize, usize, usize) {
|
||
use rayon::prelude::*;
|
||
if w == 0 {
|
||
return (0, 0, 0, 0);
|
||
}
|
||
let rows: Vec<Option<(usize, usize)>> =
|
||
m.par_chunks(w).take(h).map(|row| Some((row.iter().position(|v| *v)?, row.iter().rposition(|v| *v)? + 1))).collect();
|
||
let mut b: Option<(usize, usize, usize, usize)> = None;
|
||
for (y, span) in rows.into_iter().enumerate() {
|
||
if let Some((x0, x1)) = span {
|
||
b = Some(b.map_or((x0, y, x1, y + 1), |(bx0, by0, bx1, _)| (bx0.min(x0), by0, bx1.max(x1), y + 1)));
|
||
}
|
||
}
|
||
b.unwrap_or((0, 0, 0, 0))
|
||
}
|
||
|
||
/// Write a Content-Aware Fill result (the job's apply step): into the layer, a new layer or a
|
||
/// duplicate, as one undo step.
|
||
fn apply_content_aware_fill(s: &mut Session, label: &str, id: LayerId, output: &str, fmt: PixelFormat, window: Rect, done: FilledWindow) -> Result<Value> {
|
||
let n = fmt.channels();
|
||
let (w, h) = (window.width() as usize, window.height() as usize);
|
||
let FilledWindow { mut filled, bounds, original, cover, n_hole } = done;
|
||
let layer = s.edit(label, |doc, active| {
|
||
let target = match output {
|
||
"new" => {
|
||
let mut l = Layer::raster(doc.next_layer_name("Content-Aware Fill"), PixelFormat { alpha: true, ..fmt });
|
||
let mut px = vec![0.0f32; w * h * n.max(1)];
|
||
let lf = l.surface().map(|s| s.format()).unwrap_or(fmt);
|
||
let ln = lf.channels();
|
||
px.resize(w * h * ln, 0.0);
|
||
each_selected(w, bounds, &cover, |i, _, c| {
|
||
let rgba = to_rgba(&fmt, &filled[i * n..(i + 1) * n]);
|
||
let mut enc = [0.0f32; 8];
|
||
from_rgba_into(&lf, [rgba[0], rgba[1], rgba[2], rgba[3] * c], &mut enc);
|
||
px[i * ln..(i + 1) * ln].copy_from_slice(&enc[..ln]);
|
||
});
|
||
if let Some(s) = l.surface_mut() {
|
||
s.write_region(window, &px);
|
||
s.prune();
|
||
}
|
||
let nid = doc.insert_above(Some(id), l);
|
||
*active = Some(nid);
|
||
return Ok(nid);
|
||
}
|
||
"duplicate" => {
|
||
let mut dup = doc.layer(id).ok_or(EngineError::NoLayer(id))?.duplicate();
|
||
dup.name = doc.copy_name(&dup.name);
|
||
let nid = doc.insert_above(Some(id), dup);
|
||
*active = Some(nid);
|
||
nid
|
||
}
|
||
_ => id,
|
||
};
|
||
let surf = writable_surface(doc, target)?;
|
||
// The fill blended in by the coverage (elsewhere it is the original).
|
||
each_selected(w, bounds, &cover, |i, k, c| {
|
||
for ch in 0..n {
|
||
let (j, o) = (i * n + ch, k * n + ch);
|
||
filled[j] = original[o] + (filled[j] - original[o]) * c;
|
||
}
|
||
});
|
||
surf.write_region(window, &filled);
|
||
Ok(target)
|
||
})?;
|
||
Ok(json!({"layer": layer.0, "filled": n_hole, "window": [window.x0, window.y0, window.width(), window.height()]}))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Content-Aware Scale
|
||
|
||
/// Reach of Content-Aware Scale's `x`/`y`: the largest document side.
|
||
const CAS_POSITION_LIMIT: f64 = 300_000.0;
|
||
|
||
/// What Content-Aware Scale does, read from its params against the active document: the
|
||
/// command and the transform box's live preview ([`ContentAwareScalePreview`]) share it.
|
||
struct CasPlan {
|
||
doc: std::sync::Arc<Document>,
|
||
layer: LayerId,
|
||
/// The area scaled: the selection's bounds, else the layer's content.
|
||
region: Rect,
|
||
/// Where the result goes: `x`, `y` (default: the region's corner), `width`, `height`.
|
||
dst: Rect,
|
||
amount: f32,
|
||
protect: Option<Surface>,
|
||
skin_tones: bool,
|
||
}
|
||
|
||
fn cas_plan(s: &Session, p: &Value) -> Result<CasPlan> {
|
||
let cmd = "edit.contentAwareScale";
|
||
let id = pixel_layer(s).map_err(EngineError::Other)?;
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let doc = st.doc.clone();
|
||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
|
||
let region = match &doc.selection {
|
||
Some(sel) => sel.content_bounds().intersect(&doc.bounds()),
|
||
None => surf.content_bounds(),
|
||
};
|
||
if region.is_empty() {
|
||
return Err(EngineError::Other("nothing to scale: the layer is empty".into()));
|
||
}
|
||
let (w, h) = (region.width() as usize, region.height() as usize);
|
||
let dim = |k: &str, pct: &str, cur: usize| -> Result<u32> {
|
||
let v = match (p.get(k).and_then(Value::as_f64), p.get(pct).and_then(Value::as_f64)) {
|
||
(Some(px), _) => px,
|
||
(None, Some(pc)) => cur as f64 * pc / 100.0,
|
||
(None, None) => cur as f64,
|
||
};
|
||
if !(1.0..=60_000.0).contains(&v) {
|
||
return Err(bad(cmd, format!("`{k}` must be 1..60000 px")));
|
||
}
|
||
Ok(v.round() as u32)
|
||
};
|
||
let (nw, nh) = (dim("width", "scaleX", w)?, dim("height", "scaleY", h)?);
|
||
let at = |k: &str, cur: i32| -> Result<i32> {
|
||
match p.get(k).filter(|v| !v.is_null()) {
|
||
None => Ok(cur),
|
||
Some(v) => match v.as_f64() {
|
||
Some(x) if (-CAS_POSITION_LIMIT..=CAS_POSITION_LIMIT).contains(&x) => Ok(x.round() as i32),
|
||
_ => Err(bad(cmd, format!("`{k}` must be a number of px within ±300000"))),
|
||
},
|
||
}
|
||
};
|
||
let dst = Rect::from_xywh(at("x", region.x0)?, at("y", region.y0)?, nw, nh);
|
||
let amount = f32_or(p, "amount", 100.0).clamp(0.0, 100.0) / 100.0;
|
||
// Protection: an alpha channel and/or skin tones.
|
||
let protect = match p.get("protect").filter(|v| !v.is_null() && v.as_str() != Some("none")) {
|
||
Some(v) => Some(channel_mask(&doc, v).cloned().ok_or_else(|| bad(cmd, "`protect` must name an alpha channel (index or name) or \"none\""))?),
|
||
None => None,
|
||
};
|
||
let skin_tones = bool_or(p, "protectSkinTones", false);
|
||
Ok(CasPlan { doc, layer: id, region, dst, amount: if amount.is_finite() { amount } else { 1.0 }, protect, skin_tones })
|
||
}
|
||
|
||
/// The scaled pixels: `img` (`w`×`h`, `fmt`) seam-carved to `nw`×`nh` where the protection
|
||
/// (`protect`, one value per pixel, and the skin tones when asked) steers the seams, blended with
|
||
/// a plain resample by `amount` (0..1). Checks `ctx` for cancellation before every seam.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn cas_pixels(
|
||
ctx: &crate::jobs::JobCtx,
|
||
label: &str,
|
||
img: &[f32],
|
||
(w, h): (usize, usize),
|
||
fmt: PixelFormat,
|
||
mut protect: Option<Vec<f32>>,
|
||
skin_tones: bool,
|
||
(nw, nh): (usize, usize),
|
||
amount: f32,
|
||
) -> Result<Vec<f32>> {
|
||
use photocraft_algo::seam::{carve_with, resize_bilinear, skin_mask};
|
||
let n = fmt.channels();
|
||
if skin_tones {
|
||
let rgba: Vec<f32> = img.chunks_exact(n).flat_map(|px| to_rgba(&fmt, px)).collect();
|
||
let skin = skin_mask(w, h, 4, &rgba);
|
||
protect = Some(match protect {
|
||
Some(pm) => pm.iter().zip(&skin).map(|(a, b)| a.max(*b)).collect(),
|
||
None => skin,
|
||
});
|
||
}
|
||
ctx.check()?;
|
||
let carved = if amount > 0.0 {
|
||
ctx.stage(0.05, 0.95, label, |ctl| carve_with(w, h, n, img, protect.as_deref(), nw, nh, ctl)).map_err(|_| EngineError::Cancelled)?
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let plain = if amount < 1.0 { resize_bilinear(w, h, n, img, nw, nh) } else { Vec::new() };
|
||
Ok(match (carved.is_empty(), plain.is_empty()) {
|
||
(false, true) => carved,
|
||
(true, false) => plain,
|
||
_ => carved.iter().zip(&plain).map(|(c, l)| l + (c - l) * amount).collect::<Vec<f32>>(),
|
||
})
|
||
}
|
||
|
||
fn content_aware_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let CasPlan { doc, layer: id, region, dst, amount, protect: protect_mask, skin_tones } = cas_plan(s, p)?;
|
||
let (w, h) = (region.width() as usize, region.height() as usize);
|
||
let (nw, nh) = (dst.width() as usize, dst.height() as usize);
|
||
let label = "Content-Aware Scale";
|
||
// A background job when started with `Session::start` (#210): seam carving runs on a worker
|
||
// against the document snapshot and checks for cancellation before every seam.
|
||
crate::jobs::run(
|
||
s,
|
||
label,
|
||
true,
|
||
move |ctx| {
|
||
ctx.progress(0.0, label);
|
||
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
|
||
let img = surf.read_region(region);
|
||
let protect: Option<Vec<f32>> =
|
||
protect_mask.map(|m| (0..w * h).map(|i| m.sample_channel(region.x0 + (i % w) as i32, region.y0 + (i / w) as i32, 0)).collect());
|
||
cas_pixels(ctx, label, &img, (w, h), surf.format(), protect, skin_tones, (nw, nh), amount)
|
||
},
|
||
move |s, out| {
|
||
s.edit(label, |doc, _| {
|
||
let surf = writable_surface(doc, id)?;
|
||
crate::pixels::try_clear_surface(surf, region, None)?;
|
||
surf.write_region(dst, &out);
|
||
surf.prune();
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"from": [w, h], "to": [nw, nh], "bounds": [dst.x0, dst.y0, nw, nh]}))
|
||
},
|
||
)
|
||
}
|
||
|
||
/// Content-Aware Scale on a reduced copy, for the transform box's live preview (#3096): the
|
||
/// command's own carving, on the area scaled down so the longer of its sides before and after
|
||
/// is at most `max_side` px. Prepared from the session ([`ContentAwareScalePreview::new`]) and
|
||
/// [`run`](ContentAwareScalePreview::run) on any thread.
|
||
pub struct ContentAwareScalePreview {
|
||
plan: CasPlan,
|
||
max_side: u32,
|
||
}
|
||
|
||
impl ContentAwareScalePreview {
|
||
/// `p` takes `edit.contentAwareScale`'s params; the same ones fail the same way.
|
||
pub fn new(s: &Session, p: &Value, max_side: u32) -> Result<Self> {
|
||
Ok(Self { plan: cas_plan(s, p)?, max_side: max_side.max(1) })
|
||
}
|
||
|
||
/// The area scaled, in document px.
|
||
pub fn region(&self) -> Rect {
|
||
self.plan.region
|
||
}
|
||
|
||
/// The reduced sizes of the area before and after: `[w, h, new w, new h]`.
|
||
fn reduced(&self) -> [usize; 4] {
|
||
let (region, dst) = (self.plan.region, self.plan.dst);
|
||
let (w, h, nw, nh) = (region.width(), region.height(), dst.width(), dst.height());
|
||
let k = (f64::from(w.max(h).max(nw).max(nh)) / f64::from(self.max_side)).max(1.0);
|
||
let side = |v: u32| ((f64::from(v) / k).round() as usize).max(1);
|
||
[side(w), side(h), side(nw), side(nh)]
|
||
}
|
||
|
||
/// The size of [`run`](Self::run)'s result, px.
|
||
pub fn size(&self) -> [u32; 2] {
|
||
let [_, _, w, h] = self.reduced();
|
||
[w as u32, h as u32]
|
||
}
|
||
|
||
/// The scaled pixels at the reduced size ([`size`](Self::size)), in the layer's format, as a
|
||
/// surface with its corner at the origin. Checks `ctx` for cancellation before every seam.
|
||
pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<Surface> {
|
||
let CasPlan { doc, layer, region, dst: _, amount, protect, skin_tones } = &self.plan;
|
||
let surf = doc.layer(*layer).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(*layer))?;
|
||
let fmt = surf.format();
|
||
let n = fmt.channels();
|
||
let (w, h) = (region.width(), region.height());
|
||
let [pw, ph, pnw, pnh] = self.reduced();
|
||
// Nearest samples of the area (and of the protecting channel) at the reduced size.
|
||
let src_x: Vec<i32> = (0..pw).map(|i| region.x0 + (((i as f64 + 0.5) * f64::from(w) / pw as f64) as i32).min(w as i32 - 1)).collect();
|
||
let src_y = |j: usize| region.y0 + (((j as f64 + 0.5) * f64::from(h) / ph as f64) as i32).min(h as i32 - 1);
|
||
let mut img = Vec::with_capacity(pw * ph * n);
|
||
for j in 0..ph {
|
||
ctx.check()?;
|
||
let row = surf.read_region(Rect::from_xywh(region.x0, src_y(j), w, 1));
|
||
for &x in &src_x {
|
||
let at = (x - region.x0) as usize * n;
|
||
img.extend_from_slice(row.get(at..at + n).ok_or_else(|| EngineError::Other("preview read out of bounds".into()))?);
|
||
}
|
||
}
|
||
let protect = protect.as_ref().map(|m| (0..pw * ph).map(|i| m.sample_channel(src_x[i % pw], src_y(i / pw), 0)).collect());
|
||
let out = cas_pixels(ctx, "Content-Aware Scale", &img, (pw, ph), fmt, protect, *skin_tones, (pnw, pnh), *amount)?;
|
||
let mut s = Surface::new(fmt);
|
||
s.write_region(Rect::from_xywh(0, 0, pnw as u32, pnh as u32), &out);
|
||
Ok(s)
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Define Brush / Custom Shape
|
||
|
||
fn define_brush_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let name = match p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) {
|
||
Some(n) => n.to_string(),
|
||
None => {
|
||
let n = s.tools.presets.iter().filter(|b| b.name.starts_with("Sampled Brush")).count() + 1;
|
||
format!("Sampled Brush {n}")
|
||
}
|
||
};
|
||
s.execute("brush.defineFromSelection", json!({"name": name}))
|
||
}
|
||
|
||
/// The path Define Custom Shape uses: `"work"`, a saved path name, else the work path, the
|
||
/// active shape layer, the active layer's vector mask, or the last saved path.
|
||
pub(crate) fn current_path(doc: &Document, active: Option<LayerId>, spec: Option<&str>) -> Option<Path> {
|
||
let ok = |p: &Path| !p.subpaths.is_empty();
|
||
match spec {
|
||
Some("work") => return doc.work_path.clone().filter(ok),
|
||
Some(name) => return doc.paths.iter().find(|n| n.name == name).map(|n| n.path.clone()).filter(ok),
|
||
None => {}
|
||
}
|
||
if let Some(p) = doc.work_path.clone().filter(ok) {
|
||
return Some(p);
|
||
}
|
||
if let Some(l) = active.and_then(|id| doc.layer(id)) {
|
||
if let LayerContent::Shape(sh) = &l.content
|
||
&& ok(&sh.path)
|
||
{
|
||
return Some(sh.path.clone());
|
||
}
|
||
if let Some(vm) = l.vector_mask.as_ref().filter(|v| ok(&v.path)) {
|
||
return Some(vm.path.clone());
|
||
}
|
||
}
|
||
doc.paths.last().map(|n| n.path.clone()).filter(ok)
|
||
}
|
||
|
||
fn can_define_shape(s: &Session) -> std::result::Result<(), String> {
|
||
let d = s.active().ok_or("no document open")?;
|
||
current_path(&d.doc, d.active_layer, None).map(|_| ()).ok_or_else(|| "select a path or shape layer first".into())
|
||
}
|
||
|
||
fn define_custom_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let path = current_path(&st.doc, st.active_layer, p.get("path").and_then(Value::as_str)).ok_or_else(|| bad("edit.defineCustomShape", "no such path"))?;
|
||
let name = p
|
||
.get("name")
|
||
.and_then(Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|n| !n.is_empty())
|
||
.map(str::to_string)
|
||
.unwrap_or_else(|| format!("Shape {}", s.edit_state.custom_shapes.len() + 1));
|
||
let shapes = &mut s.edit_state.custom_shapes;
|
||
match shapes.iter_mut().find(|c| c.name == name) {
|
||
Some(c) => c.path = path,
|
||
None => shapes.push(CustomShape { name: name.clone(), path }),
|
||
}
|
||
Ok(json!({"name": name, "count": shapes.len()}))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Find and Replace Text
|
||
|
||
/// Byte ranges of `find` in `text` (case-insensitive unless `case`; whole words only with `whole`).
|
||
pub fn find_matches(text: &str, find: &str, case: bool, whole: bool) -> Vec<(usize, usize)> {
|
||
if find.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let norm = |c: char| -> String { if case { c.to_string() } else { c.to_lowercase().collect() } };
|
||
let needle: String = find.chars().map(norm).collect();
|
||
let mut out = Vec::new();
|
||
let mut at = 0;
|
||
while at < text.len() {
|
||
// Compare char by char so case folding that changes byte lengths stays aligned.
|
||
let mut hay = String::new();
|
||
let mut end = at;
|
||
for (i, c) in text[at..].char_indices() {
|
||
if hay.len() >= needle.len() {
|
||
break;
|
||
}
|
||
hay.push_str(&norm(c));
|
||
end = at + i + c.len_utf8();
|
||
}
|
||
if hay == needle {
|
||
let word = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
|
||
let before = text[..at].chars().next_back();
|
||
let after = text[end..].chars().next();
|
||
if !whole || (!word(before) && !word(after)) {
|
||
out.push((at, end));
|
||
at = end;
|
||
continue;
|
||
}
|
||
}
|
||
at += text[at..].chars().next().map_or(1, char::len_utf8);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn type_layers(doc: &Document, forward: bool) -> Vec<LayerId> {
|
||
// Layers panel order: top first.
|
||
let mut ids: Vec<LayerId> =
|
||
doc.walk().into_iter().rev().filter(|(p, _, l)| matches!(l.content, LayerContent::Text(_)) && !doc.locks_at(p).all).map(|(_, _, l)| l.id).collect();
|
||
if !forward {
|
||
ids.reverse();
|
||
}
|
||
ids
|
||
}
|
||
|
||
fn has_type_layer(s: &Session) -> std::result::Result<(), String> {
|
||
let d = s.active().ok_or("no document open")?;
|
||
if type_layers(&d.doc, true).is_empty() { Err("the document has no type layers".into()) } else { Ok(()) }
|
||
}
|
||
|
||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "edit.findAndReplaceText";
|
||
let find = p.get("find").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad(cmd, "missing `find`"))?.to_string();
|
||
let replace = str_or(p, "replace", "").to_string();
|
||
let (case, whole, forward, all_layers) =
|
||
(bool_or(p, "caseSensitive", false), bool_or(p, "wholeWord", false), bool_or(p, "forward", true), bool_or(p, "allLayers", true));
|
||
let action = str_or(p, "action", "changeAll").to_string();
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let mut layers = type_layers(&st.doc, forward);
|
||
if !all_layers {
|
||
let active = layer_param(s, p).ok();
|
||
layers.retain(|id| Some(*id) == active);
|
||
}
|
||
let text_of = |doc: &Document, id: LayerId| -> Option<String> {
|
||
match &doc.layer(id)?.content {
|
||
LayerContent::Text(t) => Some(t.text.clone()),
|
||
_ => None,
|
||
}
|
||
};
|
||
match action.as_str() {
|
||
"changeAll" => {
|
||
let doc = st.doc.clone();
|
||
let plan: Vec<(LayerId, Vec<(usize, usize)>)> =
|
||
layers.iter().filter_map(|id| Some((*id, find_matches(&text_of(&doc, *id)?, &find, case, whole)))).filter(|(_, m)| !m.is_empty()).collect();
|
||
let count: usize = plan.iter().map(|(_, m)| m.len()).sum();
|
||
if count == 0 {
|
||
return Ok(json!({"count": 0, "layers": 0}));
|
||
}
|
||
s.edit("Replace All Text", |doc, _| {
|
||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||
for (id, matches) in &plan {
|
||
if let Some(LayerContent::Text(t)) = doc.layer_mut(*id).map(|l| &mut l.content) {
|
||
for (a, b) in matches.iter().rev() {
|
||
crate::type_cmds::replace_text(t, *a, *b, &replace);
|
||
}
|
||
crate::type_cmds::refresh_at(dpi, format, t);
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
s.edit_state.find_cursor = None;
|
||
Ok(json!({"count": count, "layers": plan.len()}))
|
||
}
|
||
"find" | "change" | "changeFind" => {
|
||
let doc = st.doc.clone();
|
||
let cursor = s.edit_state.find_cursor;
|
||
let mut changed = None;
|
||
if action != "find"
|
||
&& let Some((id, a, b)) = cursor
|
||
&& text_of(&doc, id).is_some_and(|t| find_matches(&t, &find, case, whole).contains(&(a, b)))
|
||
{
|
||
crate::type_cmds::check_text_unlocked(&doc, id)?;
|
||
s.edit("Replace Text", |doc, _| {
|
||
let (dpi, format) = (doc.resolution_dpi, doc.pixel_format());
|
||
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||
crate::type_cmds::replace_text(t, a, b, &replace);
|
||
crate::type_cmds::refresh_at(dpi, format, t);
|
||
}
|
||
Ok(())
|
||
})?;
|
||
let end = a + replace.len();
|
||
// Collapse the cursor on the side the search moves away from, so the next search
|
||
// skips the replaced text even when the replacement still matches.
|
||
let c = if forward { end } else { a };
|
||
s.edit_state.find_cursor = Some((id, c, c));
|
||
changed = Some((id, a, end));
|
||
if action == "change" {
|
||
return Ok(json!({"changed": {"layer": id.0, "start": a, "end": end}}));
|
||
}
|
||
}
|
||
// Find the next match after the cursor (wrapping through the layers).
|
||
let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||
let cursor = s.edit_state.find_cursor;
|
||
let start = cursor.and_then(|(id, _, _)| layers.iter().position(|l| *l == id)).unwrap_or(0);
|
||
for k in 0..=layers.len() {
|
||
// `allLayers: false` with a non-type layer active leaves no layers to search (#703).
|
||
let Some(&id) = layers.get((start + k) % layers.len().max(1)) else { break };
|
||
let Some(text) = text_of(&doc, id) else { continue };
|
||
let ms = find_matches(&text, &find, case, whole);
|
||
let hit = match (k, cursor) {
|
||
(0, Some((cid, a, b))) if cid == id => {
|
||
if forward {
|
||
ms.into_iter().find(|m| m.0 >= b)
|
||
} else {
|
||
ms.into_iter().rev().find(|m| m.1 <= a)
|
||
}
|
||
}
|
||
_ => {
|
||
if forward {
|
||
ms.into_iter().next()
|
||
} else {
|
||
ms.into_iter().next_back()
|
||
}
|
||
}
|
||
};
|
||
if let Some((a, b)) = hit {
|
||
s.edit_state.find_cursor = Some((id, a, b));
|
||
let _ = s.select_layer(id);
|
||
return Ok(
|
||
json!({"found": {"layer": id.0, "start": a, "end": b, "text": &text[a..b]}, "changed": changed.map(|(l, a, b)| json!({"layer": l.0, "start": a, "end": b}))}),
|
||
);
|
||
}
|
||
}
|
||
s.edit_state.find_cursor = None;
|
||
Ok(json!({"found": null, "changed": changed.map(|(l, a, b)| json!({"layer": l.0, "start": a, "end": b}))}))
|
||
}
|
||
other => Err(bad(cmd, format!("unknown action `{other}` (find|change|changeFind|changeAll)"))),
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ presets
|
||
|
||
/// Libraries the Preset Manager edits (patterns export as `.pat` with `pattern.export`).
|
||
const PRESET_KINDS: [&str; 3] = ["brushes", "customShapes", "patterns"];
|
||
|
||
fn preset_names(s: &Session, kind: &str) -> Option<Vec<String>> {
|
||
Some(match kind {
|
||
"brushes" => s.tools.presets.iter().map(|b| b.name.clone()).collect(),
|
||
"customShapes" => s.edit_state.custom_shapes.iter().map(|c| c.name.clone()).collect(),
|
||
"patterns" => s.patterns.items.iter().map(|p| p.name.clone()).collect(),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
fn preset_index(s: &Session, kind: &str, p: &Value) -> Result<usize> {
|
||
let names = preset_names(s, kind).ok_or_else(|| bad("edit.presets.presetManager", format!("unknown preset kind `{kind}` ({})", PRESET_KINDS.join("|"))))?;
|
||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||
return if (i as usize) < names.len() { Ok(i as usize) } else { Err(bad("edit.presets.presetManager", format!("no preset {i}"))) };
|
||
}
|
||
let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("edit.presets.presetManager", "give `index` or `name`"))?;
|
||
names.iter().position(|n| n == name).ok_or_else(|| bad("edit.presets.presetManager", format!("no preset named `{name}`")))
|
||
}
|
||
|
||
fn preset_manager(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "edit.presets.presetManager";
|
||
let action = str_or(p, "action", "list");
|
||
if action == "list" {
|
||
let kinds: Vec<&str> = match p.get("kind").and_then(Value::as_str) {
|
||
Some(k) => vec![k],
|
||
None => PRESET_KINDS.to_vec(),
|
||
};
|
||
let mut out = serde_json::Map::new();
|
||
for k in kinds {
|
||
out.insert(k.to_string(), json!(preset_names(s, k).ok_or_else(|| bad(cmd, format!("unknown preset kind `{k}`")))?));
|
||
}
|
||
return Ok(Value::Object(out));
|
||
}
|
||
let kind = p.get("kind").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `kind`"))?.to_string();
|
||
let i = preset_index(s, &kind, p)?;
|
||
match action {
|
||
"rename" => {
|
||
let new =
|
||
p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string();
|
||
match kind.as_str() {
|
||
// Same rules as Brush Settings: no duplicate names (case-insensitive), the current
|
||
// preset follows, a renamed built-in is the user's preset now (and persists as one).
|
||
"brushes" => crate::brush_preset_cmds::rename_at(s, i, &new, cmd)?,
|
||
"patterns" => s.patterns.items[i].name = new,
|
||
_ => s.edit_state.custom_shapes[i].name = new,
|
||
}
|
||
}
|
||
"delete" => match kind.as_str() {
|
||
"brushes" => {
|
||
let gone = s.tools.presets.remove(i);
|
||
crate::brush_cmds::forget_deleted_preset(s, &gone.name);
|
||
}
|
||
"patterns" => drop(s.patterns.items.remove(i)),
|
||
_ => drop(s.edit_state.custom_shapes.remove(i)),
|
||
},
|
||
"move" => {
|
||
let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to`"))? as usize;
|
||
match kind.as_str() {
|
||
"brushes" => crate::move_item(&mut s.tools.presets, i, to),
|
||
"patterns" => crate::move_item(&mut s.patterns.items, i, to),
|
||
_ => crate::move_item(&mut s.edit_state.custom_shapes, i, to),
|
||
};
|
||
}
|
||
other => return Err(bad(cmd, format!("unknown action `{other}` (list|rename|delete|move)"))),
|
||
}
|
||
if kind == "brushes" {
|
||
s.brush_presets_changed();
|
||
}
|
||
Ok(json!({kind.clone(): preset_names(s, &kind)}))
|
||
}
|
||
|
||
/// Exported preset file (`.pcpresets`, JSON).
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct PresetFile {
|
||
pub format: String,
|
||
pub version: u32,
|
||
pub brushes: Vec<photocraft_paint::BrushPreset>,
|
||
pub custom_shapes: Vec<CustomShape>,
|
||
pub tool_presets: Vec<crate::presets::tools::ToolPreset>,
|
||
}
|
||
|
||
pub const PRESET_FORMAT: &str = "photocraft-presets";
|
||
|
||
fn export_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "edit.presets.exportImportPresets";
|
||
let kinds: Vec<String> = p
|
||
.get("kinds")
|
||
.and_then(Value::as_array)
|
||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||
.unwrap_or_else(|| ["brushes", "customShapes", "tools"].iter().map(|k| k.to_string()).collect());
|
||
let want = |k: &str| kinds.iter().any(|x| x == k);
|
||
match str_or(p, "action", "export") {
|
||
"export" => {
|
||
let file = PresetFile {
|
||
format: PRESET_FORMAT.into(),
|
||
version: 1,
|
||
brushes: if want("brushes") {
|
||
let mut out: Vec<photocraft_paint::BrushPreset> =
|
||
s.tools.presets.iter().filter(|b| !b.builtin || bool_or(p, "includeBuiltins", false)).cloned().collect();
|
||
// The file embeds every bitmap: load the tips the preset store keeps (#1843).
|
||
for b in &mut out {
|
||
s.load_brush_tips(&mut b.brush).map_err(|e| bad(cmd, format!("brush preset `{}`: {e}", b.name)))?;
|
||
}
|
||
out
|
||
} else {
|
||
Vec::new()
|
||
},
|
||
custom_shapes: if want("customShapes") { s.edit_state.custom_shapes.clone() } else { Vec::new() },
|
||
tool_presets: if want("tools") {
|
||
let builtins = crate::presets::tools::builtin();
|
||
s.presets.tool_presets.iter().filter(|preset| bool_or(p, "includeBuiltins", false) || !builtins.contains(preset)).cloned().collect()
|
||
} else {
|
||
Vec::new()
|
||
},
|
||
};
|
||
Ok(json!({"data": file, "brushes": file.brushes.len(), "customShapes": file.custom_shapes.len(), "tools": file.tool_presets.len()}))
|
||
}
|
||
"import" => {
|
||
let data = p.get("data").cloned().ok_or_else(|| bad(cmd, "missing `data` (an exported preset file)"))?;
|
||
let data = match data {
|
||
Value::String(text) => serde_json::from_str(&text).map_err(|e| bad(cmd, format!("not a preset file: {e}")))?,
|
||
v => v,
|
||
};
|
||
let file: PresetFile = serde_json::from_value(data).map_err(|e| bad(cmd, format!("not a preset file: {e}")))?;
|
||
if file.format != PRESET_FORMAT {
|
||
return Err(bad(cmd, format!("not a preset file (format `{}`)", file.format)));
|
||
}
|
||
// Validate the whole tool import before changing any preset library.
|
||
let mut tools = s.presets.tool_presets.clone();
|
||
let nt = if want("tools") { file.tool_presets.len() } else { 0 };
|
||
if nt > crate::presets::tools::MAX_PRESETS {
|
||
return Err(bad(cmd, "too many tool presets"));
|
||
}
|
||
if want("tools") {
|
||
for mut preset in file.tool_presets {
|
||
preset.name = crate::presets::tools::checked_name(&preset.name, cmd)?;
|
||
if preset.tool.trim().is_empty() || !preset.options.is_object() {
|
||
return Err(bad(cmd, "tool presets require a non-empty tool and an options object"));
|
||
}
|
||
if let Some(old) = tools.iter_mut().find(|old| old.name == preset.name && old.tool.eq_ignore_ascii_case(&preset.tool)) {
|
||
*old = preset;
|
||
} else if tools.iter().any(|old| old.name == preset.name) {
|
||
return Err(bad(cmd, "a preset of that name belongs to another tool; rename it before importing"));
|
||
} else if tools.len() < crate::presets::tools::MAX_PRESETS {
|
||
tools.push(preset);
|
||
} else {
|
||
return Err(bad(cmd, "too many tool presets"));
|
||
}
|
||
}
|
||
}
|
||
let (mut nb, mut ns) = (0, 0);
|
||
if want("brushes") {
|
||
for mut b in file.brushes {
|
||
// Nested folders as the commands bound them (a hand-edited file can't build an
|
||
// absurd tree).
|
||
b.folder.retain(|f| !f.trim().is_empty());
|
||
b.folder.truncate(crate::brush_preset_cmds::MAX_FOLDER_DEPTH);
|
||
match s.tools.presets.iter_mut().find(|x| x.name == b.name) {
|
||
Some(x) => *x = b,
|
||
None => s.tools.presets.push(b),
|
||
}
|
||
nb += 1;
|
||
}
|
||
s.brush_presets_changed();
|
||
}
|
||
if want("customShapes") {
|
||
for c in file.custom_shapes {
|
||
match s.edit_state.custom_shapes.iter_mut().find(|x| x.name == c.name) {
|
||
Some(x) => *x = c,
|
||
None => s.edit_state.custom_shapes.push(c),
|
||
}
|
||
ns += 1;
|
||
}
|
||
}
|
||
if want("tools") {
|
||
s.presets.tool_presets = tools;
|
||
s.presets_changed();
|
||
}
|
||
Ok(json!({"brushes": nb, "customShapes": ns, "tools": nt}))
|
||
}
|
||
other => Err(bad(cmd, format!("unknown action `{other}` (export|import)"))),
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ specs
|
||
|
||
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!(
|
||
"edit.fade",
|
||
"Fade…",
|
||
["Edit"],
|
||
Some("Cmd+Shift+F"),
|
||
r##"{"opacity":0..100=100,"mode":"normal|multiply|screen|overlay|softLight|hardLight|darken|lighten|difference|color|luminosity"}"##,
|
||
can_fade,
|
||
fade
|
||
),
|
||
spec!("edit.purge.undo", "Undo", ["Edit", "Purge"], None, "{}", can_purge_undo, |s, _| purge(s, "undo")),
|
||
spec!("edit.purge.clipboard", "Clipboard", ["Edit", "Purge"], None, "{}", can_purge_clipboard, |s, _| purge(s, "clipboard")),
|
||
spec!("edit.purge.histories", "Histories", ["Edit", "Purge"], None, "{}", can_purge_histories, |s, _| purge(s, "histories")),
|
||
spec!("edit.purge.videoCache", "Video Cache", ["Edit", "Purge"], None, "{}", always, |_, _| Ok(
|
||
json!({"purged": [], "bytes": 0, "message": "nothing to purge (no video layers)"})
|
||
)),
|
||
spec!("edit.purge.all", "All", ["Edit", "Purge"], None, "{}", can_purge_all, |s, _| purge(s, "all")),
|
||
spec!(
|
||
"edit.contentAwareFill",
|
||
"Content-Aware Fill…",
|
||
["Edit"],
|
||
None,
|
||
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##,
|
||
can_caf,
|
||
content_aware_fill
|
||
),
|
||
spec!("edit.deleteAndFillSelection", "Delete and Fill Selection", [], None, "{}", can_caf, delete_and_fill),
|
||
spec!(
|
||
"edit.contentAwareScale",
|
||
"Content-Aware Scale",
|
||
["Edit"],
|
||
Some("Cmd+Alt+Shift+C"),
|
||
r##"{"width":px?,"height":px?,"scaleX":1..400=100,"scaleY":1..400=100,"x":px?,"y":px?,"amount":0..100=100,"protect":"none"|channel index|name,"protectSkinTones":bool=false}"##,
|
||
|s| pixel_layer(s).map(|_| ()),
|
||
content_aware_scale
|
||
),
|
||
spec!(
|
||
"edit.defineBrushPreset",
|
||
"Define Brush Preset…",
|
||
["Edit"],
|
||
None,
|
||
r##"{"name":text}"##,
|
||
|s| if s.is_enabled("brush.defineFromSelection") { Ok(()) } else { Err("select pixels to define a brush from".into()) },
|
||
define_brush_preset
|
||
),
|
||
spec!(
|
||
"edit.defineCustomShape",
|
||
"Define Custom Shape…",
|
||
["Edit"],
|
||
None,
|
||
r##"{"name":text,"path":"work|<saved path name>"?}"##,
|
||
can_define_shape,
|
||
define_custom_shape
|
||
),
|
||
spec!(
|
||
"edit.findAndReplaceText",
|
||
"Find and Replace Text…",
|
||
["Edit"],
|
||
None,
|
||
r##"{"find":text,"replace":text,"action":"changeAll|find|change|changeFind","caseSensitive":bool=false,"wholeWord":bool=false,"forward":bool=true,"allLayers":bool=true}"##,
|
||
has_type_layer,
|
||
find_replace
|
||
),
|
||
spec!(
|
||
"edit.presets.presetManager",
|
||
"Preset Manager…",
|
||
["Edit", "Presets"],
|
||
None,
|
||
r##"{"action":"list|rename|delete|move","kind":"brushes|customShapes|patterns","index":n?,"name":str?,"newName":str?,"to":n?}"##,
|
||
always,
|
||
preset_manager
|
||
),
|
||
spec!(
|
||
"edit.presets.exportImportPresets",
|
||
"Export/Import Presets…",
|
||
["Edit", "Presets"],
|
||
None,
|
||
r##"{"action":"export|import","kinds":["brushes","customShapes","tools"]?,"data":json (import),"includeBuiltins":bool=false}"##,
|
||
always,
|
||
export_import
|
||
),
|
||
]
|
||
}
|
||
|
||
/// The custom shape library (for the Custom Shape tool).
|
||
pub fn custom_shapes(s: &Session) -> &[CustomShape] {
|
||
&s.edit_state.custom_shapes
|
||
}
|
||
|
||
#[cfg(test)]
|
||
#[path = "edit_menu_cmds/tests.rs"]
|
||
mod tests;
|