From 2b2fe271387e3561a61f368ac3b9b5529206b7f7 Mon Sep 17 00:00:00 2001 From: John Cambra <24539725+VegaEX@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:55:51 -0700 Subject: [PATCH 1/2] Play CopyCat, AJ and Line and Color action sets; keep patterns across restarts; Shift Edge scale .atn import maps 116 events (was 112). Three more third-party sets import: COPYCAT 44 -> 0 unmapped steps (all three actions play end to end with its .pat), Line and Color 3 -> 0, AJ_Actions2023 8 -> 4 (Lens Correction with a recorded lens profile stays unmapped; Timeline frame steps and an empty Set are switched off). - .atn: Create Clipping Mask (groupEvent), Apply Locking and layer lock / Advanced Blending sets, Diffuse, New Snapshot (replace existing), Clear History, deleting a layer mask by Msk and a smart filter mask, smart filter Blending Options, Shift-click layer ranges, Select and Mask's newer "output to selection". - Engine: history.clear (History panel menu, snapshots kept); history.newSnapshot replaceExisting; layer.smartFilter.deleteFilterMask ifPresent (Photoshop adds that mask by default); Bring to Front / Send to Back of a layer already there is a no-op, not an error. - Playback: Save in an Edit Contents document saves into the smart object (headless and in the desktop shell, which failed for want of a path). - Patterns persist in the preset store: patterns.json plus one .pat per pattern; patterns over 128 px open as a preview and load at full size on use, so gigabyte texture libraries cost nothing at start. Corrupt files are skipped and never overwritten. The Patterns panel no longer prunes folder entries before the store has loaded. - Select and Mask Shift Edge: +-100% moves the edge by its soft width (radius, feather or smooth; 1 px for a hard edge), was a fixed 4 px, which left a solid black band around Debaser's distressed ink. Co-Authored-By: Claude Opus 5.5 --- crates/algo/src/matting.rs | 34 ++- crates/engine/src/actions_cmds.rs | 35 ++- crates/engine/src/atn_cmds.rs | 236 ++++++++++++++++- crates/engine/src/atn_cmds/tests.rs | 10 + crates/engine/src/commands.rs | 16 +- crates/engine/src/history_cmds.rs | 44 +++- crates/engine/src/lib.rs | 3 + crates/engine/src/pattern_cmds.rs | 61 +++-- crates/engine/src/preset_store.rs | 38 ++- crates/engine/src/preset_store/patterns.rs | 282 +++++++++++++++++++++ crates/engine/src/preset_store/tests.rs | 111 ++++++++ crates/engine/src/smart_cmds.rs | 29 ++- crates/engine/src/smart_cmds/tests.rs | 7 + crates/engine/src/smartselect_cmds.rs | 3 +- crates/engine/src/tests.rs | 4 + crates/ui-egui/src/actions.rs | 2 + crates/ui-egui/src/preset_panels.rs | 6 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 22 +- docs/target-app-parity.md | 9 +- 20 files changed, 899 insertions(+), 58 deletions(-) create mode 100644 crates/engine/src/preset_store/patterns.rs diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index e40b687040..bca0ca112c 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 @@ -750,22 +754,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/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index d881a6112b..0eb136f23b 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -128,6 +128,14 @@ pub fn shell_save_command(id: &str) -> bool { matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") } +/// Save in an Edit Contents document writes it back into its smart object, as File › Save does +/// there: the step needs no file and plays headless too. +pub fn saves_smart_contents(s: &Session, id: &str) -> bool { + id == "file.save" && s.is_enabled(SAVE_CONTENTS) +} + +pub const SAVE_CONTENTS: &str = "layer.smartObjects.saveContents"; + /// View-only menu commands recorded by the desktop shell. Headless playback reports /// them as unsupported instead of silently dropping them; Batch and droplets skip them. pub fn shell_view_command(id: &str) -> bool { @@ -317,7 +325,8 @@ 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})); @@ -692,6 +701,30 @@ mod tests { 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 unchecked_steps_are_skipped_and_follow_move_and_delete() { let mut s = Session::new(); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..6d2793a227 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1089,10 +1089,31 @@ fn make_from(d: &Descriptor) -> Mapped { let id = if get_ref(d, "From").is_some() { "layer.groupLayers" } else { "layer.new.group" }; step(id, 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 }, } } +/// 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")]; + fn make_to(id: &str, p: &Value) -> Option { let with_name = |cls: &str| { let mut u = desc(cls); @@ -1153,6 +1174,18 @@ fn make_to(id: &str, p: &Value) -> Option { d } "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") { @@ -1184,6 +1217,56 @@ fn make_to(id: &str, p: &Value) -> Option { }) } +/// 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. +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 }, + } +} + +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))) +} + 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 }; @@ -1287,10 +1370,65 @@ 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 ") { @@ -1383,6 +1521,39 @@ 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. @@ -1406,6 +1577,8 @@ fn select_from(d: &Descriptor) -> Mapped { match get_enum(d, "selectionModifier").as_deref() { Some("addToSelection") => p["mode"] = json!("add"), Some("removeFromSelection") => p["mode"] = json!("remove"), + // Shift-click: every layer between the active one and this one. + Some("addToSelectionContinuous") => p["mode"] = json!("range"), _ => {} } step("layer.select", p) @@ -1449,6 +1622,7 @@ fn select_to(id: &str, p: &Value) -> Option { None | Some("replace") => {} Some("add") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")), Some("remove") => d = d.with("selectionModifier", enm("selectionModifierType", "removeFromSelection")), + Some("range") => d = d.with("selectionModifier", enm("selectionModifierType", "addToSelectionContinuous")), Some(_) => return None, } d.with("MkVs", D::Boolean(false)) @@ -1483,6 +1657,15 @@ 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 }, } } @@ -1497,6 +1680,8 @@ fn quick_mask_ref() -> D { 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 }, } @@ -1505,6 +1690,13 @@ fn clear_from(d: &Descriptor) -> Mapped { 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, } } @@ -1816,6 +2008,8 @@ 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, }) } @@ -2501,6 +2695,7 @@ macro_rules! adjustment_event { }; } +const DIFFUSE_MODE: &[(&str, &str)] = &[("Nrml", "normal"), ("DrkO", "darkenOnly"), ("LghO", "lightenOnly"), ("Anst", "anisotropic")]; const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")]; /// Select and Mask (`refineSelectionEdge`). Smart Radius, decontamination and the output follow @@ -2523,6 +2718,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 @@ -2608,6 +2805,7 @@ const EVENTS: &[Event] = &[ ), ev!("Rple", "ripple", "Ripple", "filter.distort.ripple", &[F("Amnt", "amount", Kind::Long), F("RplS", "size", Kind::Enum("RplS", RIPPLE_SIZE))]), ev!("Crst", "crystallize", "Crystallize", "filter.pixelate.crystallize", &[F("ClSz", "cellSize", Kind::Long)]), + 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 { @@ -2762,6 +2960,7 @@ const EVENTS: &[Event] = &[ "layer.newAdjustmentLayer.gradientMap", "file.new", "file.newFromClipboard", + "history.newSnapshot", ], from: make_from, to: make_to, @@ -2784,6 +2983,8 @@ const EVENTS: &[Event] = &[ "layer.setAdjustment", "layer.fill.pattern.set", "layer.new.layerFromBackground", + "layer.layerStyle.blendingOptions", + "layer.smartFilter.blendingOptions", ], from: set_from, to: set_to, @@ -2807,8 +3008,37 @@ 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: "GrpL", + name: "groupEvent", + label: "Create Clipping Mask", + ids: &["layer.createClippingMask"], + from: |d| match get_ref(d, "null").map(target) { + None => none("layer.createClippingMask"), + Some(Target::Ordinal(o)) if o == "Trgt" => none("layer.createClippingMask"), + _ => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| p.as_object().is_some_and(|o| o.is_empty()).then(|| desc("null").with("null", target_ref("Lyr "))), + }, + 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/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 871300372c..8948274fdb 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -314,6 +314,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})), @@ -321,6 +322,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})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7ddad80737..31905638cf 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1467,9 +1467,13 @@ pub fn adjustment_kind(a: &Adjustment) -> &'static str { } } +/// 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"; + 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) { @@ -1494,12 +1498,16 @@ 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/history_cmds.rs b/crates/engine/src/history_cmds.rs index c9c91b3d14..59dd6a0e99 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -110,6 +110,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. @@ -143,6 +153,10 @@ fn new_snapshot(s: &mut Session, p: &Value) -> Result { let id = st.next_snapshot; st.next_snapshot = id.saturating_add(1); let name = name.unwrap_or_else(|| format!("Snapshot {id}")); + // 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 })) } @@ -259,11 +273,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", @@ -416,6 +440,24 @@ mod tests { assert_eq!(snapshots(&s), json!([{"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/lib.rs b/crates/engine/src/lib.rs index d2aecf37db..5d5a65d5f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -676,6 +676,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/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 0cb9052ceb..62a8c5db5d 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -22,11 +22,14 @@ use crate::{EngineError, Result, Session}; #[derive(Clone, Debug)] pub struct PatternLibrary { pub items: Vec, + /// 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() } + PatternLibrary { items: builtin(), stored: std::collections::HashMap::new() } } } @@ -110,24 +113,47 @@ 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) +} + +/// 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). @@ -152,10 +178,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, }) @@ -163,8 +189,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)) } @@ -228,7 +255,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); } @@ -263,6 +290,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})) } @@ -278,6 +306,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); match s.patterns.items.iter_mut().find(|q| q.id == pat.id) { Some(q) => *q = pat, None => s.patterns.items.push(pat), @@ -293,7 +322,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/preset_store.rs b/crates/engine/src/preset_store.rs index 83cb16a838..3d538c5bd1 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -35,6 +35,9 @@ //! 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. @@ -49,6 +52,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`]). @@ -550,6 +556,8 @@ pub struct PresetStore { actions_rev: u64, /// `actions.json` exists but failed to load: left on disk untouched, so it is never written. actions_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, @@ -568,6 +576,8 @@ pub struct Opened { pub warnings: Vec, /// Recorded actions (`actions.json`). Empty when the file is missing or unreadable. pub actions: 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). @@ -644,6 +654,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 = loaded_actions.unwrap_or_default(); + let (patterns, pattern_files) = patterns::load(backend.as_ref(), &mut warnings); let mut store = PresetStore { backend, synced_rev: None, @@ -653,6 +664,7 @@ pub fn open(backend: Box) -> Opened { index: None, actions_rev: 0, actions_skipped, + patterns: pattern_files, warnings: Vec::new(), cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }), }; @@ -710,7 +722,7 @@ pub fn open(backend: Box) -> Opened { let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default(); let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default(); store.index = index; - Opened { store, presets, hidden_builtins, order, warnings, actions } + Opened { store, presets, hidden_builtins, order, warnings, actions, patterns } } /// `actions.json`: a missing file is an empty list. Anything else unreadable is a warning. @@ -1042,6 +1054,17 @@ impl PresetStore { Ok(()) } + /// Write the pattern library's changes (see [`patterns`]). + pub fn sync_patterns(&mut self, library: &crate::pattern_cmds::PatternLibrary) { + patterns::sync(self.backend.as_ref(), &mut self.patterns, library, &mut self.warnings); + } + + /// The full pixels of a stored pattern the library holds as a preview. + pub fn load_pattern(&self, id: &str) -> Result { + let entry = self.patterns.index.iter().find(|e| e.id == id).ok_or_else(|| format!("pattern {id} is not in the preset store"))?; + patterns::load_full(self.backend.as_ref(), entry) + } + /// 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. @@ -1091,7 +1114,17 @@ impl Session { /// built-in of the same name; presets created before the store finished loading win), hide /// 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 } = opened; + let Opened { store, presets, hidden_builtins, order, mut warnings, actions: disk, patterns } = opened; + // Stored patterns join the library after the built-ins; patterns imported before the + // store finished loading keep theirs (same id). + for l in patterns { + if !self.patterns.items.iter().any(|q| q.id == l.pattern.id) { + if let Some(size) = l.full { + self.patterns.stored.insert(l.pattern.id.clone(), size); + } + self.patterns.items.push(l.pattern); + } + } let lib = &mut self.tools.presets; lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name)))); for p in presets { @@ -1151,6 +1184,7 @@ impl Session { } } st.sync_actions(&self.actions.list, actions_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..7a17c25167 --- /dev/null +++ b/crates/engine/src/preset_store/patterns.rs @@ -0,0 +1,282 @@ +//! 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, +} + +/// 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 builtin_ids() -> &'static HashSet { + static IDS: std::sync::OnceLock> = std::sync::OnceLock::new(); + IDS.get_or_init(|| crate::pattern_cmds::builtin().into_iter().map(|p| p.id).collect()) +} + +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 (Vec::new(), files), + 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 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) { + let builtins = builtin_ids(); + let mut want: Vec = library + .items + .iter() + .filter(|p| !builtins.contains(&p.id) && !files.failed.contains(&p.id)) + .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 { + 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()) || 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) { + Ok(()) => written.push(e), + Err(err) => { + warnings.push(err); + files.failed.insert(e.id); + } + } + } + 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 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.index = index.patterns; +} + +fn write_pattern(backend: &dyn PresetBackend, p: &Pattern, e: &Entry) -> 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))?; + backend.write(&preview_name(&e.id), &small).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name))?; + } + backend.write(&e.file, &bytes).map_err(|err| format!("Couldn't save pattern \"{}\": {err}", p.name)) +} diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index 860ebd4fc3..db514ebd51 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -703,3 +703,114 @@ fn an_actions_file_that_failed_to_load_is_not_overwritten() { assert_eq!(mem.files.lock().unwrap().get(ACTIONS_FILE), Some(&newer), "an actions file the store could not read was overwritten"); assert!(!s.preset_store.as_mut().unwrap().take_warnings().is_empty()); } + +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() +} + +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(); +} + +/// 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)]); +} + +/// 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"); +} + +/// 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"); + 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"); + // 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()); +} diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e2c1cf2bfa..ba0bf14517 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1146,6 +1146,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()) } @@ -1277,12 +1281,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/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 1caaff6ce2..b4c6781a5c 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -328,6 +328,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()); } /// Scales `id` to 10 % around the origin and back, returning the composite. diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 380b6a7200..6e2cbe2bed 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -961,8 +961,9 @@ mod tests { #[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/tests.rs b/crates/engine/src/tests.rs index 04063d3073..dcb514f714 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -566,6 +566,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/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index a5c2f15e9b..3c91886f06 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -121,6 +121,8 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result = app .session diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..f949a196b9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (`.atn` import maps 116 events; `.pat` patterns persist in the preset store; earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -131,7 +131,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R/W | `engine/src/kys.rs` (`write`: Edit › Keyboard Shortcuts › Export…, rows carry our id; Photoshop reading our files is unverified) | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (112 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (116 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | | Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | @@ -160,6 +160,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | 2026-10-13 | minor | 16-bit Posterize bins the 16-bit sample and lands on 8-bit levels through a 16-bit step, as Photoshop does (RGB, Grayscale and the CMYK/Lab channel path): psd-tools posterize_16bits_rgb and posterize_16bits_grayscale pass; psd-tools floor → 241 of 309 (main 239) | | 2026-10-13 | minor | Gradient Map adjustment layers are driven by the W3C luminosity (0.3, 0.59, 0.11), not the Rec. 601 grey: rgb8 and rgb16 gradient-map.psd pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | PSD: a "Saving Merged Transparency" global block (`Mtrn`, `Mt16`, `Mt32`; Photoshop's 32-bit documents keep a positive layer count) marks the merged image's first extra channel as its transparency: opening such a file no longer adds a "Transparency" alpha channel, and the oracle compares psd-tools transparentbg.psd's alpha (83.7 % → 0.9 % of pixels off; its shape edges remain) | +| 2026-10-13 | minor | `.atn`: 116 events (Create Clipping Mask, Apply Locking, Diffuse, New Snapshot, Clear History, smart filter mask and blending, Shift-click layer ranges); imported `.pat` patterns are kept in the preset store across restarts | | 2026-10-13 | minor | Palette PNGs open in Indexed Color mode (#4804) | | 2026-10-12 | minor | `.atn`: 112 events mapped (Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, points, Bitmap) | | 2026-10-12 | minor | DDS textures open, read-only (#1327) | diff --git a/docs/gaps.md b/docs/gaps.md index faf0884c89..aff578deac 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (G11: `.atn` import maps 116 events, CopyCat plays, patterns persist, Shift Edge scale; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -99,7 +99,7 @@ what an agent can't finish alone. Duplicate Layers into a new document, Bitmap halftone screens…) load as kept Photoshop steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 112 Photoshop events map to commands both ways (Transform, + and `actions.load`/`actions.save`; 116 Photoshop events map to commands both ways (Transform, Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional since the third and fourth PRs; Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill @@ -112,7 +112,22 @@ what an agent can't finish alone. now smooths the spray's displacement, so high values tear an edge without detaching specks, as Photoshop's does (calibrated by eye on the Distress Press guide's screenshots). Crystallize fills each cell with the colour at its point instead of the cell's average, so a black-and-white - mask keeps hard edges (not yet checked against a Photoshop render). The panel groups actions + mask keeps hard edges (not yet checked against a Photoshop render). Select and Mask's Shift Edge + moves an edge by its soft width (radius, feather or smooth; 1 px for a hard edge) instead of a + fixed 4 px, so Debaser's two −100% chokes leave a thin rim, not a solid black band around the + ink (2026-10-13; scale not yet measured in Photoshop). Since 2026-10-13 also Create Clipping + Mask, Apply Locking and lock/Advanced Blending `set`s, Diffuse, New Snapshot (replace + existing), Clear History (`history.clear`), deleting a fill layer's or smart object's filter + mask (`ifPresent`: Photoshop adds that mask by default, PhotoCraft doesn't), smart filter + Blending Options, Shift-click layer ranges and Select and Mask's newer "output to selection"; + Save in an Edit Contents document saves into its smart object during playback, and Bring to + Front of the front layer is a no-op. CopyCat (244 steps) plays end to end with its `.pat`; + AJ_Actions2023 keeps Lens Correction (a recorded lens profile) and the Timeline frame steps + unmapped; Line and Color plays except for calls to actions from other sets. Imported and + defined patterns persist in the preset store (`patterns.json` plus one `.pat` per pattern); + patterns over 128 px open as a preview and are read at full size when used, so gigabyte + texture libraries cost nothing at start. + The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents (AUTO-219-2 done). @@ -288,6 +303,7 @@ what an agent can't finish alone. | 2026-10-13 | minor | G1: 16-bit Posterize bins the 16-bit sample, as Photoshop does (exact on two oracles); psd-tools floor → 241 of 309 | | 2026-10-13 | minor | G1: Gradient Map driven by the W3C luminosity (0.3, 0.59, 0.11), as Photoshop is; rgb8/rgb16 gradient-map pass; Photoshop-oracle floor 145 → 147 of 258 | | 2026-10-13 | minor | G6: stale "missing tool" list corrected against `main` — Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (P flyout, #4144) are live tools, so the toolbox is 67 of 68 and Art History Brush is the only one missing; no percentage or estimate changed | +| 2026-10-13 | minor | G11: `.atn` import maps 116 events; CopyCat, AJ_Actions2023 and Line and Color sets import (CopyCat plays end to end); patterns persist across restarts; Shift Edge scale | | 2026-10-13 | minor | G4: Hue Jitter amount-track appearance (#3825, partial); no readiness percentage or estimate changed | | 2026-10-13 | minor | G4: minimum inputs in Shape Dynamics and Transfer follow their Control (#3825, partial); no readiness percentage or estimate changed | | 2026-10-12 | minor | G5: Free Transform options bar H/V skew fields done | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..d30937017e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (`.atn` import maps 116 events, CopyCat plays end to end; imported patterns persist; Shift Edge scales with edge softness; earlier: on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (112 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (116 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 112 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-13, 116 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (116 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | @@ -567,6 +567,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). |---|---|---| | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | +| 2026-10-13 | minor | `.atn` import: 116 events (clipping mask, locks, Diffuse, snapshots, Clear History, smart filter blending); CopyCat plays end to end; patterns persist across restarts; Shift Edge scale; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | | 2026-10-12 | minor | `.atn` import: 112 events mapped (TrueGrit Texture Supply sets play end to end); percentages unchanged | | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | From c96891c298151c5cb11a55928b9e1c27911f38bd Mon Sep 17 00:00:00 2001 From: John Cambra <24539725+VegaEX@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:21:08 -0700 Subject: [PATCH 2/2] Patterns: don't re-read a large saved pattern's file on every use Checks that only need a pattern's id (Pattern Stamp's enabled state, the Patterns panel's move, the New Pattern Fill dialog) now use pattern_cmds::resolve_id and never read pixels. The preset store keeps the last pattern it read in full, so painting with the same 4500 px texture again doesn't re-read tens of megabytes per stroke. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/pattern_cmds.rs | 12 ++++++++++++ crates/engine/src/preset_store.rs | 19 +++++++++++++++++-- crates/engine/src/preset_store/tests.rs | 12 ++++++++++++ crates/engine/src/presets/patterns.rs | 2 +- .../engine/src/retouch_cmds/pattern_stamp.rs | 2 +- crates/ui-egui/src/pattern_fill_ui.rs | 4 ++-- 6 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 62a8c5db5d..d7d3345eaf 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -132,6 +132,18 @@ fn library_pattern<'a>(s: &'a Session, key: &str) -> Option<&'a Pattern> { 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). diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 3d538c5bd1..8a9c4485df 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -561,6 +561,9 @@ pub struct PresetStore { 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. @@ -667,6 +670,7 @@ pub fn open(backend: Box) -> Opened { 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 { @@ -1057,12 +1061,23 @@ impl PresetStore { /// Write the pattern library's changes (see [`patterns`]). pub fn sync_patterns(&mut self, library: &crate::pattern_cmds::PatternLibrary) { 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 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 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"))?; - patterns::load_full(self.backend.as_ref(), entry) + 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. diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index db514ebd51..4a80d392d1 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -796,9 +796,21 @@ fn large_patterns_load_on_use() { 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(); 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/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index 0dc9f102ac..3ac575afcb 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -26,7 +26,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(()) } } pub(super) fn run(s: &mut Session, p: &Value) -> Result { diff --git a/crates/ui-egui/src/pattern_fill_ui.rs b/crates/ui-egui/src/pattern_fill_ui.rs index 574a221367..7d2e8ce445 100644 --- a/crates/ui-egui/src/pattern_fill_ui.rs +++ b/crates/ui-egui/src/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)) }