diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index f4bfccb0b5..0901dab0c0 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -35,7 +35,9 @@ pub struct RefineParams { pub feather: f32, /// 0–100. pub contrast: f32, - /// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole + /// −100–100. 100% moves the edge by its soft width: `max(radius, feather, 2 × smooth σ)`, + /// at least 1 px, so a hard edge moves a pixel (Photoshop's Shift Edge moves soft borders; + /// a band of several pixels around hard line art fills it in solid). Values between whole /// pixels blend the two neighbouring shifts, so the slider does not jump. pub shift_edge: f32, } @@ -60,9 +62,11 @@ impl RefineParams { fn feather_sigma(&self) -> f32 { self.feather.max(0.0) / 2.0 } - /// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64. + /// Signed shift in pixels, fractional. 100% is the edge's soft width (at least 1 px), + /// clamped to ±64. fn shift_amount(&self) -> f32 { - let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0); + let soft = self.radius.max(self.feather).max(2.0 * self.smooth_sigma()).max(1.0); + let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * soft; if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 } } /// How many pixels the mask can move: the farther of the two integer radii a fractional @@ -765,22 +769,24 @@ mod tests { assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1); let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() }); assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0); + // A hard edge (no radius, feather or smooth) moves 1 px at 100%, so 50% is half a + // pixel. The pixel just outside fades in instead of snapping on. let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() }); - assert_eq!(grown[5 * w + 33], 1.0); - assert_eq!(grown[5 * w + 34], 0.0); - let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() }); - assert_eq!(shrunk[5 * w + 27], 1.0); - assert_eq!(shrunk[5 * w + 28], 0.0); - // Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel. - // The pixel just outside a hard edge fades in instead of snapping on. - let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() }); - let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() }); assert_eq!(m[5 * w + 30], 0.0); + assert_eq!(grown[5 * w + 30], 1.0); + assert_eq!(grown[5 * w + 31], 0.0); + let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -100.0, ..Default::default() }); + assert_eq!(shrunk[5 * w + 28], 1.0); + assert_eq!(shrunk[5 * w + 29], 0.0); + let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 50.0, ..Default::default() }); assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]); - assert_eq!(one[5 * w + 30], 1.0); assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel"); - let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() }); + let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() }); assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]); + // A softer edge moves farther: 100% is the feather, here 4 px. + let wide = refine_buffer(None, &m, w, h, &RefineParams { feather: 4.0, contrast: 100.0, shift_edge: 100.0, ..Default::default() }); + assert_eq!(wide[5 * w + 33], 1.0); + assert_eq!(wide[5 * w + 34], 0.0); let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() }); assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0); } diff --git a/crates/engine/src/cmd/actions_cmds.rs b/crates/engine/src/cmd/actions_cmds.rs index 010af60ec9..4bc6dcf722 100644 --- a/crates/engine/src/cmd/actions_cmds.rs +++ b/crates/engine/src/cmd/actions_cmds.rs @@ -496,6 +496,11 @@ fn noop_step(s: &Session, id: &str) -> bool { id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none()) } +pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents"; +pub fn saves_smart_contents(s: &Session, id: &str) -> bool { + id == "file.save" && s.is_enabled(SAVE_CONTENTS) +} + fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option) { let mut ran = 0u64; let mut failed = None; @@ -514,7 +519,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option { if let Some(error) = nested_failure(id, &value) { failed = Some(json!({"step": i, "id": id, "error": error})); diff --git a/crates/engine/src/cmd/actions_cmds/tests.rs b/crates/engine/src/cmd/actions_cmds/tests.rs index 674c28d78c..d8350b0f82 100644 --- a/crates/engine/src/cmd/actions_cmds/tests.rs +++ b/crates/engine/src/cmd/actions_cmds/tests.rs @@ -5,6 +5,30 @@ fn pixel(s: &Session, layer: usize, x: i32, y: i32) -> Vec { s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y) } +/// CopyCat's shape: Edit Contents, change the contents, Save. The Save step has no file to +/// write: it updates the smart object, headless too. +#[test] +fn save_in_edit_contents_updates_the_smart_object() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("layer.smartObjects.editContents", json!({})).unwrap(); + let steps = vec![("edit.fill".to_string(), json!({"contents": "color", "color": "#0000ff"})), ("file.save".to_string(), json!({}))]; + s.actions.list.push(Action::new("Edit and save", steps)); + let played = s.execute("actions.play", json!({"action": "Edit and save"})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + s.set_active(0); + let doc = &s.active().unwrap().doc; + let flat = crate::file_cmds::flattened(doc, doc.pixel_format()); + let p = flat.pixel(2, 2); + assert!(p[0] < 0.01 && p[2] > 0.99, "the smart object shows the saved contents: {p:?}"); + // With no Edit Contents document, Save stays the shell's (it needs the document's file). + s.actions.list.push(Action::new("Save", vec![("file.save".to_string(), json!({}))])); + assert!(s.execute("actions.play", json!({"action": "Save"})).unwrap().get("failed").is_some()); +} + #[test] fn deselect_with_nothing_selected_is_a_noop_in_playback() { // #6744: imported actions often start with Deselect; with no selection Photoshop plays on. diff --git a/crates/engine/src/cmd/atn_cmds/events.rs b/crates/engine/src/cmd/atn_cmds/events.rs index 3760e6916e..5723c93c59 100644 --- a/crates/engine/src/cmd/atn_cmds/events.rs +++ b/crates/engine/src/cmd/atn_cmds/events.rs @@ -1,3 +1,4 @@ +const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")]; use super::adjustments::{adjustment_by_class, adjustment_steps, levels_from, levels_to, shadows_highlights_from, shadows_highlights_to}; use super::descriptors::{desc, get_bool, get_ref, jbool, layer_ref, layer_target, ref_class}; use super::fields::{F, Kind, Mapped, fields_from, fields_to, none, step}; @@ -14,7 +15,7 @@ use super::steps_layer::{ STROKE, apply_comp_from, apply_comp_to, clear_from, clear_to, delete_from, delete_to, duplicate_from, duplicate_to, fill_from, fill_to, flip_from, flip_to, move_from, move_to, play_from, play_to, rotate_from, rotate_to, select_from, select_to, stroke_to, visibility_from, visibility_to, }; -use super::steps_make::{make_from, make_to, set_from, set_to}; +use super::steps_make::{locking_from, locking_to, make_from, make_to, set_from, set_to}; use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM}; use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json}; @@ -248,6 +249,7 @@ pub(super) const EVENTS: &[Event] = &[ ), ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::ExactLong), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::ExactLong)]), + ev!("Dfs ", "diffuse", "Diffuse", "filter.stylize.diffuse", &[F("Md ", "mode", Kind::Enum("DfsM", DIFFUSE_MODE)), F("FlRs", "seed", Kind::Long)]), Event { code: "GEfc", name: "filterGallery", label: "Filter Gallery", ids: GALLERY_IDS, from: gallery_from, to: gallery_to }, // Adjustments (Image › Adjustments). Event { @@ -385,6 +387,7 @@ pub(super) const EVENTS: &[Event] = &[ "layer.layerMask.hideSelection", "file.new", "file.newFromClipboard", + "history.newSnapshot", ], from: make_from, to: make_to, @@ -408,6 +411,8 @@ pub(super) const EVENTS: &[Event] = &[ "layer.setAdjustment", "layer.fill.pattern.set", "layer.new.layerFromBackground", + "layer.layerStyle.blendingOptions", + "layer.smartFilter.blendingOptions", ], from: set_from, to: set_to, @@ -436,8 +441,25 @@ pub(super) const EVENTS: &[Event] = &[ from: select_from, to: select_to, }, - Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to }, - Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to }, + Event { + code: "Dlt ", + name: "delete", + label: "Delete", + ids: &["layer.delete", "edit.clear", "layer.layerMask.delete", "layer.smartFilter.deleteFilterMask"], + from: delete_from, + to: delete_to, + }, + Event { code: "", name: "applyLocking", label: "Apply Locking", ids: &["layer.lockLayers"], from: locking_from, to: locking_to }, + // Timeline › Make Frames from Layers: no frame animation in PhotoCraft, no document change. + Event { + code: "", + name: "animationFramesFromLayers", + label: "Make Frames from Layers", + ids: &[], + from: |_| Mapped::Unsupported { harmless: true }, + to: |_, _| None, + }, + Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode", "history.clear"], from: clear_from, to: clear_to }, Event { code: "Trnf", name: "transform", diff --git a/crates/engine/src/cmd/atn_cmds/steps_image.rs b/crates/engine/src/cmd/atn_cmds/steps_image.rs index a1b808bf34..a52b9fbf5d 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_image.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_image.rs @@ -286,6 +286,8 @@ const REFINE_OUTPUT: &[(&str, &str)] = &[ ("refineEdgeOutputNewLayerWithMask", "newLayerWithMask"), ("refineEdgeOutputNewDocument", "newDocument"), ("refineEdgeOutputNewDocumentWithMask", "newDocumentWithMask"), + // Newer Photoshop's name for Output To: Selection. + ("selectionOutputToSelection", "selection"), ]; /// Filter Gallery effects (`GEfc` with the effect in `GEfk`): Photoshop's effect code, the diff --git a/crates/engine/src/cmd/atn_cmds/steps_layer.rs b/crates/engine/src/cmd/atn_cmds/steps_layer.rs index 9dbdad579b..c69595e566 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_layer.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_layer.rs @@ -117,6 +117,15 @@ pub(super) fn delete_from(d: &Descriptor) -> Mapped { // The targeted channel of a layer is its mask (what Photoshop records after making an // adjustment layer over a selection and discarding the mask). Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"), + // The mask Photoshop adds to a new fill or adjustment layer, or to a smart object's filters. + Some(r) if ref_class(r).as_deref() == Some("Chnl") => match target(r) { + Target::Ordinal(o) if o == "Msk" => none("layer.layerMask.delete"), + // Photoshop adds an empty filter mask with the first smart filter; PhotoCraft doesn't. + Target::Ordinal(o) if o == "filterMask" => step("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), + _ => Mapped::Unsupported { harmless: false }, + }, + // Timeline frames: PhotoCraft has no frame animation, so there is nothing to delete. + Some(r) if ref_class(r).as_deref() == Some("animationClass") => Mapped::Unsupported { harmless: true }, Some(_) => Mapped::Unsupported { harmless: false }, } } @@ -131,6 +140,8 @@ pub(super) fn quick_mask_ref() -> D { pub(super) fn clear_from(d: &Descriptor) -> Mapped { match get_ref(d, "null").map(target) { Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})), + // History panel menu › Clear History. + Some(Target::Property(p)) if p == "HsSt" => none("history.clear"), None => none("edit.clear"), _ => Mapped::Unsupported { harmless: false }, } @@ -139,6 +150,13 @@ pub(super) fn clear_from(d: &Descriptor) -> Mapped { pub(super) fn clear_to(id: &str, p: &Value) -> Option { match id { "select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())), + "history.clear" => Some(desc("null").with( + "null", + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: code("HsSt") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") }, + ]), + )), _ => None, } } @@ -148,6 +166,8 @@ pub(super) fn delete_to(id: &str, p: &Value) -> Option { "edit.clear" => desc("null"), "layer.delete" => desc("null").with("null", layer_ref(p)?), "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), + "layer.smartFilter.deleteFilterMask" => desc("null") + .with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: Id::new("filterMask") }])), _ => return None, }) } diff --git a/crates/engine/src/cmd/atn_cmds/steps_make.rs b/crates/engine/src/cmd/atn_cmds/steps_make.rs index 96f9033102..f25e7f975b 100644 --- a/crates/engine/src/cmd/atn_cmds/steps_make.rs +++ b/crates/engine/src/cmd/atn_cmds/steps_make.rs @@ -136,6 +136,9 @@ fn new_document_from_descriptor(d: &Descriptor) -> Option { Some(step("file.new", params)) } +/// New Snapshot's From: the full document, the layers merged, or the current layer. +const SNAPSHOT_FROM: &[(&str, &str)] = &[("FllD", "fullDocument"), ("MrgL", "mergedLayers"), ("CrnL", "currentLayer")]; + pub(super) fn make_from(d: &Descriptor) -> Mapped { if let Some(mapped) = duplicate_layer_from_descriptor(d) { return mapped; @@ -198,6 +201,24 @@ pub(super) fn make_from(d: &Descriptor) -> Mapped { None => step("layer.new.group", Value::Object(p)), } } + // History panel › New Snapshot (of the current state). + Some("SnpS") => { + let mut p = Map::new(); + if let Some(n) = get_text(d, "Nm ") { + p.insert("name".into(), n.into()); + } + match get_enum(d, "Usng").as_deref().map(|u| lookup(SNAPSHOT_FROM, u)) { + None => {} + Some(Some(f)) => { + p.insert("from".into(), f.into()); + } + Some(None) => return Mapped::Unsupported { harmless: false }, + } + if get_bool(d, "replaceExisting") == Some(true) { + p.insert("replaceExisting".into(), true.into()); + } + step("history.newSnapshot", Value::Object(p)) + } _ => Mapped::Unsupported { harmless: false }, } } @@ -286,6 +307,18 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option { desc("null").with("Nw ", D::Class(class("Chnl"))).with("At ", at).with("Usng", enm("UsrM", lookup_back(MASK_USING, id)?)) } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), + "history.newSnapshot" => { + let current = D::Reference(vec![ReferenceItem::Property { class: class("HstS"), key: code("CrnH") }]); + let mut d = desc("null").with("null", class_ref("SnpS")).with("From", current); + if let Some(n) = jstr(p, "name") { + d = d.with("Nm ", text(n)); + } + d = d.with("Usng", enm("HstS", lookup_back(SNAPSHOT_FROM, jstr(p, "from").unwrap_or("fullDocument"))?)); + if jbool(p, "replaceExisting") == Some(true) { + d = d.with("replaceExisting", D::Boolean(true)); + } + d + } "file.new" => { let mut n = desc("Dcmn"); if let Some(name) = jstr(p, "name") { @@ -401,6 +434,56 @@ fn background_from(d: &Descriptor) -> Mapped { steps(v) } +/// The Layers panel lock buttons (`layerLocking`) ↔ `layer.lockLayers` params. +const LOCKS: &[(&str, &str)] = &[ + ("protectTransparency", "transparency"), + ("protectComposite", "pixels"), + ("protectPosition", "position"), + ("protectArtboardAutonest", "artboard"), + ("protectAll", "all"), +]; + +/// Blending Options' Advanced checkboxes ↔ `layer.layerStyle.blendingOptions` params. +const ADVANCED_BLENDING: &[(&str, &str)] = &[("blendClipped", "blendClippedLayersAsGroup"), ("transparencyShapesLayer", "transparencyShapesLayer")]; + +fn lock_params(locks: &Descriptor) -> Option> { + let mut m = Map::new(); + // "Unlock all" clears every lock. + if get_bool(locks, "protectNone") == Some(true) { + for (_, param) in LOCKS { + m.insert((*param).into(), false.into()); + } + } + for (key, param) in LOCKS { + if let Some(b) = get_bool(locks, key) { + m.insert((*param).into(), b.into()); + } + } + (!m.is_empty()).then_some(m) +} + +/// `applyLocking`: a lock button in the Layers panel, on the target layer. +pub(super) fn locking_from(d: &Descriptor) -> Mapped { + let on_target = get_ref(d, "null").is_none_or(|r| ref_class(r).as_deref() == Some("Lyr") && matches!(target(r), Target::Ordinal(o) if o == "Trgt")); + match get_obj(d, "layerLocking").and_then(lock_params) { + Some(l) if on_target => step("layer.lockLayers", Value::Object(l)), + _ => Mapped::Unsupported { harmless: false }, + } +} + +pub(super) fn locking_to(_: &str, p: &Value) -> Option { + let mut locks = desc("layerLocking"); + for (key, param) in LOCKS { + if let Some(b) = jbool(p, param) { + locks = locks.with(key, D::Boolean(b)); + } + } + if locks.items.is_empty() { + return None; + } + Some(desc("null").with("null", target_ref("Lyr ")).with("layerLocking", D::Descriptor(locks))) +} + pub(super) fn set_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; let unsupported = Mapped::Unsupported { harmless: false }; @@ -441,10 +524,65 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped { if let Some(vis) = get_bool(t, "Vsbl") { p.insert("visible".into(), vis.into()); } + // Lock buttons and Blending Options' Advanced checkboxes act on the target layer. + let targeted = !p.contains_key("layer"); + if let Some(locks) = get_obj(t, "layerLocking") { + match lock_params(locks) { + Some(l) if targeted => v.push(("layer.lockLayers", Value::Object(l))), + _ => return unsupported, + } + } + let mut advanced = Map::new(); + for (key, param) in ADVANCED_BLENDING { + if let Some(b) = get_bool(t, key) { + advanced.insert((*param).into(), b.into()); + } + } + if !advanced.is_empty() { + if !targeted { + return unsupported; + } + v.push(("layer.layerStyle.blendingOptions", Value::Object(advanced))); + } if p.keys().any(|k| k != "layer") { v.push(("layer.setProps", Value::Object(p))); } - if v.is_empty() { unsupported } else { steps(v) } + // The Layer Style dialog's OK on a layer without effects records only the effects' + // scale (`Lefx` = {Scl}); an empty `T` is an OK that changed nothing. Neither changes + // the document. + let no_effect = t.items.iter().all(|(k, val)| match val { + D::Descriptor(fx) if k.is("Lefx") => fx.items.iter().all(|(fk, _)| fk.is("Scl ")), + _ => false, + }); + if !v.is_empty() { + steps(v) + } else if no_effect { + Mapped::Unsupported { harmless: true } + } else { + unsupported + } + } + // Layer › Smart Filter › Blending Options (`indx` counts the filters from 1, bottom first). + (Some("filterFX"), _) => { + let index = match r.first() { + Some(ReferenceItem::Index { index, .. }) if *index >= 1 => index - 1, + _ => return unsupported, + }; + let on_target = + matches!(r.get(1..), Some(rest) if ref_class(rest).as_deref() == Some("Lyr") && matches!(target(rest), Target::Ordinal(o) if o == "Trgt")); + if !on_target { + return unsupported; + } + let Some(opts) = get_obj(d, "filterFX").and_then(|f| get_obj(f, "blendOptions")) else { return unsupported }; + let mut p = json!({"index": index}); + if let Some(o) = get_num(opts, "Opct") { + p["opacity"] = json!(o / 100.0); + } + if let Some(m) = get_enum(opts, "Md ") { + let Some(label) = blend_label(&m) else { return unsupported }; + p["blend"] = json!(label); + } + step("layer.smartFilter.blendingOptions", p) } // A Pattern Fill layer's pattern (double-click its thumbnail). (Some("contentLayer"), Target::Ordinal(o)) if o == "Trgt" => match get_obj(d, "T ") { @@ -578,6 +716,39 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option { } desc("null").with("null", target_ref("contentLayer")).with("T ", D::Descriptor(pattern_desc(p)?)) } + "layer.layerStyle.blendingOptions" => { + let o = p.as_object()?; + let mut t = desc("Lyr "); + for (key, param) in ADVANCED_BLENDING { + if let Some(b) = jbool(p, param) { + t = t.with(key, D::Boolean(b)); + } + } + // Only the Advanced checkboxes export: Photoshop records the dialog's other options + // differently. + if t.items.is_empty() || t.items.len() != o.len() { + return None; + } + desc("null").with("null", target_ref("Lyr ")).with("T ", D::Descriptor(t)) + } + "layer.smartFilter.blendingOptions" => { + if p.get("layer").is_some() { + return None; + } + let index = u32::try_from(p.get("index")?.as_u64()?).ok()?.checked_add(1)?; + let mut opts = desc("blendOptions"); + if let Some(o) = jnum(p, "opacity") { + opts = opts.with("Opct", pct(o * 100.0)); + } + if let Some(m) = jstr(p, "blend") { + opts = opts.with("Md ", enm("BlnM", blend_ps(m)?)); + } + let r = D::Reference(vec![ + ReferenceItem::Index { class: class("filterFX"), index }, + ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Trgt") }, + ]); + desc("null").with("null", r).with("filterFX", D::Descriptor(desc("filterFX").with("blendOptions", D::Descriptor(opts)))) + } "layer.setAdjustment" => { // The adjustment kind isn't in the params: Photoshop needs it, so only steps that // name one (`kind`) export. diff --git a/crates/engine/src/cmd/atn_cmds/tests.rs b/crates/engine/src/cmd/atn_cmds/tests.rs index 8fc2f92aff..b45cca5389 100644 --- a/crates/engine/src/cmd/atn_cmds/tests.rs +++ b/crates/engine/src/cmd/atn_cmds/tests.rs @@ -704,6 +704,7 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.undo", json!({})), ("edit.redo", json!({})), ("layer.select", json!({"layer": "YELLOW", "mode": "remove"})), + ("layer.select", json!({"layer": "Or paste photocopy texture here", "mode": "range"})), ("layer.translate", json!({"dx": 0.36, "dy": -1.08, "units": "pt"})), ("select.loadSelection", json!({"channel": "targeted"})), ("layer.newFillLayer.pattern", json!({"pattern": "Debaser-Distressed-Dark", "scale": 50, "angle": 0, "link": true})), @@ -711,6 +712,15 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.bitmap", json!({"method": "threshold"})), ("image.imageSize", json!({"resolution": 1200, "resample": "preserveDetails", "keepPrintSize": true})), ("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false})), + // COPYCAT, AJ_Actions2023 and the Line and Color tutorial set. + ("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})), + ("layer.smartFilter.blendingOptions", json!({"index": 1, "opacity": 0.5, "blend": "Darken"})), + ("layer.layerStyle.blendingOptions", json!({"blendClippedLayersAsGroup": false, "transparencyShapesLayer": false})), + ("layer.createClippingMask", json!({})), + ("layer.lockLayers", json!({"transparency": true})), + ("filter.stylize.diffuse", json!({"mode": "normal", "seed": 321654})), + ("history.clear", json!({})), + ("history.newSnapshot", json!({"name": "CopyCat - action result", "from": "fullDocument", "replaceExisting": true})), ("layerComp.apply", json!({"comp": "Comp 1"})), ]; for (id, params) in &cases { diff --git a/crates/engine/src/cmd/history_cmds.rs b/crates/engine/src/cmd/history_cmds.rs index d8bea8607b..ec6d3d6ead 100644 --- a/crates/engine/src/cmd/history_cmds.rs +++ b/crates/engine/src/cmd/history_cmds.rs @@ -130,6 +130,16 @@ fn name_param(cmd: &str, p: &Value) -> Result> { } } +/// History panel menu › Clear History: drops every state of the active document but its current +/// one, keeping the snapshots (Edit › Purge › Histories clears every open document). Undoable in +/// Photoshop only by its own Undo; here, as with Purge, it is not. +fn clear_history(s: &mut Session, _: &Value) -> Result { + let st = s.active_mut().ok_or(EngineError::NoDocument)?; + st.history.clear(); + st.coalesce = None; + Ok(Value::Null) +} + /// History panel › Create new snapshot (the camera button): a named copy of the document, /// "Snapshot N" by default. `from` is the New Snapshot dialog's From: the full document (default), /// its layers merged into one, or only the active layer. Not a history state. @@ -163,6 +173,10 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result { let name = name.unwrap_or_else(|| next_snapshot_name(st)); let id = st.next_snapshot; st.next_snapshot = id.saturating_add(1); + // An action's New Snapshot with "replace existing": a snapshot of that name gives way. + if p.get("replaceExisting").and_then(Value::as_bool) == Some(true) { + st.snapshots.retain(|snap| snap.name != name); + } st.snapshots.push(Snapshot { id, name: name.clone(), doc, layers }); Ok(json!({ "snapshot": id, "name": name })) } @@ -284,11 +298,21 @@ pub fn specs() -> Vec { label: "New Snapshot", menu: &[], shortcut: None, - params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument"} → {snapshot,name}: a named copy of the document for the session (not a history state)"##, + params: r##"{"name":str?="Snapshot N","from":"fullDocument"|"mergedLayers"|"currentLayer"="fullDocument","replaceExisting":bool=false (drop snapshots of the same name first)} → {snapshot,name}: a named copy of the document for the session (not a history state)"##, enabled: has_doc, run: new_snapshot, journal: true, }, + CommandSpec { + id: "history.clear", + label: "Clear History", + menu: &[], + shortcut: None, + params: "{} (the active document's states; snapshots stay)", + enabled: has_doc, + run: clear_history, + journal: true, + }, CommandSpec { id: "history.selectSnapshot", label: "Select Snapshot", @@ -442,6 +466,24 @@ mod tests { assert_eq!(snapshots(&s), json!([first, {"id": 1, "name": "Snapshot 1"}, {"id": 2, "name": "Before green"}])); } + #[test] + fn replace_existing_snapshot_and_clear_history() { + let mut s = session(); + s.execute("history.newSnapshot", json!({"name": "Result"})).unwrap(); + s.execute("history.newSnapshot", json!({"name": "Other"})).unwrap(); + s.execute("history.newSnapshot", json!({"name": "Result", "replaceExisting": true})).unwrap(); + assert_eq!(snapshots(&s), json!([{"id": 2, "name": "Other"}, {"id": 3, "name": "Result"}])); + let pixel = top_pixel(&s); + s.execute("history.clear", json!({})).unwrap(); + assert_eq!(entries(&s).len(), 1, "only the current state is left"); + assert!(!s.active().unwrap().history.can_undo()); + assert_eq!(top_pixel(&s), pixel, "the document is unchanged"); + assert_eq!(snapshots(&s).as_array().map(Vec::len), Some(2), "snapshots stay"); + // Nothing left to clear is not an error (an action may clear twice). + s.execute("history.clear", json!({})).unwrap(); + assert!(Session::new().execute("history.clear", json!({})).is_err(), "no document"); + } + #[test] fn select_snapshot_returns_to_it_as_one_undoable_state() { let mut s = session(); diff --git a/crates/engine/src/cmd/ops.rs b/crates/engine/src/cmd/ops.rs index c106af6aac..cf4cd9b0cc 100644 --- a/crates/engine/src/cmd/ops.rs +++ b/crates/engine/src/cmd/ops.rs @@ -2,9 +2,13 @@ use super::enabled::check_all_unlocked; use super::layer_params::{layer_param, mask_at_depth}; use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels}; +/// Bring to Front / Send to Back of a layer already there: nothing to do, which isn't an error +/// (a Photoshop action plays it without complaint). +const ALREADY_THERE: &str = "the layer is already at the front or back"; + pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { let id = layer_param(s, p)?; - s.edit("Arrange", |doc, _| { + let edited = s.edit("Arrange", |doc, _| { // The Background stays at the bottom: it doesn't move and nothing goes below it. let bg = crate::background_cmds::background_id(doc); if bg == Some(id) { @@ -29,12 +33,16 @@ pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result { moved = true; } if !moved { - return Err(EngineError::Other("layer can't move further".into())); + let end = matches!(delta, i32::MAX | i32::MIN); + return Err(EngineError::Other(if end { ALREADY_THERE } else { "layer can't move further" }.into())); } crate::clip_group::settle(doc, &[id], &before, false); Ok(()) - })?; - Ok(Value::Null) + }); + match edited { + Err(EngineError::Other(m)) if m == ALREADY_THERE => Ok(json!({"moved": false})), + r => r.map(|_| Value::Null), + } } /// Add (`Some`) or remove (`None`) a layer mask. Adding acts on every selected layer that can take diff --git a/crates/engine/src/cmd/pattern_cmds.rs b/crates/engine/src/cmd/pattern_cmds.rs index 4bc972556a..265e29b48a 100644 --- a/crates/engine/src/cmd/pattern_cmds.rs +++ b/crates/engine/src/cmd/pattern_cmds.rs @@ -25,17 +25,21 @@ pub struct PatternLibrary { /// Bumped when a pattern's pixels are replaced under the same id (an imported `.pat` whose /// id is already in the library), which its id, name and size don't show. pub pixels_rev: u64, + /// Saved patterns held as a small preview until used, by id: their full size. The preset + /// store reads the pixels ([`full`]); everything that paints goes through [`resolve`]. + pub stored: std::collections::HashMap, } impl Default for PatternLibrary { fn default() -> Self { - PatternLibrary { items: builtin(), pixels_rev: 0 } + PatternLibrary { items: builtin(), pixels_rev: 0, stored: Default::default() } } } impl PatternLibrary { /// Add `pat`, or replace the pattern with its id (marking the pixels changed). pub fn upsert(&mut self, pat: Pattern) { + self.stored.remove(&pat.id); match self.items.iter_mut().find(|q| q.id == pat.id) { Some(q) => { *q = pat; @@ -132,24 +136,59 @@ fn has_pattern(s: &Session) -> std::result::Result<(), String> { if s.patterns.items.is_empty() && s.active().is_none_or(|d| d.doc.patterns.is_empty()) { Err("no patterns".into()) } else { Ok(()) } } -/// Finds a pattern by id or name: the active document's first, then the library. An empty key -/// picks the first library pattern (Photoshop's default). -pub fn resolve(s: &Session, key: &str) -> Option { - if key.is_empty() { - // The Patterns panel's selection, else the first library pattern. - return crate::presets::patterns::current(s).or(s.patterns.items.first()).cloned(); +/// `p` with its full pixels: a saved pattern held as a preview is read from the preset store. +pub fn full(s: &Session, p: &Pattern) -> std::result::Result { + if !s.patterns.stored.contains_key(&p.id) { + return Ok(p.clone()); } - if let Some(d) = s.active() + let st = s.preset_store.as_ref().ok_or_else(|| format!("pattern \"{}\" is not loaded: no preset store", p.display_name()))?; + let mut f = st.load_pattern(&p.id).map_err(|e| format!("pattern \"{}\" could not be loaded: {e}", p.display_name()))?; + f.name = p.name.clone(); + Ok(f) +} + +/// The library pattern `key` names (the panel's selection or the first for an empty key). +fn library_pattern<'a>(s: &'a Session, key: &str) -> Option<&'a Pattern> { + if key.is_empty() { + return crate::presets::patterns::current(s).or(s.patterns.items.first()); + } + photocraft_doc::pattern::find(&s.patterns.items, key, key) +} + +/// The id of the pattern [`resolve`] finds, without reading a saved pattern's pixels: for checks +/// and lookups that don't paint. +pub fn resolve_id(s: &Session, key: &str) -> Option { + if !key.is_empty() + && let Some(d) = s.active() + && let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key) + { + return Some(p.id.clone()); + } + library_pattern(s, key).map(|p| p.id.clone()) +} + +/// Finds a pattern by id or name: the active document's first, then the library. An empty key +/// picks the first library pattern (Photoshop's default). `None` also when a saved pattern's +/// pixels can't be read (see [`resolve_param`] for the reason). +pub fn resolve(s: &Session, key: &str) -> Option { + if !key.is_empty() + && let Some(d) = s.active() && let Some(p) = photocraft_doc::pattern::find(&d.doc.patterns, key, key) { return Some(p.clone()); } - photocraft_doc::pattern::find(&s.patterns.items, key, key).cloned() + full(s, library_pattern(s, key)?).ok() } pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result { let key = p.get("pattern").and_then(Value::as_str).unwrap_or(""); - resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))) + if let Some(pat) = resolve(s, key) { + return Ok(pat); + } + match library_pattern(s, key).map(|lp| full(s, lp)) { + Some(Err(e)) => Err(bad(cmd, e)), + _ => Err(bad(cmd, format!("no pattern \"{key}\" (see pattern.list)"))), + } } /// Copies `pat` into the document's patterns unless it is already there (by id). @@ -174,10 +213,10 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc } } -fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value { +fn info(p: &Pattern, size: (u32, u32), in_doc: bool, in_library: bool) -> Value { let f = p.surface.format(); json!({ - "id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height, + "id": p.id, "name": p.display_name(), "storedName": p.name, "width": size.0, "height": size.1, "mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha, "inDocument": in_doc, "inLibrary": in_library, }) @@ -185,8 +224,9 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value { fn list(s: &mut Session, _: &Value) -> Result { let doc_pats: Vec = s.active().map(|d| d.doc.patterns.clone()).unwrap_or_default(); - let mut out: Vec = s.patterns.items.iter().map(|p| info(p, doc_pats.iter().any(|d| d.id == p.id), true)).collect(); - out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, true, false))); + let size = |p: &Pattern| s.patterns.stored.get(&p.id).copied().unwrap_or((p.width, p.height)); + let mut out: Vec = s.patterns.items.iter().map(|p| info(p, size(p), doc_pats.iter().any(|d| d.id == p.id), true)).collect(); + out.extend(doc_pats.iter().filter(|d| !s.patterns.items.iter().any(|p| p.id == d.id)).map(|p| info(p, (p.width, p.height), true, false))); Ok(Value::Array(out)) } @@ -250,7 +290,7 @@ fn define(s: &mut Session, p: &Value) -> Result { }; let pat = Pattern::new(name, surf, r.width(), r.height()); let id = pat.id.clone(); - let out = info(&pat, false, true); + let out = info(&pat, (pat.width, pat.height), false, true); if !s.patterns.items.iter().any(|q| q.id == id) { s.patterns.items.push(pat); } @@ -285,6 +325,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { fn delete(s: &mut Session, p: &Value) -> Result { let i = library_index(s, "pattern.delete", p)?; let pat = s.patterns.items.remove(i); + s.patterns.stored.remove(&pat.id); Ok(json!({"deleted": pat.id})) } @@ -300,6 +341,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut ids = Vec::new(); for pat in pats { ids.push(pat.id.clone()); + s.patterns.stored.remove(&pat.id); s.patterns.upsert(pat); } Ok(json!({"imported": ids, "count": ids.len()})) @@ -312,7 +354,7 @@ fn export(s: &mut Session, p: &Value) -> Result { Some(keys) => { keys.iter().filter_map(Value::as_str).map(|k| resolve(s, k).ok_or_else(|| bad(cmd, format!("no pattern \"{k}\"")))).collect::>()? } - None => s.patterns.items.clone(), + None => s.patterns.items.iter().map(|p| full(s, p).map_err(|e| bad(cmd, e))).collect::>()?, }; let bytes = photocraft_io::pattern_map::write_pat(&pats).map_err(EngineError::Other)?; crate::file_cmds::write_file(path, &bytes)?; diff --git a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs index 4499715307..06b5bd17e7 100644 --- a/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/cmd/retouch_cmds/pattern_stamp.rs @@ -21,7 +21,7 @@ const STREAM_PHASE_Y: u64 = 102; pub(super) fn enabled(s: &Session) -> std::result::Result<(), String> { has_pixel_layer(s)?; - if crate::pattern_cmds::resolve(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) } + if crate::pattern_cmds::resolve_id(s, "").is_none() { Err("no pattern selected".into()) } else { Ok(()) } } /// A parsed `paint.patternStamp`: shared by the command and its live preview, so both paint the diff --git a/crates/engine/src/cmd/smart_cmds.rs b/crates/engine/src/cmd/smart_cmds.rs index f19813e3ab..2377e6c041 100644 --- a/crates/engine/src/cmd/smart_cmds.rs +++ b/crates/engine/src/cmd/smart_cmds.rs @@ -102,6 +102,10 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { fn has_smart_filters(s: &Session) -> std::result::Result<(), String> { if active_smart(s)?.smart_filters.is_empty() { Err("the smart object has no smart filters".into()) } else { Ok(()) } } +/// Delete Filter Mask with `ifPresent` needs only a smart object. +pub(crate) fn smart_layer_active(s: &Session) -> std::result::Result<(), String> { + active_smart(s).map(|_| ()) +} fn has_filter_mask(s: &Session) -> std::result::Result<(), String> { active_smart(s)?.filter_mask.as_ref().map(|_| ()).ok_or_else(|| "the smart object has no filter mask".into()) } @@ -265,12 +269,25 @@ pub fn specs() -> Vec { }) } ), - spec!("layer.smartFilter.deleteFilterMask", "Delete Filter Mask", SF, r##"{"layer":id?}"##, has_filter_mask, |s, p| { - edit_filters(s, p, "Delete Filter Mask", |sm| { - sm.filter_mask = None; - Ok(()) - }) - }), + spec!( + "layer.smartFilter.deleteFilterMask", + "Delete Filter Mask", + SF, + r##"{"layer":id?,"ifPresent":bool=false (true: a smart object without a filter mask is left as it is, as a Photoshop action's step needs where Photoshop added the mask by default)}"##, + has_filter_mask, + |s, p| { + if p.get("ifPresent").and_then(Value::as_bool) == Some(true) { + let id = layer_param(s, p)?; + if smart(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?.filter_mask.is_none() { + return Ok(Value::Null); + } + } + edit_filters(s, p, "Delete Filter Mask", |sm| { + sm.filter_mask = None; + Ok(()) + }) + } + ), spec!( "layer.smartFilter.disableFilterMask", "Disable Filter Mask", diff --git a/crates/engine/src/cmd/smart_cmds/tests.rs b/crates/engine/src/cmd/smart_cmds/tests.rs index 594d8ac3dd..47cd460fa3 100644 --- a/crates/engine/src/cmd/smart_cmds/tests.rs +++ b/crates/engine/src/cmd/smart_cmds/tests.rs @@ -420,6 +420,13 @@ fn selection_becomes_the_filter_mask() { s.execute("layer.smartFilter.deleteFilterMask", json!({})).unwrap(); assert!(active_smart(&s).filter_mask.is_none()); assert!(!s.is_enabled("layer.smartFilter.deleteFilterMask")); + // A Photoshop action deletes the mask Photoshop adds by default: with `ifPresent`, no mask + // is no step and no error. A layer that isn't a smart object still refuses. + let steps = s.active().unwrap().history.past_len(); + assert_eq!(s.execute("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})).unwrap(), Value::Null); + assert_eq!(s.active().unwrap().history.past_len(), steps); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(s.execute("layer.smartFilter.deleteFilterMask", json!({"ifPresent": true})).is_err()); } /// #5619: deleting (or dragging away) the last smart filter removes the filter mask with it, so diff --git a/crates/engine/src/cmd/smartselect_cmds/tests.rs b/crates/engine/src/cmd/smartselect_cmds/tests.rs index 7ac28d1e44..085b3b0af5 100644 --- a/crates/engine/src/cmd/smartselect_cmds/tests.rs +++ b/crates/engine/src/cmd/smartselect_cmds/tests.rs @@ -451,8 +451,9 @@ fn refine_edge_outputs_to_a_new_document() { #[test] fn refine_edge_shift_and_contrast() { let mut s = blurred_edge_selected(); + // A hard selection moves 1 px at 100%: no band of several pixels. s.execute("select.refineEdge", json!({"shiftEdge": 100})).unwrap(); - assert!(cov(&s, 42, 20) > 0.99); + assert!(cov(&s, 40, 20) > 0.99 && cov(&s, 41, 20) < 0.01); s.execute("edit.undo", json!({})).unwrap(); s.execute("select.refineEdge", json!({"feather": 8, "contrast": 100})).unwrap(); assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0); diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index d21064815d..3c1b5b195c 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -35,14 +35,17 @@ //! changes asynchronously. Gradient presets, including imported `.grd` groups, are small and persist with //! the preferences document (`presets.gradients`). //! +//! The pattern library (imported `.pat` and defined patterns, not the built-ins) lives there too: +//! `patterns.json` plus one `.pat` file per pattern, see [`patterns`]. +//! //! The Actions list (`actions.json`, see [`crate::actions_cmds`]) lives in the same directory. //! A missing file is an empty list. A corrupt or oversized file is skipped with a warning. //! Headless sessions have no store, so their actions stay in memory. //! -//! So do the patterns the user defined or imported (`patterns.pat`, Photoshop's own pattern file -//! format, #9317): the whole user library in its order, rewritten when its ids, names or order -//! change. The built-in patterns are regenerated on start and never written. Like `actions.json`, -//! a file that fails to load is warned about and never overwritten. +//! Patterns use `patterns.json` and lazily loaded per-pattern assets. On first startup a +//! valid legacy `patterns.pat` is migrated by writing assets before the atomic index commit. +//! Legacy and corrupt data are preserved; an existing index is authoritative even when corrupt. +//! A failed migration leaves the legacy library usable and retries at the next startup. use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Read; @@ -54,6 +57,9 @@ use serde::{Deserialize, Serialize}; use crate::Session; +mod patterns; +pub use patterns::{Loaded as LoadedPattern, MAX_PATTERN_BYTES, PATTERNS_FILE, PREVIEW_SIDE as PATTERN_PREVIEW_SIDE}; + /// Group file extension. pub const GROUP_EXT: &str = "pcbrushes"; /// Tip file extension (inside [`TIPS_DIR`]). @@ -73,7 +79,7 @@ pub const ACTIONS_FILE: &str = "actions.json"; /// Largest Actions file read or written. pub const MAX_ACTIONS_BYTES: u64 = 16 << 20; /// The user's pattern library (`.pat`). -pub const PATTERNS_FILE: &str = "patterns.pat"; +pub const LEGACY_PATTERNS_FILE: &str = "patterns.pat"; /// Largest pattern library read or written. pub const MAX_PATTERNS_BYTES: u64 = 256 << 20; /// The whole store never grows beyond this; further groups are skipped with a warning. @@ -562,13 +568,14 @@ pub struct PresetStore { actions_defaults_version: u32, /// `actions.json` exists but failed to load: left on disk untouched, so it is never written. actions_skipped: bool, - /// [`patterns_key`] of the user patterns last written (or loaded). - patterns_key: u64, - /// `patterns.pat` exists but failed to load: left on disk untouched, so it is never written. - patterns_skipped: bool, + /// The pattern files and index (`patterns.json`). + patterns: patterns::PatternFiles, warnings: Vec, /// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load). cache: Mutex, + /// The stored pattern last read in full, so painting with it again doesn't re-read its file + /// (a texture can be tens of megabytes). One: the pattern in use. + pattern_cache: Mutex>, } /// A loaded store: the presets on disk plus what the session should hide. @@ -584,8 +591,8 @@ pub struct Opened { pub warnings: Vec, /// Recorded actions (`actions.json`). Empty when the file is missing or unreadable. pub actions: Vec, - /// User patterns (`patterns.pat`), in library order. Empty when the file is missing or unreadable. - pub patterns: Vec, + /// Imported and defined patterns (`patterns.json`), in library order; large ones as previews. + pub patterns: Vec, } /// Load a store (any thread; the desktop app does this in the background at start). @@ -662,9 +669,7 @@ pub fn open(backend: Box) -> Opened { let loaded_actions = load_actions(backend.as_ref(), &mut warnings); let actions_skipped = loaded_actions.is_none(); let (actions, actions_defaults_version) = loaded_actions.map(|f| (f.actions, f.defaults_version)).unwrap_or_default(); - let loaded_patterns = load_patterns(backend.as_ref(), &mut warnings); - let patterns_skipped = loaded_patterns.is_none(); - let patterns = loaded_patterns.unwrap_or_default(); + let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings); let mut store = PresetStore { backend, synced_rev: None, @@ -675,10 +680,10 @@ pub fn open(backend: Box) -> Opened { actions_rev: 0, actions_defaults_version, actions_skipped, - patterns_key: patterns_key(&patterns, 0), - patterns_skipped, + patterns: pattern_files, warnings: Vec::new(), cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }), + pattern_cache: Mutex::new(None), }; let mut presets = Vec::new(); for (file, size, g) in parsed { @@ -737,40 +742,6 @@ pub fn open(backend: Box) -> Opened { Opened { store, presets, hidden_builtins, order, warnings, actions, patterns } } -/// `patterns.pat`: a missing file is no patterns. Anything else unreadable is a warning. -/// The saved patterns; `None` when `patterns.pat` exists but could not be loaded. -fn load_patterns(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option> { - let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERNS_BYTES) { - Ok(b) => b, - Err(e) if missing_file(&e) => return Some(Vec::new()), - Err(e) => { - warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); - return None; - } - }; - match photocraft_io::pattern_map::read_pat(&bytes) { - Ok(p) => Some(p), - Err(e) => { - warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); - None - } - } -} - -/// What decides whether the user patterns need rewriting: their ids, names, sizes and order, and -/// the library's [`pixels_rev`](crate::pattern_cmds::PatternLibrary::pixels_rev) (pixels replaced -/// under the same id). -fn patterns_key(patterns: &[photocraft_doc::Pattern], pixels_rev: u64) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - for p in patterns { - (&p.id, &p.name, p.width, p.height).hash(&mut h); - } - patterns.len().hash(&mut h); - pixels_rev.hash(&mut h); - h.finish() -} - /// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. /// The saved actions; `None` when `actions.json` exists but could not be loaded. fn load_actions(backend: &dyn PresetBackend, warnings: &mut Vec) -> Option { @@ -1100,6 +1071,29 @@ impl PresetStore { Ok(()) } + /// Write the pattern library's changes (see [`patterns`]). + pub fn sync_patterns(&mut self, library: &crate::pattern_cmds::PatternLibrary) { + let changed = self.patterns.pixels_rev != library.pixels_rev; + patterns::sync(self.backend.as_ref(), &mut self.patterns, library, &mut self.warnings); + let cache = self.pattern_cache.get_mut().unwrap_or_else(PoisonError::into_inner); + if changed || cache.as_ref().is_some_and(|p| !self.patterns.index.iter().any(|e| e.id == p.id)) { + *cache = None; + } + } + + /// The full pixels of a stored pattern the library holds as a preview: the last one read is + /// kept, so only switching patterns reads a file. + pub fn load_pattern(&self, id: &str) -> Result { + let mut cache = self.pattern_cache.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(p) = cache.as_ref().filter(|p| p.id == id) { + return Ok(p.clone()); + } + let entry = self.patterns.index.iter().find(|e| e.id == id).ok_or_else(|| format!("pattern {id} is not in the preset store"))?; + let p = patterns::load_full(self.backend.as_ref(), entry)?; + *cache = Some(p.clone()); + Ok(p) + } + /// Write the Actions list when `rev` differs from the last load or write. /// An oversized list is warned once and not retried. An I/O error is warned and retried /// on the next command. @@ -1133,49 +1127,6 @@ impl PresetStore { } } -impl PresetStore { - /// Write the user patterns (the library minus the built-ins) when they changed since the last - /// load or write. An oversized library is warned once per change and not written. An I/O - /// error is warned and retried on the next command. - pub fn sync_patterns(&mut self, library: &[photocraft_doc::Pattern], pixels_rev: u64) { - let builtins = crate::pattern_cmds::builtin(); - let user: Vec = library.iter().filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)).cloned().collect(); - let key = patterns_key(&user, pixels_rev); - if self.patterns_key == key { - return; - } - self.patterns_key = key; - if self.patterns_skipped { - self.warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged")); - return; - } - if user.is_empty() { - if let Err(e) = self.backend.remove(PATTERNS_FILE) { - self.warnings.push(format!("Couldn't save patterns: {e}")); - // Try again after the next command. - self.patterns_key = key.wrapping_add(1); - } - return; - } - let bytes = match photocraft_io::pattern_map::write_pat(&user) { - Ok(b) => b, - Err(e) => { - self.warnings.push(format!("Couldn't save patterns: {e}")); - return; - } - }; - if bytes.len() as u64 > MAX_PATTERNS_BYTES { - self.warnings.push(format!("patterns: the pattern library is too large to save ({} MB limit)", MAX_PATTERNS_BYTES >> 20)); - return; - } - if let Err(e) = self.backend.write(PATTERNS_FILE, &bytes) { - self.warnings.push(format!("Couldn't save patterns: {e}")); - // Try again after the next command. - self.patterns_key = key.wrapping_add(1); - } - } -} - fn builtin_names() -> &'static [String] { static NAMES: std::sync::OnceLock> = std::sync::OnceLock::new(); NAMES.get_or_init(|| photocraft_paint::presets::builtin().into_iter().map(|p| p.name).collect()) @@ -1193,21 +1144,27 @@ impl Session { /// deleted built-ins, then sync. Returns the load warnings. pub fn attach_preset_store(&mut self, opened: Opened) -> Vec { let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk, patterns } = opened; - // Saved patterns join the library after the built-ins, and a saved built-in (replaced by - // an import or renamed) takes the shipped one's place; a pattern defined or imported - // before the store finished loading keeps its place (and the same id isn't added twice). let builtins = crate::pattern_cmds::builtin(); - for p in patterns { - match self.patterns.items.iter_mut().find(|q| q.id == p.id) { - Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => *q = p, - // The earlier copy wins; other pixels than the saved ones must still be written, - // which its id, name and size don't show. + for l in patterns { + let id = l.pattern.id.clone(); + match self.patterns.items.iter_mut().find(|q| q.id == id) { + Some(q) if crate::pattern_cmds::is_pristine_builtin(q, &builtins) => { + *q = l.pattern; + if let Some(size) = l.full { + self.patterns.stored.insert(id, size); + } + } Some(q) => { - if q.surface != p.surface { + if q.surface != l.pattern.surface { self.patterns.pixels_rev += 1; } } - None => self.patterns.items.push(p), + None => { + if let Some(size) = l.full { + self.patterns.stored.insert(id, size); + } + self.patterns.items.push(l.pattern); + } } } let lib = &mut self.tools.presets; @@ -1280,7 +1237,7 @@ impl Session { } } st.sync_actions(&self.actions.list, actions_rev); - st.sync_patterns(&self.patterns.items, self.patterns.pixels_rev); + st.sync_patterns(&self.patterns); } } diff --git a/crates/engine/src/preset_store/patterns.rs b/crates/engine/src/preset_store/patterns.rs new file mode 100644 index 0000000000..ff535ebcf6 --- /dev/null +++ b/crates/engine/src/preset_store/patterns.rs @@ -0,0 +1,380 @@ +//! The pattern library in the preset store: imported (`.pat`) and defined patterns survive a +//! restart, as Photoshop keeps its Patterns panel. +//! +//! - `pattern-.pat`: one pattern per file (a one-pattern Photoshop `.pat`), written once +//! when the pattern enters the library and deleted when it leaves it; +//! - `pattern-.preview.pat`: the same pattern shrunk to at most [`PREVIEW_SIDE`] px, for +//! patterns larger than that; +//! - `patterns.json`: the library order, names and full sizes (renaming rewrites only this file). +//! +//! Opening the store reads the index and the previews, never a large pattern's pixels: texture +//! libraries run to gigabytes (Debaser's colour libraries hold twenty 4500 × 4500 px patterns per +//! file). Such a pattern joins the library as its preview, with its full size in +//! [`crate::pattern_cmds::PatternLibrary::stored`], and its pixels are read when it is used +//! ([`super::PresetStore::load_pattern`]). +//! +//! Built-in patterns are never written. A pattern file or index that fails to load is left on +//! disk untouched and never overwritten or deleted; a missing index is an empty library. + +use std::collections::HashSet; + +use photocraft_doc::Pattern; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde::{Deserialize, Serialize}; + +use super::{PresetBackend, missing_file}; +use crate::pattern_cmds::PatternLibrary; + +/// Library order and names. +pub const PATTERNS_FILE: &str = "patterns.json"; +/// Largest `patterns.json` read or written. +pub const MAX_PATTERN_INDEX_BYTES: u64 = 4 << 20; +/// Largest pattern file read or written. +pub const MAX_PATTERN_BYTES: u64 = 256 << 20; +/// Most patterns loaded. +pub const MAX_PATTERNS: usize = 10_000; +/// Patterns larger than this (either side) are loaded as a preview of this size until used. +pub const PREVIEW_SIDE: u32 = 128; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +pub(super) struct Entry { + pub id: String, + pub name: String, + pub file: String, + #[serde(default)] + pub width: u32, + #[serde(default)] + pub height: u32, +} + +#[derive(Serialize, Deserialize)] +struct IndexFile { + version: u32, + patterns: Vec, +} + +/// The store's view of the pattern files it owns. +#[derive(Default)] +pub(super) struct PatternFiles { + /// The index as last loaded or written. + pub index: Vec, + /// `patterns.json` exists but failed to load: never written. + pub index_skipped: bool, + /// Pattern files that failed to load: never written or deleted. + pub skipped: HashSet, + /// Patterns that couldn't be written (too large, I/O error): not retried this session, so + /// one failure is one warning. + pub failed: HashSet, + pub pixels_rev: u64, + pub pending_removals: HashSet, +} + +/// A stored pattern as loaded: the pattern (a preview when `full` is set) and its full size. +pub struct Loaded { + pub pattern: Pattern, + pub full: Option<(u32, u32)>, +} + +fn hash(id: &str) -> u64 { + id.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)) +} + +/// The file a pattern is stored in (its id hashed: ids are UUIDs or content hashes, but may hold +/// any character). +fn file_name(id: &str) -> String { + format!("pattern-{:016x}.pat", hash(id)) +} + +fn preview_name(id: &str) -> String { + format!("pattern-{:016x}.preview.pat", hash(id)) +} + +#[cfg(test)] +pub(super) fn file_name_for_test(id: &str) -> String { + file_name(id) +} + +fn is_large(width: u32, height: u32) -> bool { + width.max(height) > PREVIEW_SIDE +} + +/// `p` shrunk to at most [`PREVIEW_SIDE`] px (nearest sample: a swatch, not a resample). +fn preview(p: &Pattern) -> Pattern { + let (w, h) = (p.width.max(1), p.height.max(1)); + let step = f64::from(w.max(h)) / f64::from(PREVIEW_SIDE); + let pw = ((f64::from(w) / step).round() as u32).clamp(1, PREVIEW_SIDE); + let ph = ((f64::from(h) / step).round() as u32).clamp(1, PREVIEW_SIDE); + let fmt = p.surface.format(); + let mut data = Vec::with_capacity(pw as usize * ph as usize * fmt.channels()); + for y in 0..ph { + for x in 0..pw { + let sx = ((f64::from(x) * step) as u32).min(w - 1); + let sy = ((f64::from(y) * step) as u32).min(h - 1); + data.extend(p.surface.pixel(sx as i32, sy as i32)); + } + } + let mut surface = Surface::new(fmt); + surface.write_region(Rect::new(0, 0, pw as i32, ph as i32), &data); + Pattern { id: p.id.clone(), name: p.name.clone(), width: pw, height: ph, surface } +} + +fn read_one(backend: &dyn PresetBackend, file: &str) -> Result { + let mut v = photocraft_io::pattern_map::read_pat(&backend.read(file, MAX_PATTERN_BYTES)?)?; + if v.is_empty() { Err("no pattern in the file".to_string()) } else { Ok(v.swap_remove(0)) } +} + +/// A stored pattern's full pixels (named and identified as in `entry`). +pub(super) fn load_full(backend: &dyn PresetBackend, entry: &Entry) -> Result { + let mut p = read_one(backend, &entry.file)?; + p.id = entry.id.clone(); + p.name = entry.name.clone(); + Ok(p) +} + +/// Load the stored patterns, in library order: small ones whole, large ones as previews. +pub(super) fn load(backend: &dyn PresetBackend, warnings: &mut Vec) -> (Vec, PatternFiles) { + let mut files = PatternFiles::default(); + let bytes = match backend.read(PATTERNS_FILE, MAX_PATTERN_INDEX_BYTES) { + Ok(b) => b, + Err(e) if missing_file(&e) => return migrate_legacy(backend, warnings), + Err(e) => { + warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let index = match serde_json::from_slice::(&bytes) { + Ok(f) if f.version == 1 => f.patterns, + Ok(f) => { + warnings.push(format!("patterns: {PATTERNS_FILE} skipped: unsupported version {}", f.version)); + files.index_skipped = true; + return (Vec::new(), files); + } + Err(e) => { + warnings.push(format!("patterns: {PATTERNS_FILE} skipped: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let mut ids = HashSet::new(); + let valid = index.len() <= MAX_PATTERNS && index.iter().all(|e| e.file == file_name(&e.id) && ids.insert(e.id.clone())); + if !valid { + warnings + .push("patterns: invalid/duplicate index entries; preserve files and recover patterns.json before saving (legacy PAT is not reimported)".into()); + files.index_skipped = true; + return (Vec::new(), files); + } + let mut patterns = Vec::new(); + let mut seen = HashSet::new(); + for e in index.into_iter().take(MAX_PATTERNS) { + // Only generated names: an edited index can't reach other files. + if e.file != file_name(&e.id) || !seen.insert(e.id.clone()) { + warnings.push(format!("patterns: entry \"{}\" skipped: bad file name", e.name)); + continue; + } + let loaded = if is_large(e.width, e.height) { + // The preview; without one (a failed write), the full file once to make it. + match read_one(backend, &preview_name(&e.id)) { + Ok(mut p) => { + p.id = e.id.clone(); + p.name = e.name.clone(); + Ok(Loaded { pattern: p, full: Some((e.width, e.height)) }) + } + Err(_) => load_full(backend, &e).map(|p| Loaded { pattern: preview(&p), full: Some((p.width, p.height)) }), + } + } else { + load_full(backend, &e).map(|p| Loaded { pattern: p, full: None }) + }; + match loaded { + Ok(l) => { + patterns.push(l); + files.index.push(e); + } + Err(err) => { + warnings.push(format!("patterns: {} (\"{}\") skipped: {err}", e.file, e.name)); + files.skipped.insert(e.file.clone()); + // Kept in the index, so a later write doesn't forget the file. + files.index.push(e); + } + } + } + (patterns, files) +} + +/// Write what changed: new patterns' files (and previews), the index when the order or a name +/// changed, and delete the files of patterns that left the library. Failures are warnings. +pub(super) fn sync(backend: &dyn PresetBackend, files: &mut PatternFiles, library: &PatternLibrary, warnings: &mut Vec) { + files.pending_removals.retain(|f| match backend.remove(f) { + Ok(()) => false, + Err(e) => { + warnings.push(format!("Couldn't delete pattern file {f}: {e}")); + true + } + }); + let builtins = crate::pattern_cmds::builtin(); + let pixels_changed = files.pixels_rev != library.pixels_rev; + let mut want: Vec = library + .items + .iter() + .filter(|p| !crate::pattern_cmds::is_pristine_builtin(p, &builtins)) + .map(|p| { + let (width, height) = library.stored.get(&p.id).copied().unwrap_or((p.width, p.height)); + Entry { id: p.id.clone(), name: p.name.clone(), file: file_name(&p.id), width, height } + }) + .collect(); + // Unreadable files stay listed (at the end) until the library takes their pattern back. + for e in &files.index { + if files.skipped.contains(&e.file) && !want.iter().any(|w| w.id == e.id) { + want.push(e.clone()); + } + } + if want == files.index && !pixels_changed { + return; + } + if files.index_skipped { + warnings.push(format!("Couldn't save patterns: {PATTERNS_FILE} could not be read, so it was left unchanged")); + files.index = want; + return; + } + let have: HashSet<&str> = files.index.iter().map(|e| e.id.as_str()).collect(); + let mut written = Vec::with_capacity(want.len()); + for e in want { + // Already on disk (a preview in the library included: its pixels never left the disk). + if (have.contains(e.id.as_str()) && !pixels_changed) || files.skipped.contains(&e.file) || library.stored.contains_key(&e.id) { + written.push(e); + continue; + } + let Some(p) = library.items.iter().find(|p| p.id == e.id) else { continue }; + match write_pattern(backend, p, &e, have.contains(e.id.as_str())) { + Ok(()) => written.push(e), + Err(err) => { + warnings.push(err); + files.failed.insert(e.id); + return; + } + } + } + if written.len() > MAX_PATTERNS { + warnings.push("patterns: index count limit exceeded".into()); + return; + } + let index = IndexFile { version: 1, patterns: written }; + let bytes = match serde_json::to_vec_pretty(&index) { + Ok(b) => b, + Err(e) => { + warnings.push(format!("Couldn't save patterns: {e}")); + return; + } + }; + if bytes.len() as u64 > MAX_PATTERN_INDEX_BYTES { + warnings.push("patterns: index byte limit exceeded".into()); + return; + } + if let Err(e) = backend.write(PATTERNS_FILE, &bytes) { + warnings.push(format!("Couldn't save patterns: {e}")); + return; + } + // Files of patterns that left the library (written by an earlier index) are deleted only + // once the new index no longer names them. + let keep: HashSet<&str> = index.patterns.iter().map(|e| e.file.as_str()).collect(); + for e in &files.index { + if !keep.contains(e.file.as_str()) && !files.skipped.contains(&e.file) { + for f in [e.file.clone(), preview_name(&e.id)] { + if let Err(err) = backend.remove(&f) { + warnings.push(format!("Couldn't delete pattern file {f}: {err}")); + files.pending_removals.insert(f); + } + } + } + } + files.index = index.patterns; + files.pixels_rev = library.pixels_rev; +} + +fn write_pattern(backend: &dyn PresetBackend, p: &Pattern, e: &Entry, owned: bool) -> Result<(), String> { + let bytes = photocraft_io::pattern_map::write_pat(std::slice::from_ref(p)).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + if bytes.len() as u64 > MAX_PATTERN_BYTES { + return Err(format!("pattern \"{}\" is too large to save ({} MB limit)", p.name, MAX_PATTERN_BYTES >> 20)); + } + if is_large(p.width, p.height) { + // The preview first: a full file without one still loads (by making it). + let small = photocraft_io::pattern_map::write_pat(&[preview(p)]).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + write_asset(backend, &preview_name(&e.id), &small, owned).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + } + write_asset(backend, &e.file, &bytes, owned).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name)) +} + +/// The index itself is the atomic migration commit. Never consult stale PAT when it exists, +/// including when corrupt. An interrupted asset write leaves the legacy bytes authoritative. +fn migrate_legacy(backend: &dyn PresetBackend, warnings: &mut Vec) -> (Vec, PatternFiles) { + let mut files = PatternFiles::default(); + let bytes = match backend.read(super::LEGACY_PATTERNS_FILE, super::MAX_PATTERNS_BYTES) { + Ok(b) => b, + Err(e) if missing_file(&e) => return (Vec::new(), files), + Err(e) => { + warnings.push(format!("patterns: legacy file preserved: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let patterns = match photocraft_io::pattern_map::read_pat(&bytes) { + Ok(p) => p, + Err(e) => { + warnings.push(format!("patterns: legacy file preserved: {e}")); + files.index_skipped = true; + return (Vec::new(), files); + } + }; + let mut ids = HashSet::new(); + let mut names = HashSet::new(); + if patterns.len() > MAX_PATTERNS || patterns.iter().any(|p| !ids.insert(p.id.clone()) || !names.insert(file_name(&p.id))) { + warnings.push("patterns: legacy migration refused duplicate IDs, file hash collision or count limit; legacy preserved".into()); + files.index_skipped = true; + return (patterns.into_iter().map(|pattern| Loaded { pattern, full: None }).collect(), files); + } + let migration = (|| -> Result<(), String> { + let entries: Vec = + patterns.iter().map(|p| Entry { id: p.id.clone(), name: p.name.clone(), file: file_name(&p.id), width: p.width, height: p.height }).collect(); + let index = serde_json::to_vec_pretty(&IndexFile { version: 1, patterns: entries.clone() }).map_err(|e| e.to_string())?; + if index.len() as u64 > MAX_PATTERN_INDEX_BYTES { + return Err("index exceeds size limit".into()); + } + for (p, e) in patterns.iter().zip(&entries) { + // Never overwrite unknown files, even if a name collides. Identical orphan assets + // from an interrupted migration can be reused; the legacy file stays untouched. + let full = photocraft_io::pattern_map::write_pat(std::slice::from_ref(p))?; + if full.len() as u64 > MAX_PATTERN_BYTES { + return Err("pattern exceeds size limit".into()); + } + write_migration_asset(backend, &e.file, &full)?; + if is_large(p.width, p.height) { + let small = photocraft_io::pattern_map::write_pat(&[preview(p)])?; + write_migration_asset(backend, &preview_name(&p.id), &small)?; + } + } + // Atomic backend write last: no partial or failed migration can become authoritative. + backend.write(PATTERNS_FILE, &index)?; + Ok(()) + })(); + match migration { + Ok(()) => load(backend, warnings), + Err(e) => { + warnings.push(format!("patterns: migration not committed, legacy remains usable: {e}")); + files.index_skipped = true; + (patterns.into_iter().map(|pattern| Loaded { pattern, full: None }).collect(), files) + } + } +} +fn write_migration_asset(backend: &dyn PresetBackend, name: &str, bytes: &[u8]) -> Result<(), String> { + match backend.read(name, MAX_PATTERN_BYTES) { + Ok(existing) if existing == bytes => Ok(()), + Ok(_) => Err(format!("unrecognized existing asset {name} preserved")), + Err(e) if missing_file(&e) => backend.write(name, bytes), + Err(e) => Err(format!("existing asset {name} preserved: {e}")), + } +} + +fn write_asset(backend: &dyn PresetBackend, name: &str, bytes: &[u8], owned: bool) -> Result<(), String> { + if owned { backend.write(name, bytes) } else { write_migration_asset(backend, name, bytes) } +} diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index de16488367..51d554534d 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -704,6 +704,129 @@ fn an_actions_file_that_failed_to_load_is_not_overwritten() { assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty()); } +/// A large pattern opens as a small preview (texture libraries run to gigabytes) and is read at +/// full size when it is used; its preview file goes with it on delete. +#[test] +fn large_patterns_load_on_use() { + let dir = TempDir::new("patterns-large"); + let (mut s, _) = session(&dir); + s.execute("file.new", json!({"width": 300, "height": 200})).unwrap(); + s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap(); + s.execute("select.rect", json!({"x": 250, "y": 150, "width": 50, "height": 50})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("edit.definePattern", json!({"name": "Big"})).unwrap(); + assert_eq!(dir.files().iter().filter(|f| f.ends_with(".preview.pat")).count(), 1); + + let (mut t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + let held = t.patterns.items.iter().find(|p| p.name == "Big").unwrap(); + assert!(held.width <= PATTERN_PREVIEW_SIDE && held.height <= PATTERN_PREVIEW_SIDE, "held as a preview: {}x{}", held.width, held.height); + let listed = t.execute("pattern.list", json!({})).unwrap(); + let big = listed.as_array().unwrap().iter().find(|p| p["name"] == "Big").unwrap(); + assert_eq!((big["width"].as_u64(), big["height"].as_u64()), (Some(300), Some(200)), "the list reports the full size"); + // Checks that only need the pattern's id (Pattern Stamp's enabled state) don't read its file. + let id = t.patterns.items.iter().find(|p| p.name == "Big").unwrap().id.clone(); + let file = dir.0.join(patterns::file_name_for_test(&id)); + let moved = file.with_extension("moved"); + std::fs::rename(&file, &moved).unwrap(); + assert_eq!(crate::pattern_cmds::resolve_id(&t, "Big"), Some(id.clone())); + assert!(crate::pattern_cmds::resolve(&t, "Big").is_none(), "the full pixels come from the file"); + std::fs::rename(&moved, &file).unwrap(); + let full = crate::pattern_cmds::resolve(&t, "Big").unwrap(); + assert_eq!((full.width, full.height), (300, 200)); + assert!(full.surface.pixel(280, 180)[1] < 0.01 && full.surface.pixel(10, 10)[1] > 0.99, "full pixels"); + // Once read, it is kept: using it again doesn't read the file. + std::fs::rename(&file, &moved).unwrap(); + assert_eq!(crate::pattern_cmds::resolve(&t, "Big").map(|p| p.width), Some(300)); + std::fs::rename(&moved, &file).unwrap(); + // A Pattern Fill layer gets the full pattern into the document. + t.execute("file.new", json!({"width": 40, "height": 40})).unwrap(); + t.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap(); + let doc_pat = &t.active().unwrap().doc.patterns[0]; + assert_eq!((doc_pat.width, doc_pat.height), (300, 200)); + // Exporting the library writes full pixels too. + let out = dir.0.join("export.pat"); + t.execute("pattern.export", json!({"path": out.to_string_lossy()})).unwrap(); + let back = photocraft_io::pattern_map::read_pat(&std::fs::read(&out).unwrap()).unwrap(); + assert!(back.iter().any(|p| p.name == "Big" && p.width == 300)); + std::fs::remove_file(&out).unwrap(); + + t.execute("pattern.delete", json!({"pattern": "Big"})).unwrap(); + assert!(!dir.files().iter().any(|f| f.starts_with("pattern-")), "{:?}", dir.files()); +} + +/// A corrupt pattern file is skipped with a warning and left on disk; the others load, and later +/// saves never delete or overwrite it. A corrupt index is never overwritten. +#[test] +fn corrupt_pattern_files_are_skipped_and_kept() { + let dir = TempDir::new("patterns-corrupt"); + let (mut s, _) = session(&dir); + define_pattern(&mut s, "Grit", "#202020", 8); + define_pattern(&mut s, "Paper", "#f0e0c0", 12); + let grit = s.patterns.items.iter().find(|p| p.name == "Grit").unwrap().id.clone(); + let file = dir.0.join(patterns::file_name_for_test(&grit)); + std::fs::write(&file, b"not a pattern").unwrap(); + + let (mut t, w) = session(&dir); + assert!(w.iter().any(|w| w.contains("Grit")), "{w:?}"); + assert_eq!(user_patterns(&t), [("Paper".to_string(), 12)]); + define_pattern(&mut t, "Ink", "#000000", 4); + assert_eq!(std::fs::read(&file).unwrap(), b"not a pattern", "the unreadable file is left alone"); + let index = std::fs::read_to_string(dir.0.join(PATTERNS_FILE)).unwrap(); + assert!(index.contains("Grit") && index.contains("Ink"), "{index}"); + + std::fs::write(dir.0.join(PATTERNS_FILE), b"{").unwrap(); + let (mut u, w) = session(&dir); + assert!(w.iter().any(|w| w.contains(PATTERNS_FILE)), "{w:?}"); + assert!(user_patterns(&u).is_empty()); + define_pattern(&mut u, "Late", "#808080", 4); + assert_eq!(std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap(), b"{", "an unreadable index is never overwritten"); +} + +/// Imported and defined patterns survive a restart: one file each, written once, plus the index; +/// a rename rewrites only the index, a delete removes the file. +#[test] +fn patterns_survive_a_restart() { + let dir = TempDir::new("patterns"); + let (mut s, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + define_pattern(&mut s, "Grit", "#202020", 8); + define_pattern(&mut s, "Paper", "#f0e0c0", 12); + let pattern_files = |d: &TempDir| d.files().into_iter().filter(|f| f.starts_with("pattern-")).collect::>(); + assert_eq!(pattern_files(&dir).len(), 2); + assert!(dir.files().contains(&PATTERNS_FILE.to_string())); + assert!(!dir.files().iter().any(|f| f.contains("Checkerboard")), "built-ins are never written"); + + let (mut t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(user_patterns(&t), [("Grit".to_string(), 8), ("Paper".to_string(), 12)]); + // The pixels come back too. + let paper = t.patterns.items.iter().find(|p| p.name == "Paper").unwrap(); + let px = paper.surface.pixel(3, 3); + assert!((px[0] - 240.0 / 255.0).abs() < 0.01, "{px:?}"); + + let before = std::fs::metadata(dir.0.join(&pattern_files(&dir)[0])).unwrap().modified().unwrap(); + t.execute("pattern.rename", json!({"pattern": "Grit", "name": "Toner"})).unwrap(); + assert_eq!(std::fs::metadata(dir.0.join(&pattern_files(&dir)[0])).unwrap().modified().unwrap(), before, "a rename rewrites only the index"); + t.execute("pattern.delete", json!({"pattern": "Paper"})).unwrap(); + assert_eq!(pattern_files(&dir).len(), 1, "the deleted pattern's file is gone"); + + let (u, _) = session(&dir); + assert_eq!(user_patterns(&u), [("Toner".to_string(), 8)]); +} + +fn define_pattern(s: &mut Session, name: &str, color: &str, width: u32) { + s.execute("file.new", json!({"width": width, "height": 6})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + s.execute("edit.definePattern", json!({"name": name})).unwrap(); +} + +fn user_patterns(s: &Session) -> Vec<(String, u32)> { + let builtins: Vec = crate::pattern_cmds::builtin().into_iter().map(|p| p.id).collect(); + s.patterns.items.iter().filter(|p| !builtins.contains(&p.id)).map(|p| (p.name.clone(), p.width)).collect() +} + /// #9317: a defined or imported pattern survives a restart, as in Photoshop; the built-in /// patterns are never written, and deleting the last user pattern removes the file. #[test] @@ -719,7 +842,13 @@ fn user_patterns_survive_a_restart() { assert!(dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files()); // Only the user's pattern is in the file. let bytes = std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap(); - let saved = photocraft_io::pattern_map::read_pat(&bytes).unwrap(); + let index: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let saved: Vec = index["patterns"] + .as_array() + .unwrap() + .iter() + .map(|e| photocraft_io::pattern_map::read_pat(&std::fs::read(dir.0.join(e["file"].as_str().unwrap())).unwrap()).unwrap().remove(0)) + .collect(); assert_eq!(saved.iter().map(|p| p.name.as_str()).collect::>(), ["Green Tile"]); drop(s); @@ -736,7 +865,10 @@ fn user_patterns_survive_a_restart() { assert!(u.patterns.items.iter().any(|p| p.id == id && p.name == "Moss")); t.execute("pattern.delete", json!({"pattern": id})).unwrap(); t.sync_preset_store(); - assert!(!dir.files().contains(&PATTERNS_FILE.to_string()), "{:?}", dir.files()); + assert!( + serde_json::from_slice::(&std::fs::read(dir.0.join(PATTERNS_FILE)).unwrap()).unwrap()["patterns"].as_array().unwrap().is_empty(), + "empty authoritative index prevents resurrection" + ); } #[test] @@ -827,7 +959,13 @@ fn a_failed_patterns_removal_is_retried() { backend.fail.store(false, std::sync::atomic::Ordering::SeqCst); s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); s.sync_preset_store(); - assert!(!backend.mem.files.lock().unwrap().contains_key(PATTERNS_FILE), "the removal is retried"); + assert!(!backend.mem.files.lock().unwrap().keys().any(|f| f.starts_with("pattern-")), "owned asset removal is retried"); + assert!( + serde_json::from_slice::(backend.mem.files.lock().unwrap().get(PATTERNS_FILE).unwrap()).unwrap()["patterns"] + .as_array() + .unwrap() + .is_empty() + ); } /// #9318 review: an imported `.pat` that replaces a built-in pattern's pixels (same id) is saved @@ -840,7 +978,13 @@ fn an_imported_builtin_replacement_survives_a_restart() { let builtin = s.patterns.items[0].id.clone(); s.execute("pattern.import", json!({"data": pat_bytes(&builtin, [0.9, 0.1, 0.4, 1.0])})).unwrap(); s.sync_preset_store(); - let saved = photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap(); + let index: serde_json::Value = serde_json::from_slice(mem.files.lock().unwrap().get(PATTERNS_FILE).expect("written")).unwrap(); + let saved: Vec = index["patterns"] + .as_array() + .unwrap() + .iter() + .map(|e| photocraft_io::pattern_map::read_pat(mem.files.lock().unwrap().get(e["file"].as_str().unwrap()).unwrap()).unwrap().remove(0)) + .collect(); assert_eq!(saved.iter().map(|p| p.id.as_str()).collect::>(), [builtin.as_str()], "only the replaced built-in"); let mut t = Session::new(); t.attach_preset_store(open(Box::new(mem.clone()))); @@ -870,3 +1014,114 @@ fn an_early_same_id_import_wins_and_is_saved() { u.attach_preset_store(open(Box::new(mem.clone()))); assert_eq!(u.patterns.items.iter().find(|p| p.id == id).unwrap().surface, early, "the early import's pixels were saved"); } +#[derive(Clone)] +struct MigrationFail { + mem: MemBackend, + fail_name: String, +} +impl PresetBackend for MigrationFail { + fn list(&self) -> Result, String> { + self.mem.list() + } + fn read(&self, n: &str, m: u64) -> Result, String> { + self.mem.read(n, m) + } + fn write(&self, n: &str, b: &[u8]) -> Result<(), String> { + if n == self.fail_name { Err("injected migration write failure".into()) } else { self.mem.write(n, b) } + } + fn remove(&self, n: &str) -> Result<(), String> { + self.mem.remove(n) + } +} +fn legacy_store() -> (MemBackend, Vec, String) { + let mem = MemBackend::default(); + let id = "6f1c2c1e-0000-4000-8000-000000005176".to_string(); + let bytes = photocraft_paint::tile::b64_decode(&pat_bytes(&id, [0.2, 0.6, 0.8, 1.0])).unwrap(); + mem.write(LEGACY_PATTERNS_FILE, &bytes).unwrap(); + (mem, bytes, id) +} +#[test] +fn legacy_migration_commits_last_and_never_resurrects_renames_or_deletions() { + let (mem, old, id) = legacy_store(); + let mut s = Session::new(); + assert!(s.attach_preset_store(open(Box::new(mem.clone()))).is_empty()); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_ok()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); + s.execute("pattern.rename", json!({"pattern":id,"name":"Migrated"})).unwrap(); + s.sync_preset_store(); + let mut t = Session::new(); + t.attach_preset_store(open(Box::new(mem.clone()))); + assert_eq!(t.patterns.items.iter().find(|p| p.id == id).unwrap().name, "Migrated"); + t.execute("pattern.delete", json!({"pattern":id})).unwrap(); + t.sync_preset_store(); + for _ in 0..2 { + let mut u = Session::new(); + u.attach_preset_store(open(Box::new(mem.clone()))); + assert!(!u.patterns.items.iter().any(|p| p.id == id)); + } + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); +} +#[test] +fn interrupted_migration_preserves_legacy_and_reuses_only_identical_orphans() { + for fail_index in [false, true] { + let (mem, old, id) = legacy_store(); + let file = patterns::file_name_for_test(&id); + let fail_name = if fail_index { PATTERNS_FILE.to_string() } else { file.clone() }; + let opened = open(Box::new(MigrationFail { mem: mem.clone(), fail_name })); + assert!(!opened.warnings.is_empty()); + assert!(opened.patterns.iter().any(|p| p.pattern.id == id), "legacy remains usable"); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err(), "no commit marker on failure"); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); + let retry = open(Box::new(mem.clone())); + assert!(retry.warnings.is_empty(), "{:?}", retry.warnings); + assert!(retry.patterns.iter().any(|p| p.pattern.id == id)); + } + let (mem, old, id) = legacy_store(); + let file = patterns::file_name_for_test(&id); + mem.write(&file, b"unknown content").unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert_eq!(mem.read(&file, 100).unwrap(), b"unknown content"); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); +} +#[test] +fn malformed_authoritative_index_never_falls_back_to_stale_legacy() { + let (mem, _, id) = legacy_store(); + drop(open(Box::new(mem.clone()))); + for bytes in [b"{".as_slice(), b"{\"version\":1,\"patterns\":[{\"id\":\"x\",\"name\":\"bad\",\"file\":\"../secret\"}]}".as_slice()] { + mem.write(PATTERNS_FILE, bytes).unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert!(opened.patterns.is_empty()); + assert_eq!(mem.read(PATTERNS_FILE, 1 << 20).unwrap(), bytes); + assert!(!opened.patterns.iter().any(|p| p.pattern.id == id)); + } + let mem = MemBackend::default(); + mem.write(LEGACY_PATTERNS_FILE, b"broken PAT").unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, 20).unwrap(), b"broken PAT"); +} +#[test] +fn migration_preserves_duplicate_names_but_refuses_duplicate_ids() { + let (mem, _, id) = legacy_store(); + let bytes = mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(); + let mut pats = photocraft_io::pattern_map::read_pat(&bytes).unwrap(); + let mut other = pats[0].clone(); + other.id = "6f1c2c1e-0000-4000-8000-000000005177".into(); + pats.push(other); + mem.write(LEGACY_PATTERNS_FILE, &photocraft_io::pattern_map::write_pat(&pats).unwrap()).unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(opened.warnings.is_empty()); + assert_eq!(opened.patterns.len(), 2); + let mem = MemBackend::default(); + pats[1].id = id; + let old = photocraft_io::pattern_map::write_pat(&pats).unwrap(); + mem.write(LEGACY_PATTERNS_FILE, &old).unwrap(); + let opened = open(Box::new(mem.clone())); + assert!(!opened.warnings.is_empty()); + assert!(mem.read(PATTERNS_FILE, 1 << 20).is_err()); + assert_eq!(mem.read(LEGACY_PATTERNS_FILE, MAX_PATTERNS_BYTES).unwrap(), old); +} diff --git a/crates/engine/src/presets/patterns.rs b/crates/engine/src/presets/patterns.rs index 2425dd0900..a1d9ff2bf0 100644 --- a/crates/engine/src/presets/patterns.rs +++ b/crates/engine/src/presets/patterns.rs @@ -162,7 +162,7 @@ fn edit(s: &mut Session, p: &Value) -> Result { } "move" => { let key = req_str(p, "preset", CMD)?; - let id = crate::pattern_cmds::resolve(s, key).map(|q| q.id).ok_or_else(|| bad(CMD, format!("no pattern \"{key}\"")))?; + let id = crate::pattern_cmds::resolve_id(s, key).ok_or_else(|| bad(CMD, format!("no pattern \"{key}\"")))?; let mut q = p.clone(); q["preset"] = json!(id); edit_groups(&mut s.presets.pattern_groups, "move", &q, CMD)? diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 664c78c831..3e1741118c 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -140,6 +140,9 @@ impl Session { if spec.id == "layer.new.layerFromBackground" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) { return self.active().map(|_| ()).ok_or_else(|| "no document open".into()); } + if spec.id == "layer.smartFilter.deleteFilterMask" && params.get("ifPresent").and_then(Value::as_bool) == Some(true) { + return smart_cmds::smart_layer_active(self); + } channel_cmds::mask_target_enabled(self, spec.id, params).unwrap_or_else(|| (spec.enabled)(self)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a55e696e17..f10b76fe46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -593,6 +593,10 @@ fn arrange_and_group() { let names: Vec = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone()).collect(); assert_eq!(names, ["Background", "B", "A"]); assert!(s.execute("layer.arrange.bringForward", json!({})).is_err()); + // Bring to Front of the front layer does nothing: no error (an action plays it), no step. + let steps = s.active().unwrap().history.past_len(); + assert_eq!(s.execute("layer.arrange.bringToFront", json!({})).unwrap(), json!({"moved": false})); + assert_eq!(s.active().unwrap().history.past_len(), steps); s.execute("layer.groupLayers", json!({})).unwrap(); let doc = s.execute("document.inspect", json!({})).unwrap(); assert_eq!(doc["layers"][0]["kind"], "Group"); diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 8dae431078..324e14ff0e 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -3285,7 +3285,10 @@ fn integration_adjustment_fill_opacity_masks_and_hdr_match_cpu() { layer.fill_opacity = fill; layer.opacity = opacity; let mut mask = LayerMask::reveal_all(); - mask.surface = photocraft_raster::Surface::with_default(PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }, &[coverage]); + mask.surface = photocraft_raster::Surface::with_default( + PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }, + &[coverage], + ); layer.mask = Some(mask); d.layers = vec![base, layer]; check(&mut g, &d, &format!("combined {mode:?} fill={fill} opacity={opacity} mask={coverage} clipped={clipped}")); diff --git a/crates/ui-egui/src/dialogs/pattern_fill_ui.rs b/crates/ui-egui/src/dialogs/pattern_fill_ui.rs index 9404bf6b54..76e8e47620 100644 --- a/crates/ui-egui/src/dialogs/pattern_fill_ui.rs +++ b/crates/ui-egui/src/dialogs/pattern_fill_ui.rs @@ -65,8 +65,8 @@ pub(crate) fn open_new(app: &mut PhotocraftApp) -> Result { return Err(why); } let st = app.session.active().ok_or("no document open")?; - let pattern = photocraft_engine::pattern_cmds::resolve(&app.session, "").ok_or("no patterns")?; - let values = json!({"pattern": pattern.id, "scale": 100.0, "angle": 0.0, "link": true, "phase": [0.0, 0.0]}); + let pattern = photocraft_engine::pattern_cmds::resolve_id(&app.session, "").ok_or("no patterns")?; + let values = json!({"pattern": pattern, "scale": 100.0, "angle": 0.0, "link": true, "phase": [0.0, 0.0]}); let f = fields(cmds::CREATE, json!({"document": st.doc.id.0}), values); Ok(app.ui.open_dialog(crate::state::DialogKind::Command, f)) } diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 2b7d4e0f5d..0280e7710e 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -168,6 +168,8 @@ fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option) let target = app.session.active().map(|d| d.doc.id); let result = if id == "actions.play" { play(app, p) + } else if actions_cmds::saves_smart_contents(&app.session, id) { + app.session.execute(actions_cmds::SAVE_CONTENTS, json!({})).map_err(|e| e.to_string()) } else if actions_cmds::shell_save_command(id) { // Write synchronously, so the next step doesn't meet a document locked by a // background save. diff --git a/crates/ui-egui/src/panels/preset_panels/panels.rs b/crates/ui-egui/src/panels/preset_panels/panels.rs index 7aa89c5ccc..4b0e05fbd6 100644 --- a/crates/ui-egui/src/panels/preset_panels/panels.rs +++ b/crates/ui-egui/src/panels/preset_panels/panels.rs @@ -47,7 +47,11 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // ------------------------------------------------------------------ Patterns pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { - photocraft_engine::presets::patterns::sync(&mut app.session); + // Until the preset store has loaded the saved patterns, their folder entries would look + // deleted: don't prune them yet. + if app.services.preset_store.is_none() { + photocraft_engine::presets::patterns::sync(&mut app.session); + } let lib = app.session.patterns.items.clone(); let groups: Vec = app .session