From 51f9abaad778ccb92d1574dea44bb7ff671bc4ea Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 9 Oct 2026 23:00:12 +0200 Subject: [PATCH] Brushes: decode imported tips on demand, not at start (#1843) (#1949) Opening the preset store decoded every imported tip at full resolution, and each tip then lived twice: in the session's library and in the store's snapshot of the last sync. A generated 600-brush library (16-bit tips, 800-2000 px, 2.27 GB decoded) cost about 4.5 GB after import and again after every restart. Library presets now hold their bitmaps as StoredTile references with a small saved preview (at most 96 px): photocraft-paint gains TipShape::Stored and Pattern::Stored (rendered with the full bitmap when loaded, else the preview), and the store writes each tip's size and preview with its group. Opening the store reads previews only; once a group is written its presets drop their full bitmaps. A tip is decoded when its brush is used (picking a preset, a stroke naming one, exporting) into a 256 MB least-recently-used cache. A loaded stored tip serialises as `sampled`, so tool presets, actions and brush.get keep the same JSON. Stores written before this change have no previews: their tips are decoded once at the next start, eight at a time, and the groups rewritten. A missing or corrupt tip file is now reported when the brush is used. Measured with crates/engine/examples/abr_memory.rs on the 600-brush library from crates/io/examples/gen_abr_library.rs (Windows, release): after import 4565 -> 38 MB working set, after restart 4551 -> 20 MB, after using 5 brushes 4560 -> 37 MB; store open 1.47 s -> 0.05 s. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/examples/abr_memory.rs | 140 +++++++ crates/engine/src/brush_cmds.rs | 12 +- crates/engine/src/edit_menu_cmds.rs | 8 +- crates/engine/src/preset_import_cmds.rs | 4 +- crates/engine/src/preset_store.rs | 502 +++++++++++++++++++----- crates/engine/src/preset_store/tests.rs | 204 +++++++++- crates/io/examples/gen_abr_library.rs | 163 ++++++++ crates/paint/src/brush.rs | 96 ++++- crates/paint/src/lib.rs | 2 +- crates/paint/src/render.rs | 10 +- crates/paint/src/tile.rs | 107 ++++- crates/ui-egui/src/brush_preview.rs | 34 +- crates/ui-egui/src/brush_sections.rs | 29 +- 13 files changed, 1179 insertions(+), 132 deletions(-) create mode 100644 crates/engine/examples/abr_memory.rs create mode 100644 crates/io/examples/gen_abr_library.rs diff --git a/crates/engine/examples/abr_memory.rs b/crates/engine/examples/abr_memory.rs new file mode 100644 index 0000000000..9cec8ec1de --- /dev/null +++ b/crates/engine/examples/abr_memory.rs @@ -0,0 +1,140 @@ +//! Memory of a large imported brush library (#1843): import `.abr` files into a session with a +//! preset store on disk, or reopen that store (a restart), then use a few brushes, printing the +//! process memory after each step. Run each phase as its own process so freed memory doesn't hide +//! what a fresh start costs. Generate a library with `photocraft-io`'s `gen_abr_library` example. +//! +//! ```text +//! cargo run --release -p photocraft-engine --example abr_memory -- import +//! cargo run --release -p photocraft-engine --example abr_memory -- reopen +//! ``` +//! +//! Memory comes from PowerShell `Get-Process` on Windows and `/proc/self/status` elsewhere. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::time::Instant; + +use photocraft_engine::Session; +use photocraft_engine::paint::{Pattern, TipShape}; +use serde_json::json; + +fn memory() -> String { + #[cfg(windows)] + { + let pid = std::process::id(); + let cmd = format!("$p = Get-Process -Id {pid}; '{{0:N0}} MB working set, {{1:N0}} MB private' -f ($p.WorkingSet64/1MB), ($p.PrivateMemorySize64/1MB)"); + std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &cmd]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|e| e.to_string()) + } + #[cfg(not(windows))] + { + std::fs::read_to_string("/proc/self/status") + .unwrap_or_default() + .lines() + .filter(|l| l.starts_with("VmRSS") || l.starts_with("VmHWM")) + .collect::>() + .join(", ") + } +} + +/// Full-size tip bitmaps held by the session's preset library (count, bytes), and the bytes of +/// the previews of stored tips that aren't loaded. +fn library_tip_bytes(s: &Session) -> (usize, u64, u64) { + let (mut n, mut bytes, mut previews) = (0, 0u64, 0u64); + let mut add = |full: Option<&photocraft_engine::paint::GrayTile>, preview: Option<&photocraft_engine::paint::GrayTile>| { + if let Some(g) = full { + n += 1; + bytes += g.data.len() as u64 * 2; + } + if let Some(g) = preview { + previews += g.data.len() as u64 * 2; + } + }; + for p in &s.tools.presets { + let b = &p.brush; + for t in [&b.tip, &b.dual_brush.tip] { + match t { + TipShape::Sampled(g) => add(Some(g), None), + TipShape::Stored(r) => add(r.full.as_deref(), Some(&r.preview)), + TipShape::Round => {} + } + } + match &b.texture.pattern { + Pattern::Tile(g) => add(Some(g), None), + Pattern::Stored(r) => add(r.full.as_deref(), Some(&r.preview)), + Pattern::Procedural { .. } => {} + } + } + (n, bytes, previews) +} + +fn report(s: &Session, step: &str) { + let (n, b, p) = library_tip_bytes(s); + let cache = s.preset_store.as_ref().map(|st| st.tip_cache()).unwrap_or_default(); + println!( + "{step}: {} | {} presets, {n} full tips in the library ({:.0} MB), previews {:.1} MB, tip cache {} tips {:.0} MB ({} decodes)", + memory(), + s.tools.presets.len(), + b as f64 / 1048576.0, + p as f64 / 1048576.0, + cache.tips, + cache.bytes as f64 / 1048576.0, + cache.decodes + ); +} + +fn use_brushes(s: &mut Session, count: usize) { + let names: Vec = s.tools.presets.iter().filter(|p| !p.builtin).map(|p| p.name.clone()).take(count).collect(); + s.execute("file.new", json!({"width": 800, "height": 600})).unwrap(); + let t = Instant::now(); + for (i, n) in names.iter().enumerate() { + s.execute("tools.setBrush", json!({"preset": n})).unwrap(); + let y = 50.0 + i as f64 * 40.0; + s.execute("paint.stroke", json!({"points": [[50.0, y], [400.0, y], [700.0, y + 20.0]], "brush": {"size": 60.0}})).unwrap(); + } + println!("used {} brushes in {:.2} s", names.len(), t.elapsed().as_secs_f64()); + report(s, &format!("after using {} brushes", names.len())); +} + +fn main() { + let args: Vec = std::env::args().collect(); + println!("start: {}", memory()); + match args.get(1).map(String::as_str) { + Some("import") => { + let (abr, store) = (args.get(2).expect("abr dir"), args.get(3).expect("store dir")); + let _ = std::fs::remove_dir_all(store); + let mut s = Session::new(); + s.attach_preset_store(photocraft_engine::preset_store::open_dir(store)); + let mut files: Vec<_> = std::fs::read_dir(abr).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "abr")).collect(); + files.sort(); + let t = Instant::now(); + for f in &files { + let r = s.execute("brush.presets.importAbr", json!({"path": f.to_string_lossy()})).unwrap(); + println!("imported {} ({} presets)", f.display(), r["count"]); + } + println!("import + store sync: {:.2} s", t.elapsed().as_secs_f64()); + for w in s.preset_store.as_mut().map(|st| st.take_warnings()).unwrap_or_default() { + println!("warning: {w}"); + } + report(&s, "after import"); + use_brushes(&mut s, 5); + } + Some("reopen") => { + let store = args.get(2).expect("store dir"); + let t = Instant::now(); + let opened = photocraft_engine::preset_store::open_dir(store); + let open_s = t.elapsed().as_secs_f64(); + let mut s = Session::new(); + let w = s.attach_preset_store(opened); + println!("store open {open_s:.2} s, attach {:.2} s, {} warnings", t.elapsed().as_secs_f64() - open_s, w.len()); + for w in w.iter().take(5) { + println!("warning: {w}"); + } + report(&s, "after reopen"); + use_brushes(&mut s, 5); + } + _ => eprintln!("usage: abr_memory import | reopen "), + } +} diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 4c9dd4271a..94d62069a7 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -182,6 +182,8 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result if let Some(patch) = p.get("brush").filter(|v| v.is_object()) { b = merge_brush(&b, patch, cmd)?; } + // A library preset's tips stay on disk until a stroke uses them (#1843). + s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; if let Some(v) = num(p, "size") { b.size = v.max(0.5); } @@ -569,10 +571,14 @@ fn presets_list(s: &mut Session, p: &Value) -> Result { "builtin": pr.builtin, "size": pr.brush.size, "hardness": pr.brush.hardness, - "tip": match &pr.brush.tip { TipShape::Round => "round", TipShape::Sampled(_) => "sampled" }, + "tip": match &pr.brush.tip { TipShape::Round => "round", TipShape::Sampled(_) | TipShape::Stored(_) => "sampled" }, }); if full { - v["brush"] = brush_json(&pr.brush); + // Full settings embed the bitmaps: load stored tips (a tip that can't be loaded + // stays a reference to the store). + let mut b = pr.brush.clone(); + let _ = s.load_brush_tips(&mut b); + v["brush"] = brush_json(&b); } v }).collect::>() @@ -703,6 +709,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { } b = merge_brush(&b, &patch, cmd)?; validate_brush(&b, cmd)?; + // The tool's brush always paints with full tips: load a library preset's from the store. + s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?; let before = std::mem::replace(&mut s.tools.brush, b); // A coalesced gesture (one slider drag) journals as one call: remember the brush it started from. let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none()); diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index bbf838ba29..a0b60fdce6 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -893,7 +893,13 @@ fn export_import(s: &mut Session, p: &Value) -> Result { format: PRESET_FORMAT.into(), version: 1, brushes: if want("brushes") { - s.tools.presets.iter().filter(|b| !b.builtin || bool_or(p, "includeBuiltins", false)).cloned().collect() + let mut out: Vec = + s.tools.presets.iter().filter(|b| !b.builtin || bool_or(p, "includeBuiltins", false)).cloned().collect(); + // The file embeds every bitmap: load the tips the preset store keeps (#1843). + for b in &mut out { + s.load_brush_tips(&mut b.brush).map_err(|e| bad(cmd, format!("brush preset `{}`: {e}", b.name)))?; + } + out } else { Vec::new() }, diff --git a/crates/engine/src/preset_import_cmds.rs b/crates/engine/src/preset_import_cmds.rs index f4a13a8aca..8f36918850 100644 --- a/crates/engine/src/preset_import_cmds.rs +++ b/crates/engine/src/preset_import_cmds.rs @@ -102,7 +102,9 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io && let Some(first) = names.first() && let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first) { - s.tools.brush = pr.brush.clone().picked_over(&s.tools.brush); + let mut b = pr.brush.clone().picked_over(&s.tools.brush); + s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?; + s.tools.brush = b; } Ok(json!({ "group": group, "imported": names, "count": names.len(), "version": imp.version, "warnings": imp.warnings })) } diff --git a/crates/engine/src/preset_store.rs b/crates/engine/src/preset_store.rs index 593e2db44f..d4360f16e4 100644 --- a/crates/engine/src/preset_store.rs +++ b/crates/engine/src/preset_store.rs @@ -5,13 +5,22 @@ //! //! - `-.pcbrushes`: one JSON file per preset group (folder) holding the presets' //! settings with every bitmap (sampled tip, Dual Brush tip, texture tile) replaced by a -//! reference to a tip file; +//! reference to a tip file, plus each tip's size and the tip file of its small preview; //! - `tips/.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when //! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only //! rewrites a small JSON file; a tip no group references any more is deleted; //! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop //! in the Brushes panel persists) and the built-in presets the user deleted. //! +//! Full tips load on demand (#1843): a stored preset's bitmaps are [`StoredTile`]s holding only a +//! preview ([`GrayTile::preview`], at most [`PREVIEW_SIDE`] px), which the Brushes panel and the +//! picker draw. Opening the store reads previews, never a full tip, and once a group is written +//! its presets drop their bitmaps the same way. A tip is decoded when its brush is used +//! ([`Session::load_brush_tips`]: picking a preset, a stroke naming one, exporting), and kept in a +//! byte-capped least-recently-used cache ([`TIP_CACHE_BYTES`]), so memory grows with the brushes +//! in use, not with the size of the library. Stores written before #1843 have no previews: their +//! tips are decoded once at the next start, a few at a time, and the groups rewritten. +//! //! Built-in presets are never written (they are regenerated on start). The store syncs after //! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups //! whose content changed are rewritten. Every write is atomic (temp file + rename) and bounded @@ -29,10 +38,12 @@ //! A missing file is an empty list. A corrupt or oversized file is skipped with a warning. //! Headless and web sessions have no store, so their actions stay in memory. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Read; +use std::sync::{Arc, Mutex, PoisonError}; -use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Pattern, TipShape}; +use photocraft_paint::tile::PREVIEW_SIDE; +use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Pattern, StoredTile, TipShape}; use serde::{Deserialize, Serialize}; use crate::Session; @@ -255,6 +266,11 @@ pub fn encode_tip(t: &GrayTile) -> Vec { /// Inverse of [`encode_tip`]; any malformed, truncated or oversized input is an error. pub fn decode_tip(bytes: &[u8]) -> Result { + decode_tip_max(bytes, MAX_TIP_SIDE) +} + +/// [`decode_tip`] refusing (before inflating anything) a bitmap with a side over `max_side`. +fn decode_tip_max(bytes: &[u8], max_side: u32) -> Result { let head = bytes.get(..TIP_HEADER).ok_or("truncated tip header")?; if &head[..6] != TIP_MAGIC { return Err("not a PhotoCraft tip".into()); @@ -262,7 +278,7 @@ pub fn decode_tip(bytes: &[u8]) -> Result { let bits = head[6]; let u32_at = |i: usize| u32::from_le_bytes([head[i], head[i + 1], head[i + 2], head[i + 3]]); let (w, h) = (u32_at(7), u32_at(11)); - if w == 0 || h == 0 || w > MAX_TIP_SIDE || h > MAX_TIP_SIDE { + if w == 0 || h == 0 || w > MAX_TIP_SIDE.min(max_side) || h > MAX_TIP_SIDE.min(max_side) { return Err(format!("bad tip size {w}×{h}")); } let n = u64::from(w) * u64::from(h); @@ -314,6 +330,16 @@ struct StoredPreset { tips: TipRefs, } +/// What a group file records about each tip it references, so loading needs only the preview. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TipInfo { + width: u32, + height: u32, + /// Hash of the preview's tip file ([`GrayTile::preview`], at most [`PREVIEW_SIDE`] px). + preview: String, +} + #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct GroupFile { @@ -321,6 +347,10 @@ struct GroupFile { version: u32, group: String, presets: Vec, + /// Tip hash → size and preview. Missing in stores written before #1843: those tips are + /// decoded once at the next start to make their previews, then the group is rewritten. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + tips: BTreeMap, } #[derive(Default, Serialize, Deserialize)] @@ -347,37 +377,71 @@ pub fn group_file_name(group: &str) -> String { format!("{stem}-{}.{GROUP_EXT}", &h[..8]) } -/// Take the bitmaps out of a preset's settings (they are stored as tip files). -fn split_tips(mut b: BrushSettings) -> (BrushSettings, [Option; 3]) { - let tip = match std::mem::take(&mut b.tip) { - TipShape::Sampled(t) => Some(t), - TipShape::Round => None, - }; - let dual = match std::mem::take(&mut b.dual_brush.tip) { - TipShape::Sampled(t) => Some(t), - TipShape::Round => None, - }; - let texture = match std::mem::take(&mut b.texture.pattern) { - Pattern::Tile(t) => Some(t), - other => { - b.texture.pattern = other; - None - } - }; - (b, [tip, dual, texture]) +/// A preset bitmap as the settings hold it: embedded, or already a store reference. +enum Bitmap<'a> { + Full(&'a GrayTile), + Stored(&'a StoredTile), } -/// The opposite of [`split_tips`]: `None` when a referenced tip is missing. -fn join_tips(mut b: BrushSettings, refs: &TipRefs, tips: &HashMap) -> Option { - if let Some(h) = &refs.tip { - b.tip = TipShape::Sampled(tips.get(h)?.clone()); +/// The bitmaps of a preset's settings: sampled tip, Dual Brush tip, texture tile. +fn bitmaps(b: &BrushSettings) -> [Option>; 3] { + fn tip(t: &TipShape) -> Option> { + match t { + TipShape::Round => None, + TipShape::Sampled(g) => Some(Bitmap::Full(g)), + TipShape::Stored(r) => Some(Bitmap::Stored(r)), + } } - if let Some(h) = &refs.dual_tip { - b.dual_brush.tip = TipShape::Sampled(tips.get(h)?.clone()); + let texture = match &b.texture.pattern { + Pattern::Procedural { .. } => None, + Pattern::Tile(g) => Some(Bitmap::Full(g)), + Pattern::Stored(r) => Some(Bitmap::Stored(r)), + }; + [tip(&b.tip), tip(&b.dual_brush.tip), texture] +} + +/// A copy of `b` without its bitmaps (stored as tip files): no bitmap is cloned. +fn settings_only(b: &mut BrushSettings) -> BrushSettings { + let tip = std::mem::take(&mut b.tip); + let dual = std::mem::take(&mut b.dual_brush.tip); + let texture = match b.texture.pattern { + Pattern::Procedural { .. } => None, + _ => Some(std::mem::take(&mut b.texture.pattern)), + }; + let out = b.clone(); + b.tip = tip; + b.dual_brush.tip = dual; + if let Some(t) = texture { + b.texture.pattern = t; } - if let Some(h) = &refs.texture { - b.texture.pattern = Pattern::Tile(tips.get(h)?.clone()); + out +} + +/// Point `b`'s bitmaps at stored tiles (`refs` in [`bitmaps`] order); a `None` leaves a slot. +fn set_stored(b: &mut BrushSettings, refs: [Option; 3]) { + let [tip, dual, texture] = refs; + if let Some(r) = tip { + b.tip = TipShape::Stored(r); } + if let Some(r) = dual { + b.dual_brush.tip = TipShape::Stored(r); + } + if let Some(r) = texture { + b.texture.pattern = Pattern::Stored(r); + } +} + +/// Settings read from a group file with their tip references resolved: `None` when a referenced +/// tip is missing. +fn join_tips(mut b: BrushSettings, refs: &TipRefs, tiles: &HashMap) -> Option { + let get = |h: &Option| -> Option> { + match h { + None => Some(None), + Some(h) => tiles.get(h).cloned().map(Some), + } + }; + let r = [get(&refs.tip)?, get(&refs.dual_tip)?, get(&refs.texture)?]; + set_stored(&mut b, r); Some(b) } @@ -388,8 +452,71 @@ fn tip_file(hash: &str) -> String { /// What a group looked like when it was last written (or loaded). struct Synced { presets: Vec, + /// Tip files the group uses (full tips and previews). tips: Vec, bytes: u64, + /// Loaded from a file that lacks tip previews: rewrite it at the next sync. + stale: bool, +} + +/// The default byte budget of decoded full-size tips kept for reuse ([`PresetStore::load_tile`]). +pub const TIP_CACHE_BYTES: u64 = 256 << 20; + +/// Full-size tips decoded on demand, least recently used evicted first once over budget. +#[derive(Default)] +struct TipCache { + limit: u64, + tick: u64, + bytes: u64, + decodes: u64, + entries: HashMap, u64)>, +} + +impl TipCache { + fn get(&mut self, key: &str) -> Option> { + self.tick += 1; + let tick = self.tick; + self.entries.get_mut(key).map(|(t, used)| { + *used = tick; + t.clone() + }) + } + fn insert(&mut self, key: &str, t: Arc) { + self.tick += 1; + self.bytes += tile_bytes(&t); + if let Some((old, _)) = self.entries.insert(key.to_string(), (t, self.tick)) { + self.bytes = self.bytes.saturating_sub(tile_bytes(&old)); + } + self.evict(key); + } + /// Drop least recently used tips until within the limit (never `keep`, the one just used). + fn evict(&mut self, keep: &str) { + while self.bytes > self.limit { + let Some(oldest) = self.entries.iter().filter(|(k, _)| k.as_str() != keep).min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) else { + break; + }; + if let Some((t, _)) = self.entries.remove(&oldest) { + self.bytes = self.bytes.saturating_sub(tile_bytes(&t)); + } + } + } +} + +fn tile_bytes(t: &GrayTile) -> u64 { + t.data.len() as u64 * 2 +} + +/// The decoded-tip cache's state ([`PresetStore::tip_cache`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TipCacheStats { + /// Tips held. + pub tips: usize, + /// Bytes of the tips held. + pub bytes: u64, + /// Full tips decoded since the store opened (cache misses). + pub decodes: u64, + /// The byte budget. + pub limit: u64, } /// The persistent brush preset store attached to a session (see the module docs). @@ -405,6 +532,8 @@ pub struct PresetStore { /// [`crate::actions_cmds::ActionState::rev`] last written (or loaded). actions_rev: u64, warnings: Vec, + /// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load). + cache: Mutex, } /// A loaded store: the presets on disk plus what the session should hide. @@ -477,33 +606,47 @@ pub fn open(backend: Box) -> Opened { } } } - // Decode every referenced tip once. + // Every referenced tip as a stored tile: its size and preview come from the group files, so + // no full tip is decoded here (they load when a brush is used, `PresetStore::load_tile`). let wanted: Vec = { let mut seen = HashSet::new(); parsed.iter().flat_map(|(_, _, g)| g.presets.iter().flat_map(|p| p.tips.hashes())).filter(|h| seen.insert(h.as_str())).cloned().collect() }; - let decoded = decode_tips(backend.as_ref(), &wanted, &tip_sizes); - let mut tips: HashMap = HashMap::with_capacity(decoded.len()); - for (h, r) in wanted.iter().zip(decoded) { - match r { - Ok(t) => { - tips.insert(h.clone(), t); - } - Err(e) => warnings.push(format!("brush presets: tip {h} skipped: {e}")), + let mut info: HashMap = HashMap::new(); + for (_, _, g) in &parsed { + for (h, i) in &g.tips { + info.entry(h.clone()).or_insert_with(|| i.clone()); } } + let (tiles, regenerated) = load_tiles(backend.as_ref(), &wanted, &tip_sizes, &info, &mut warnings); let actions = load_actions(backend.as_ref(), &mut warnings); - let mut store = PresetStore { backend, synced_rev: None, groups: HashMap::new(), tips: HashMap::new(), index: None, actions_rev: 0, warnings: Vec::new() }; + let mut store = PresetStore { + backend, + synced_rev: None, + groups: HashMap::new(), + tips: HashMap::new(), + index: None, + actions_rev: 0, + warnings: Vec::new(), + cache: Mutex::new(TipCache { limit: TIP_CACHE_BYTES, ..Default::default() }), + }; let mut presets = Vec::new(); for (file, size, g) in parsed { let mut items = Vec::with_capacity(g.presets.len()); let mut hashes = Vec::new(); let mut missing = 0; + let mut stale = false; for sp in g.presets { - match join_tips(sp.brush, &sp.tips, &tips) { + match join_tips(sp.brush, &sp.tips, &tiles) { Some(brush) => { - hashes.extend(sp.tips.hashes().cloned()); + for h in sp.tips.hashes() { + stale |= regenerated.contains(h) || !g.tips.contains_key(h); + hashes.push(h.clone()); + if let Some(i) = info.get(h).filter(|i| tip_sizes.contains_key(&i.preview)) { + hashes.push(i.preview.clone()); + } + } items.push(BrushPreset { name: sp.name, brush, builtin: false, group: g.group.clone() }); } None => missing += 1, @@ -521,7 +664,7 @@ pub fn open(backend: Box) -> Opened { } } presets.extend(items.iter().cloned()); - store.groups.insert(file, Synced { presets: items, tips: hashes, bytes: size }); + store.groups.insert(file, Synced { presets: items, tips: hashes, bytes: size, stale }); } // Tip files nothing references are garbage (e.g. left by a crash), unless a group failed to // load: its tips must survive so a fixed file still works. @@ -564,26 +707,66 @@ fn missing_file(err: &str) -> bool { lower.contains("not found") || lower.contains("no such file") || lower.contains("os error 2") } -/// Read and decode tips, in parallel on native targets. -fn decode_tips(backend: &dyn PresetBackend, hashes: &[String], sizes: &HashMap) -> Vec> { - let read = |h: &String| -> Result, String> { +/// Largest preview tip file read. +const MAX_PREVIEW_BYTES: u64 = 1 << 20; +/// Tips decoded at a time when making missing previews (bounds the memory of that one-off pass). +const PREVIEW_BATCH: usize = 8; + +/// The stored tiles for `hashes` (tip files that are missing or unreadable become warnings), and +/// the hashes whose preview had to be made from the full tip: stores written before #1843 have +/// none, so their tips are decoded once, a few at a time, and the groups rewritten at the next sync. +fn load_tiles( + backend: &dyn PresetBackend, + hashes: &[String], + sizes: &HashMap, + info: &HashMap, + warnings: &mut Vec, +) -> (HashMap, HashSet) { + let mut tiles = HashMap::with_capacity(hashes.len()); + let mut todo = Vec::new(); + let sane = |w: u32, h: u32| w > 0 && h > 0 && w <= MAX_TIP_SIDE && h <= MAX_TIP_SIDE && u64::from(w) * u64::from(h) <= MAX_TIP_SAMPLES; + for h in hashes { if !sizes.contains_key(h) { - return Err("file missing".into()); + warnings.push(format!("brush presets: tip {h} skipped: file missing")); + continue; + } + let preview = info.get(h).filter(|i| sane(i.width, i.height) && sizes.contains_key(&i.preview)).and_then(|i| { + let bytes = backend.read(&tip_file(&i.preview), MAX_PREVIEW_BYTES).ok()?; + Some((i, decode_tip_max(&bytes, PREVIEW_SIDE).ok()?)) + }); + match preview { + Some((i, p)) => { + tiles.insert(h.clone(), StoredTile { key: h.clone(), width: i.width, height: i.height, preview: Arc::new(p), full: None }); + } + None => todo.push(h.clone()), } - backend.read(&tip_file(h), MAX_TIP_BYTES) - }; - // Reads stay sequential (the backend need not be Sync); decoding is the expensive part. - let raw: Vec, String>> = hashes.iter().map(read).collect(); - let dec = |r: &Result, String>| r.as_ref().map_err(Clone::clone).and_then(|b| decode_tip(b)); - #[cfg(not(target_arch = "wasm32"))] - { - use rayon::prelude::*; - raw.par_iter().map(dec).collect() } - #[cfg(target_arch = "wasm32")] - { - raw.iter().map(dec).collect() + let mut regenerated = HashSet::new(); + for batch in todo.chunks(PREVIEW_BATCH) { + // Reads stay sequential (the backend need not be Sync); decoding is the expensive part. + let raw: Vec, String>> = batch.iter().map(|h| backend.read(&tip_file(h), MAX_TIP_BYTES)).collect(); + let make = |r: &Result, String>| -> Result<(u32, u32, GrayTile), String> { + let t = decode_tip(r.as_ref().map_err(Clone::clone)?)?; + Ok((t.width, t.height, t.preview(PREVIEW_SIDE))) + }; + #[cfg(not(target_arch = "wasm32"))] + let made: Vec<_> = { + use rayon::prelude::*; + raw.par_iter().map(make).collect() + }; + #[cfg(target_arch = "wasm32")] + let made: Vec<_> = raw.iter().map(make).collect(); + for (h, r) in batch.iter().zip(made) { + match r { + Ok((w, ht, p)) => { + tiles.insert(h.clone(), StoredTile { key: h.clone(), width: w, height: ht, preview: Arc::new(p), full: None }); + regenerated.insert(h.clone()); + } + Err(e) => warnings.push(format!("brush presets: tip {h} skipped: {e}")), + } + } } + (tiles, regenerated) } /// Open a store directory (desktop). Never fails: problems become warnings. @@ -617,26 +800,69 @@ impl PresetStore { self.groups.values().map(|g| g.bytes).sum::() + self.tips.values().sum::() } + /// The decoded-tip cache: tips held, their bytes, decodes so far and the budget. + pub fn tip_cache(&self) -> TipCacheStats { + let c = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + TipCacheStats { tips: c.entries.len(), bytes: c.bytes, decodes: c.decodes, limit: c.limit } + } + + /// Change the decoded-tip cache's byte budget (evicting down to it at once). + pub fn set_tip_cache_limit(&self, bytes: u64) { + let mut c = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + c.limit = bytes; + c.evict(""); + } + + /// The full bitmap of a stored tile: the tile's own when loaded, else from the cache, else + /// read and decoded from its tip file (then cached; least recently used tips are dropped once + /// the cache is over budget). A missing, corrupt or mismatched tip file is an error. + pub fn load_tile(&self, t: &StoredTile) -> Result, String> { + if let Some(f) = &t.full { + return Ok(f.clone()); + } + let mut cache = self.cache.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(f) = cache.get(&t.key) { + return Ok(f); + } + if !self.tips.contains_key(&t.key) { + return Err(format!("brush tip {} is not in the preset store", t.key)); + } + let bytes = self.backend.read(&tip_file(&t.key), MAX_TIP_BYTES)?; + let full = decode_tip(&bytes).map_err(|e| format!("brush tip {}: {e}", t.key))?; + if (full.width, full.height) != (t.width, t.height) { + return Err(format!("brush tip {} is {}×{} px, expected {}×{}", t.key, full.width, full.height, t.width, t.height)); + } + let full = Arc::new(full); + cache.decodes += 1; + cache.insert(&t.key, full.clone()); + Ok(full) + } + /// Bring the files in line with `presets` (built-ins skipped): write changed groups, delete - /// removed ones and their unreferenced tips, update the index. - pub fn sync(&mut self, presets: &[BrushPreset]) { + /// removed ones and their unreferenced tips, update the index. The bitmaps of every group + /// written become stored tiles (#1843): the files hold them, so the presets keep previews only. + pub fn sync(&mut self, presets: &mut [BrushPreset]) { // User presets by group, in first-appearance order. - let mut order: Vec<(String, Vec<&BrushPreset>)> = Vec::new(); - for p in presets.iter().filter(|p| !p.builtin) { + let mut order: Vec<(String, Vec)> = Vec::new(); + for (i, p) in presets.iter().enumerate().filter(|(_, p)| !p.builtin) { match order.iter_mut().find(|(g, _)| *g == p.group) { - Some((_, v)) => v.push(p), - None => order.push((p.group.clone(), vec![p])), + Some((_, v)) => v.push(i), + None => order.push((p.group.clone(), vec![i])), } } let mut files = Vec::with_capacity(order.len()); - for (group, items) in &order { + for (group, idx) in &order { let file = group_file_name(group); files.push(file.clone()); - let unchanged = self.groups.get(&file).is_some_and(|s| s.presets.len() == items.len() && s.presets.iter().zip(items).all(|(a, b)| a == *b)); + let unchanged = self + .groups + .get(&file) + .is_some_and(|s| !s.stale && s.presets.len() == idx.len() && s.presets.iter().zip(idx).all(|(a, &i)| presets.get(i) == Some(a))); if unchanged { continue; } - if let Err(e) = self.write_group(&file, group, items) { + let mut items: Vec<&mut BrushPreset> = presets.iter_mut().filter(|p| !p.builtin && p.group == *group).collect(); + if let Err(e) = self.write_group(&file, group, &mut items) { self.warnings.push(format!("Couldn't save brush presets `{}`: {e}", if group.is_empty() { "(ungrouped)" } else { group })); } } @@ -690,39 +916,68 @@ impl PresetStore { } } - fn write_group(&mut self, file: &str, group: &str, items: &[&BrushPreset]) -> Result<(), String> { + /// Write one tip file (`others` bytes are already used by the rest of the store). + fn put_tip(&mut self, h: &str, t: &GrayTile, others: u64, new_bytes: &mut u64, name: &str) -> Result<(), String> { + let bytes = encode_tip(t); + if bytes.is_empty() || bytes.len() as u64 > MAX_TIP_BYTES { + return Err(format!("the tip of `{name}` is too large to save")); + } + *new_bytes += bytes.len() as u64; + if others + *new_bytes > MAX_STORE_BYTES { + return Err(format!("the preset store is full ({} MB limit)", MAX_STORE_BYTES >> 20)); + } + self.backend.write(&tip_file(h), &bytes)?; + self.tips.insert(h.to_string(), bytes.len() as u64); + Ok(()) + } + + /// Write a group file (and any tip or preview file it needs that isn't on disk yet). Once it + /// is written, the presets' bitmaps become stored tiles without their full bitmaps; on an + /// error the presets are left unchanged. + fn write_group(&mut self, file: &str, group: &str, items: &mut [&mut BrushPreset]) -> Result<(), String> { let others: u64 = self.groups.iter().filter(|(f, _)| f.as_str() != file).map(|(_, g)| g.bytes).sum::() + self.tips.values().sum::(); let mut stored = Vec::with_capacity(items.len()); + let mut tiles = Vec::with_capacity(items.len()); let mut hashes = Vec::new(); + let mut infos = BTreeMap::new(); let mut new_bytes = 0u64; - for p in items { - let (brush, bitmaps) = split_tips(p.brush.clone()); - let mut refs = [None, None, None]; - for (slot, t) in refs.iter_mut().zip(bitmaps) { - let Some(t) = t else { continue }; - if !t.is_valid() { - return Err(format!("preset `{}` has an invalid tip", p.name)); - } - let h = tip_hash(&t); - if !self.tips.contains_key(&h) { - let bytes = encode_tip(&t); - if bytes.is_empty() || bytes.len() as u64 > MAX_TIP_BYTES { - return Err(format!("the tip of `{}` is too large to save", p.name)); + for p in items.iter_mut() { + let mut refs: [Option; 3] = [None, None, None]; + for (slot, bm) in refs.iter_mut().zip(bitmaps(&p.brush)) { + let tile = match bm { + None => continue, + Some(Bitmap::Full(t)) => { + if !t.is_valid() { + return Err(format!("preset `{}` has an invalid tip", p.name)); + } + let h = tip_hash(t); + if !self.tips.contains_key(&h) { + self.put_tip(&h, t, others, &mut new_bytes, &p.name)?; + } + StoredTile { key: h, width: t.width, height: t.height, preview: Arc::new(t.preview(PREVIEW_SIDE)), full: None } } - new_bytes += bytes.len() as u64; - if others + new_bytes > MAX_STORE_BYTES { - return Err(format!("the preset store is full ({} MB limit)", MAX_STORE_BYTES >> 20)); + Some(Bitmap::Stored(r)) => { + if !self.tips.contains_key(&r.key) { + return Err(format!("the tip of `{}` is missing from the preset store", p.name)); + } + StoredTile { full: None, ..r.clone() } } - self.backend.write(&tip_file(&h), &bytes)?; - self.tips.insert(h.clone(), bytes.len() as u64); + }; + let ph = tip_hash(&tile.preview); + if !self.tips.contains_key(&ph) { + self.put_tip(&ph, &tile.preview, others, &mut new_bytes, &p.name)?; } - hashes.push(h.clone()); - *slot = Some(h); + hashes.push(tile.key.clone()); + hashes.push(ph.clone()); + infos.insert(tile.key.clone(), TipInfo { width: tile.width, height: tile.height, preview: ph }); + *slot = Some(tile); } - let [tip, dual_tip, texture] = refs; - stored.push(StoredPreset { name: p.name.clone(), brush, tips: TipRefs { tip, dual_tip, texture } }); + let key = |r: &Option| r.as_ref().map(|t| t.key.clone()); + let tips = TipRefs { tip: key(&refs[0]), dual_tip: key(&refs[1]), texture: key(&refs[2]) }; + stored.push(StoredPreset { name: p.name.clone(), brush: settings_only(&mut p.brush), tips }); + tiles.push(refs); } - let gf = GroupFile { format: GROUP_FORMAT.into(), version: 1, group: group.to_string(), presets: stored }; + let gf = GroupFile { format: GROUP_FORMAT.into(), version: 1, group: group.to_string(), presets: stored, tips: infos }; let bytes = serde_json::to_vec(&gf).map_err(|e| e.to_string())?; if bytes.len() as u64 > MAX_GROUP_BYTES { return Err(format!("the group is too large to save ({} MB limit)", MAX_GROUP_BYTES >> 20)); @@ -731,7 +986,11 @@ impl PresetStore { return Err(format!("the preset store is full ({} MB limit)", MAX_STORE_BYTES >> 20)); } self.backend.write(file, &bytes)?; - self.groups.insert(file.to_string(), Synced { presets: items.iter().map(|p| (*p).clone()).collect(), tips: hashes, bytes: bytes.len() as u64 }); + for (p, refs) in items.iter_mut().zip(tiles) { + set_stored(&mut p.brush, refs); + } + let presets = items.iter().map(|p| (**p).clone()).collect(); + self.groups.insert(file.to_string(), Synced { presets, tips: hashes, bytes: bytes.len() as u64, stale: false }); Ok(()) } @@ -825,12 +1084,67 @@ impl Session { let actions_rev = self.actions.rev; if let Some(st) = self.preset_store.as_mut() { if st.synced_rev != Some(rev) { - st.sync(&self.tools.presets); + st.sync(&mut self.tools.presets); st.synced_rev = Some(rev); + link_stored_tips(st, &mut self.tools.brush); } st.sync_actions(&self.actions.list, actions_rev); } } + + /// Load the stored tips of `b` (a preset's settings picked as the tool's brush, or exported) so + /// it paints with its full bitmaps. Tips already loaded or embedded are left alone. Fails when + /// a tip can't be loaded: its file is missing or corrupt, or there is no preset store. + pub fn load_brush_tips(&self, b: &mut BrushSettings) -> Result<(), String> { + let load = |r: &mut StoredTile| -> Result<(), String> { + if r.full.is_none() { + let st = self.preset_store.as_ref().ok_or_else(|| format!("brush tip {} is not available: no preset store is attached", r.key))?; + r.full = Some(st.load_tile(r)?); + } + Ok(()) + }; + if let TipShape::Stored(r) = &mut b.tip { + load(r)?; + } + if let TipShape::Stored(r) = &mut b.dual_brush.tip { + load(r)?; + } + if let Pattern::Stored(r) = &mut b.texture.pattern { + load(r)?; + } + Ok(()) + } +} + +/// After a sync, an embedded bitmap of the tool's brush that the store now holds (the brush was +/// just saved as a preset) becomes the stored tile, keeping its bitmap: the brush then matches its +/// preset (Brushes panel highlight) like a preset picked from the library. +fn link_stored_tips(st: &PresetStore, b: &mut BrushSettings) { + let link = |g: &GrayTile| -> Option { + if !g.is_valid() { + return None; + } + let h = tip_hash(g); + st.tips.contains_key(&h).then(|| StoredTile { key: h, width: g.width, height: g.height, preview: Arc::new(g.preview(PREVIEW_SIDE)), full: None }) + }; + if let TipShape::Sampled(g) = &b.tip + && let Some(r) = link(g) + && let TipShape::Sampled(g) = std::mem::take(&mut b.tip) + { + b.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r }); + } + if let TipShape::Sampled(g) = &b.dual_brush.tip + && let Some(r) = link(g) + && let TipShape::Sampled(g) = std::mem::take(&mut b.dual_brush.tip) + { + b.dual_brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(g)), ..r }); + } + if let Pattern::Tile(g) = &b.texture.pattern + && let Some(r) = link(g) + && let Pattern::Tile(g) = std::mem::take(&mut b.texture.pattern) + { + b.texture.pattern = Pattern::Stored(StoredTile { full: Some(Arc::new(g)), ..r }); + } } #[cfg(test)] diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index 6cf17d1ef7..42e33ad362 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -124,8 +124,11 @@ fn group_with_sampled_8_and_16_bit_tips_round_trips() { s.sync_preset_store(); let files = dir.files(); assert_eq!(files.iter().filter(|f| f.ends_with(".pcbrushes")).count(), 2, "{files:?}"); - // Four distinct bitmaps: the 64×48 tip is shared by two presets. - assert_eq!(files.iter().filter(|f| f.starts_with("tips/")).count(), 4, "{files:?}"); + // Four distinct bitmaps (the 64×48 tip is shared by two presets) plus the previews of the two + // 16-bit ones: an 8-bit tip this small is its own preview. + assert_eq!(files.iter().filter(|f| f.starts_with("tips/")).count(), 6, "{files:?}"); + // The session keeps previews, not the bitmaps (#1843). + assert!(user(&s).iter().all(|p| matches!(&p.brush.tip, TipShape::Stored(r) if !r.is_loaded()))); assert!(files.contains(&INDEX_FILE.to_string())); let (t, w) = session(&dir); @@ -147,7 +150,9 @@ fn commands_persist_and_delete_removes_from_disk() { let (mut s, _) = session(&dir); s.tools.brush.tip = TipShape::Sampled(tip16(20, 20, 4)); s.execute("brush.presets.save", json!({"name": "Scribble"})).unwrap(); - assert_eq!(dir.files().iter().filter(|f| f.starts_with("tips/")).count(), 1); + assert_eq!(dir.files().iter().filter(|f| f.starts_with("tips/")).count(), 2, "the tip and its preview"); + // The tool's brush now is the saved preset's stored tip, still loaded. + assert!(matches!(&s.tools.brush.tip, TipShape::Stored(r) if r.is_loaded())); let (t, _) = session(&dir); let p = photocraft_paint::presets::find(&t.tools.presets, "Scribble").expect("saved preset reloads"); assert_eq!(p.brush.tip, s.tools.brush.tip); @@ -289,9 +294,15 @@ fn corrupt_and_oversized_files_are_skipped_with_a_warning() { let h = tip_hash(&tip16(9, 9, 2)); be.write(&tip_file(&h), b"PCTIP1\x10garbage").unwrap(); let (mut t, w) = session(&dir); - assert_eq!(w.len(), 4, "{w:?}"); + assert_eq!(w.len(), 2, "{w:?}"); assert!(photocraft_paint::presets::find(&t.tools.presets, "Good").is_some()); - assert!(photocraft_paint::presets::find(&t.tools.presets, "Lost").is_none()); + // Full tips load when a brush is used (#1843): the broken one fails then, not at start. + assert!(photocraft_paint::presets::find(&t.tools.presets, "Lost").is_some()); + let before = t.tools.brush.clone(); + let e = t.execute("tools.setBrush", json!({"preset": "Lost"})).unwrap_err().to_string(); + assert!(e.contains(&h), "{e}"); + assert_eq!(t.tools.brush, before, "a failed pick leaves the brush alone"); + t.execute("tools.setBrush", json!({"preset": "Good"})).unwrap(); // The broken files are left alone for the user (never deleted by a sync). t.execute("brush.presets.save", json!({"name": "Another"})).unwrap(); let files = dir.files(); @@ -374,3 +385,186 @@ fn a_store_directory_that_does_not_exist_yet_opens_without_warnings() { let err = DirBackend::new(&dir).read(ACTIONS_FILE, MAX_ACTIONS_BYTES).unwrap_err(); assert!(missing_file(&err), "{err}"); } + +// ------------------------------------------------------------------ on-demand tips (#1843) + +/// A session on an in-memory store holding `n` presets with distinct `side`-px 8-bit tips (one +/// group), synced; plus the backend to reopen it. +fn library(n: u32, side: u32) -> (Session, MemBackend) { + let mem = MemBackend::default(); + let mut s = Session::new(); + s.attach_preset_store(open(Box::new(mem.clone()))); + for i in 0..n { + s.tools.presets.push(sampled(&format!("Big {i}"), "Big Set", tip8(side, side, i))); + } + s.brush_presets_changed(); + s.sync_preset_store(); + (s, mem) +} + +/// Bitmap samples a preset library holds (full tips, or previews of stored ones not loaded). +fn library_samples(s: &Session) -> usize { + let b = |p: &BrushPreset| { + [ + p.brush.tip.bitmap().map(|g| g.data.len()), + p.brush.dual_brush.tip.bitmap().map(|g| g.data.len()), + p.brush.texture.pattern.bitmap().map(|g| g.data.len()), + ] + }; + s.tools.presets.iter().flat_map(b).flatten().sum() +} + +fn cache(s: &Session) -> TipCacheStats { + s.preset_store.as_ref().unwrap().tip_cache() +} + +#[test] +fn reopening_a_library_decodes_no_tips_and_using_a_brush_decodes_only_its_own() { + let (n, side) = (20u32, 512u32); + let (s, mem) = library(n, side); + // Once written, the imported presets keep previews only (built-ins keep their small tips). + let budget = (n as usize + 40) * (PREVIEW_SIDE * PREVIEW_SIDE) as usize; + assert!(library_samples(&s) < budget, "{}", library_samples(&s)); + assert_eq!(cache(&s).decodes, 0); + let files = mem.list().unwrap(); + drop(s); + + // A restart: nothing is decoded at full size. + let mut t = Session::new(); + let w = t.attach_preset_store(open(Box::new(mem.clone()))); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(mem.list().unwrap(), files, "attaching an unchanged store rewrites nothing"); + let user_presets = user(&t); + assert_eq!(user_presets.len(), n as usize); + for p in &user_presets { + let TipShape::Stored(r) = &p.brush.tip else { panic!("{} is not a stored tip", p.name) }; + assert!(!r.is_loaded() && (r.width, r.height) == (side, side)); + assert!(r.preview.width <= PREVIEW_SIDE && r.preview.height <= PREVIEW_SIDE); + } + assert!(library_samples(&t) < budget); + assert_eq!(cache(&t), TipCacheStats { tips: 0, bytes: 0, decodes: 0, limit: TIP_CACHE_BYTES }); + + // Using one brush decodes exactly its tip; painting and picking it again reuse it. + t.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + t.execute("tools.setBrush", json!({"preset": "Big 3"})).unwrap(); + let TipShape::Stored(r) = &t.tools.brush.tip else { panic!("the brush keeps its stored tip") }; + assert_eq!(r.full.as_deref(), Some(&tip8(side, side, 3)), "the tool paints with the full tip"); + assert_eq!(cache(&t).decodes, 1); + t.execute("paint.stroke", json!({"points": [[10, 10], [50, 50]], "brush": {"size": 30}})).unwrap(); + t.execute("paint.stroke", json!({"points": [[10, 50], [50, 10]], "preset": "Big 3"})).unwrap(); + t.execute("tools.setBrush", json!({"preset": "Big 3"})).unwrap(); + assert_eq!((cache(&t).decodes, cache(&t).tips), (1, 1)); + // Picking a preset never loads the library's other tips, and the library stays light. + assert!(user(&t).iter().all(|p| matches!(&p.brush.tip, TipShape::Stored(r) if !r.is_loaded()))); + // The panel's "current brush" match still works on a stored tip. + let picked = photocraft_paint::presets::find(&t.tools.presets, "Big 3").unwrap(); + assert_eq!(picked.brush.tip, t.tools.brush.tip); +} + +#[test] +fn the_decoded_tip_cache_stays_within_its_budget() { + let (s, mem) = library(6, 256); + drop(s); + let mut t = Session::new(); + t.attach_preset_store(open(Box::new(mem))); + let one = 256 * 256 * 2; + t.preset_store.as_ref().unwrap().set_tip_cache_limit(one * 2 + 1); + for i in 0..6 { + t.execute("tools.setBrush", json!({"preset": format!("Big {i}")})).unwrap(); + let c = cache(&t); + assert!(c.bytes <= c.limit && c.tips <= 2, "{c:?}"); + } + assert_eq!(cache(&t).decodes, 6); + // The two most recently used stay: picking them again decodes nothing. + t.execute("tools.setBrush", json!({"preset": "Big 4"})).unwrap(); + t.execute("tools.setBrush", json!({"preset": "Big 5"})).unwrap(); + assert_eq!(cache(&t).decodes, 6); + t.execute("tools.setBrush", json!({"preset": "Big 0"})).unwrap(); + assert_eq!(cache(&t).decodes, 7); + // A budget below one tip still loads it (the brush in use holds its own reference). + t.preset_store.as_ref().unwrap().set_tip_cache_limit(0); + assert_eq!(cache(&t).tips, 0); + t.execute("tools.setBrush", json!({"preset": "Big 1"})).unwrap(); + assert!(matches!(&t.tools.brush.tip, TipShape::Stored(r) if r.is_loaded())); +} + +#[test] +fn missing_corrupt_or_unknown_tips_fail_when_used_without_panicking() { + let (s, mem) = library(3, 64); + drop(s); + let mut t = Session::new(); + t.attach_preset_store(open(Box::new(mem.clone()))); + t.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + let key = |t: &Session, name: &str| match &photocraft_paint::presets::find(&t.tools.presets, name).unwrap().brush.tip { + TipShape::Stored(r) => r.key.clone(), + other => panic!("{other:?}"), + }; + // Deleted behind the store's back. + let k0 = key(&t, "Big 0"); + mem.remove(&tip_file(&k0)).unwrap(); + let before = t.tools.brush.clone(); + assert!(t.execute("tools.setBrush", json!({"preset": "Big 0"})).is_err()); + assert!(t.execute("paint.stroke", json!({"points": [[1, 1], [9, 9]], "preset": "Big 0"})).is_err()); + assert_eq!(t.tools.brush, before); + // Corrupt, or a different bitmap under the same name. + let k1 = key(&t, "Big 1"); + mem.write(&tip_file(&k1), b"PCTIP1\x08garbage").unwrap(); + assert!(t.execute("tools.setBrush", json!({"preset": "Big 1"})).is_err()); + let k2 = key(&t, "Big 2"); + mem.write(&tip_file(&k2), &encode_tip(&tip8(10, 10, 0))).unwrap(); + let e = t.execute("tools.setBrush", json!({"preset": "Big 2"})).unwrap_err().to_string(); + assert!(e.contains("expected 64×64"), "{e}"); + // Exporting a library whose tip is gone is an error too, not a file with a hole. + assert!(t.execute("edit.presets.exportImportPresets", json!({"action": "export"})).is_err()); + // A stored reference the store doesn't know, or with no store at all. + let stray = json!({"tip": {"stored": {"key": "../../etc/passwd", "width": 4, "height": 4, + "preview": {"width": 1, "height": 1, "data": [1.0]}}}}); + assert!(t.execute("tools.setBrush", json!({"brush": stray})).is_err()); + let mut headless = Session::new(); + assert!(headless.execute("tools.setBrush", json!({"brush": stray})).is_err()); + assert!(headless.execute("brush.presets.list", json!({"full": true})).is_ok()); +} + +#[test] +fn a_store_without_previews_makes_them_once_and_then_loads_lazily() { + // A store written before #1843: group files without tip sizes, no preview files. + let mem = MemBackend::default(); + let tips = [tip16(300, 200, 1), tip8(40, 40, 2)]; + let hashes: Vec = tips.iter().map(tip_hash).collect(); + for (t, h) in tips.iter().zip(&hashes) { + mem.write(&tip_file(h), &encode_tip(t)).unwrap(); + } + let group = json!({"format": GROUP_FORMAT, "version": 1, "group": "Old", "presets": [ + {"name": "Old A", "brush": {"size": 30}, "tips": {"tip": hashes[0], "texture": hashes[1]}}, + {"name": "Old B", "brush": {"size": 12}, "tips": {"tip": hashes[1]}}, + ]}); + let file = group_file_name("Old"); + mem.write(&file, &serde_json::to_vec(&group).unwrap()).unwrap(); + + let mut s = Session::new(); + let w = s.attach_preset_store(open(Box::new(mem.clone()))); + assert!(w.is_empty(), "{w:?}"); + let a = photocraft_paint::presets::find(&s.tools.presets, "Old A").unwrap().clone(); + assert!(matches!(&a.brush.tip, TipShape::Stored(r) if (r.width, r.height) == (300, 200) && !r.is_loaded())); + assert!(matches!(&a.brush.texture.pattern, Pattern::Stored(r) if r.key == hashes[1])); + // Attaching rewrote the group with sizes and previews. + let rewritten: serde_json::Value = serde_json::from_slice(&mem.read(&file, MAX_GROUP_BYTES).unwrap()).unwrap(); + assert_eq!(rewritten["tips"][&hashes[0]]["width"], 300); + assert_eq!(rewritten["presets"][0]["brush"]["size"], 30.0); + // The next start reads previews only and changes nothing. + let files = mem.list().unwrap(); + let mut t = Session::new(); + assert!(t.attach_preset_store(open(Box::new(mem.clone()))).is_empty()); + assert_eq!(mem.list().unwrap(), files); + assert_eq!(user(&t), user(&s)); + t.execute("tools.setBrush", json!({"preset": "Old A"})).unwrap(); + assert_eq!(t.tools.brush.tip.bitmap(), Some(&tips[0])); + assert_eq!(t.tools.brush.texture.pattern.bitmap(), Some(&tips[1])); + assert_eq!(cache(&t).decodes, 2); + // Exported presets embed the full bitmaps, as before. + let out = t.execute("edit.presets.exportImportPresets", json!({"action": "export"})).unwrap(); + let back: Vec = serde_json::from_value(out["data"]["brushes"].clone()).unwrap(); + let a = back.iter().find(|p| p.name == "Old A").unwrap(); + assert_eq!(a.brush.tip, TipShape::Sampled(tips[0].clone())); + assert_eq!(a.brush.texture.pattern, Pattern::Tile(tips[1].clone())); +} diff --git a/crates/io/examples/gen_abr_library.rs b/crates/io/examples/gen_abr_library.rs new file mode 100644 index 0000000000..2736e49d06 --- /dev/null +++ b/crates/io/examples/gen_abr_library.rs @@ -0,0 +1,163 @@ +//! Generates a large, redistributable ABR v6 brush library for memory and import testing (#1843). +//! +//! Real painting packs are commercial or "personal use only", so we can't test against them. This +//! writes a deterministic stand-in with the same shape: hundreds of presets with big 16-bit sampled +//! tips (RLE-compressed, like Photoshop CC), full dynamics descriptors, split over several files. +//! Every tip comes from `photocraft_paint::procedural` (original, seeded generators), so the output +//! carries no third-party rights. Nothing it writes is committed; regenerate it when needed. +//! +//! `cargo run --release -p photocraft-io --example gen_abr_library -- --out ` +//! +//! Options (defaults in brackets): `--brushes N` [600], `--edge PX` largest tip edge [2000], +//! `--min-edge PX` smallest tip edge [800], `--depth 8|16` [16], `--per-file N` presets per file [100], +//! `--raw` store tips uncompressed (bigger files). Each file stays under the reader's 512 MB decoded cap. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use photocraft_paint::procedural::*; +use photocraft_paint::tile::GrayTile; +use photocraft_psd::abr::{AbrSample, MAX_TOTAL_BYTES, write_v6}; +use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value}; +use std::path::PathBuf; +use std::time::Instant; + +const KINDS: [&str; 9] = ["Chalk", "Spatter", "Bristle", "Charcoal", "Leaf", "Grass", "Sponge", "Star", "Rake"]; + +fn tip(kind: usize, n: u32, seed: u32) -> GrayTile { + match kind { + 0 => chalk_tip(n, seed), + 1 => spatter_tip(n, seed, 20 + seed % 40), + 2 => bristle_tip(n, seed), + 3 => charcoal_tip(n, seed), + 4 => leaf_tip(n), + 5 => grass_tip(n, seed, 8 + seed % 16), + 6 => sponge_tip(n, seed), + 7 => star_tip(n), + _ => rake_tip(n, seed, 6 + seed % 12), + } +} + +fn prc(v: f64) -> Value { + Value::UnitFloat { unit: *b"#Prc", value: v } +} + +fn var(control: i32, jitter: f64) -> Value { + Value::Descriptor(Descriptor::new("brVr").with("bVTy", Value::Integer(control)).with("jitter", prc(jitter))) +} + +fn text(s: &str) -> Value { + Value::Text(UnicodeString::new_nul(s)) +} + +/// A cheap deterministic 0..1 value per (brush, salt), to vary the presets. +fn h(i: u32, salt: u32) -> f64 { + let mut x = (u64::from(i) << 32 | u64::from(salt)).wrapping_mul(0x9E37_79B9_7F4A_7C15); + x ^= x >> 31; + x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9); + x ^= x >> 29; + (x % 10_000) as f64 / 10_000.0 +} + +fn preset(i: u32, id: &str, name: &str, edge: u32) -> Descriptor { + let mut d = Descriptor::new("brushPreset").with("Nm ", text(name)).with( + "Brsh", + Value::Descriptor( + Descriptor::new("sampledBrush") + .with("Dmtr", Value::UnitFloat { unit: *b"#Pxl", value: f64::from(edge) * (0.1 + 0.4 * h(i, 1)) }) + .with("Angl", Value::UnitFloat { unit: *b"#Ang", value: (h(i, 2) * 360.0 - 180.0).round() }) + .with("Rndn", prc((50.0 + 50.0 * h(i, 3)).round())) + .with("Spcn", prc((5.0 + 60.0 * h(i, 4)).round())) + .with("Intr", Value::Boolean(true)) + .with("flipX", Value::Boolean(h(i, 5) < 0.2)) + .with("sampledData", text(id)), + ), + ); + d = d.with("useTipDynamics", Value::Boolean(true)).with("szVr", var(2, (h(i, 6) * 40.0).round())).with("angleDynamics", var(0, (h(i, 7) * 100.0).round())); + if h(i, 8) < 0.5 { + d = d + .with("useScatter", Value::Boolean(true)) + .with("scatterDynamics", var(0, (h(i, 9) * 200.0).round())) + .with("Cnt ", Value::Integer(1 + (h(i, 10) * 4.0) as i32)); + } + if h(i, 11) < 0.6 { + d = d.with("usePaintDynamics", Value::Boolean(true)).with("opVr", var(2, 0.0)).with("prVr", var(2, 0.0)); + } + d +} + +fn main() { + let args: Vec = std::env::args().collect(); + let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); + let num = |name: &str, default: u32| arg(name).map_or(default, |v| v.parse().expect("numeric option")); + let out = PathBuf::from(arg("--out").unwrap_or_else(|| "target/abr-library".into())); + let brushes = num("--brushes", 600); + let max_edge = num("--edge", 2000).clamp(16, photocraft_psd::abr::MAX_EDGE); + let min_edge = num("--min-edge", 800).clamp(16, max_edge); + let depth = num("--depth", 16) as u16; + assert!(matches!(depth, 8 | 16), "--depth must be 8 or 16"); + let per_file = num("--per-file", 100).max(1); + let rle = !args.iter().any(|a| a == "--raw"); + std::fs::create_dir_all(&out).unwrap(); + + let started = Instant::now(); + let threads = std::thread::available_parallelism().map_or(4, |n| n.get()); + let (mut file_no, mut first, mut total_bytes, mut total_decoded) = (0u32, 0u32, 0u64, 0u64); + while first < brushes { + // Fill one file up to `per_file` presets, keeping its decoded samples well under the reader's cap. + let mut ids = Vec::new(); + let mut decoded = 0usize; + let mut i = first; + while i < brushes && ids.len() < per_file as usize { + let edge = min_edge + ((max_edge - min_edge) as f64 * h(i, 0)) as u32; + let bytes = edge as usize * edge as usize * usize::from(depth / 8); + if !ids.is_empty() && decoded + bytes > MAX_TOTAL_BYTES * 3 / 4 { + break; + } + decoded += bytes; + ids.push((i, edge)); + i += 1; + } + let samples: Vec = std::thread::scope(|s| { + let chunk = ids.len().div_ceil(threads); + let handles: Vec<_> = ids + .chunks(chunk) + .map(|part| { + s.spawn(move || { + part.iter() + .map(|&(i, edge)| { + let t = tip(i as usize % KINDS.len(), edge, i); + let data = if depth == 16 { + t.data.iter().flat_map(|v| v.to_be_bytes()).collect() + } else { + t.data.iter().map(|v| (v >> 8) as u8).collect() + }; + AbrSample { id: format!("$pc-gen-{i:05}"), width: edge, height: edge, depth, data } + }) + .collect::>() + }) + }) + .collect(); + handles.into_iter().flat_map(|h| h.join().unwrap()).collect() + }); + let presets: Vec = ids + .iter() + .map(|&(i, edge)| preset(i, &format!("$pc-gen-{i:05}"), &format!("{} {:03} ({edge} px)", KINDS[i as usize % KINDS.len()], i + 1), edge)) + .collect(); + let bytes = write_v6(2, &samples, &[], &presets, rle).unwrap(); + // Sanity: our own importer must accept every preset. + let imported = photocraft_io::abr_map::read_abr(&bytes, "Generated").unwrap(); + assert_eq!(imported.presets.len(), ids.len(), "every generated preset imports"); + file_no += 1; + let path = out.join(format!("photocraft-test-brushes-{file_no:02}.abr")); + std::fs::write(&path, &bytes).unwrap(); + println!("{}: {} brushes, {:.1} MB on disk, {:.1} MB decoded", path.display(), ids.len(), bytes.len() as f64 / 1e6, decoded as f64 / 1e6); + total_bytes += bytes.len() as u64; + total_decoded += decoded as u64; + first = i; + } + println!( + "{brushes} brushes in {file_no} files: {:.1} MB on disk, {:.1} MB of decoded tips, {:.1} s", + total_bytes as f64 / 1e6, + total_decoded as f64 / 1e6, + started.elapsed().as_secs_f64() + ); +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 9f7b11cb2c..4c262a5911 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -11,7 +11,7 @@ use photocraft_color::BlendMode; use serde::{Deserialize, Serialize}; use crate::mixer::MixerSettings; -use crate::tile::GrayTile; +use crate::tile::{GrayTile, StoredTile}; /// Largest brush diameter accepted by the rasterizer and brush controls. pub const MAX_BRUSH_SIZE: f32 = 5000.0; @@ -72,7 +72,10 @@ impl Dynamic { } /// The brush tip bitmap source. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// +/// A loaded [`TipShape::Stored`] tip serialises as `sampled` (its full bitmap), so tool presets, +/// recorded actions and `brush.get` see the same JSON as for an embedded tip. +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TipShape { /// Computed round/elliptical tip (hardness, angle, roundness). @@ -80,6 +83,50 @@ pub enum TipShape { Round, /// Sampled tip: grayscale bitmap where 1 = full paint. Scaled so its larger side equals `size`. Sampled(GrayTile), + /// A sampled tip its owner (the preset store) keeps outside the settings and loads on demand. + Stored(StoredTile), +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +enum TipShapeOut<'a> { + Round, + Sampled(&'a GrayTile), + Stored(&'a StoredTile), +} + +impl Serialize for TipShape { + fn serialize(&self, s: S) -> Result { + match self { + TipShape::Round => TipShapeOut::Round, + TipShape::Sampled(g) => TipShapeOut::Sampled(g), + TipShape::Stored(r) => match r.full.as_deref() { + Some(g) => TipShapeOut::Sampled(g), + None => TipShapeOut::Stored(r), + }, + } + .serialize(s) + } +} + +impl TipShape { + /// The bitmap a sampled tip paints with (a stored tip's full bitmap, or its preview while it + /// isn't loaded); `None` for a computed tip. + pub fn bitmap(&self) -> Option<&GrayTile> { + match self { + TipShape::Round => None, + TipShape::Sampled(g) => Some(g), + TipShape::Stored(r) => Some(r.bitmap()), + } + } + /// The tip's real pixel size (a stored tip's, not its preview's); `None` for a computed tip. + pub fn bitmap_size(&self) -> Option<(u32, u32)> { + match self { + TipShape::Round => None, + TipShape::Sampled(g) => Some((g.width, g.height)), + TipShape::Stored(r) => Some((r.width, r.height)), + } + } } /// Shape Dynamics. @@ -158,11 +205,52 @@ pub enum PatternStyle { } /// A texture pattern: a procedural generator or an explicit grayscale tile (tiled infinitely). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// A loaded [`Pattern::Stored`] tile serialises as `tile`, like [`TipShape::Stored`]. +#[derive(Clone, Debug, PartialEq, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Pattern { - Procedural { style: PatternStyle, size: u32, seed: u32 }, + Procedural { + style: PatternStyle, + size: u32, + seed: u32, + }, Tile(GrayTile), + /// A tile its owner (the preset store) keeps outside the settings and loads on demand. + Stored(StoredTile), +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +enum PatternOut<'a> { + Procedural { style: PatternStyle, size: u32, seed: u32 }, + Tile(&'a GrayTile), + Stored(&'a StoredTile), +} + +impl Serialize for Pattern { + fn serialize(&self, s: S) -> Result { + match self { + Pattern::Procedural { style, size, seed } => PatternOut::Procedural { style: *style, size: *size, seed: *seed }, + Pattern::Tile(g) => PatternOut::Tile(g), + Pattern::Stored(r) => match r.full.as_deref() { + Some(g) => PatternOut::Tile(g), + None => PatternOut::Stored(r), + }, + } + .serialize(s) + } +} + +impl Pattern { + /// The tile a bitmap pattern textures with (a stored tile's full bitmap, or its preview while + /// it isn't loaded); `None` for a procedural pattern. + pub fn bitmap(&self) -> Option<&GrayTile> { + match self { + Pattern::Procedural { .. } => None, + Pattern::Tile(g) => Some(g), + Pattern::Stored(r) => Some(r.bitmap()), + } + } } impl Default for Pattern { diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index ec58d456e2..e8317b013a 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -37,7 +37,7 @@ pub use brush::{ }; pub use mixer::MixerSettings; pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke}; -pub use tile::GrayTile; +pub use tile::{GrayTile, StoredTile}; /// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 641500e1c6..65fb825c65 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -97,8 +97,10 @@ fn prepare_pattern(b: &BrushSettings) -> Option> { } let mut img = match &t.pattern { Pattern::Procedural { style, size, seed } => crate::procedural::pattern(*style, *size, *seed), - Pattern::Tile(g) if g.is_valid() => PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() }, - Pattern::Tile(_) => return None, + p => match p.bitmap() { + Some(g) if g.is_valid() => PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() }, + _ => return None, + }, }; let (br, ct) = (t.brightness.clamp(-1.0, 1.0), t.contrast.clamp(-1.0, 1.0)); for v in &mut img.data { @@ -121,8 +123,8 @@ pub(crate) fn rect_around(d: &Dab, reach: f32) -> Rect { impl BrushContext { pub fn new(brush: &BrushSettings) -> Self { let brush = brush.bounded_for_render(); - let mips = |t: &TipShape| match t { - TipShape::Sampled(g) if g.is_valid() => Some(Arc::new(Mips::new(g))), + let mips = |t: &TipShape| match t.bitmap() { + Some(g) if g.is_valid() => Some(Arc::new(Mips::new(g))), _ => None, }; Self { diff --git a/crates/paint/src/tile.rs b/crates/paint/src/tile.rs index 23808ad615..6152411c91 100644 --- a/crates/paint/src/tile.rs +++ b/crates/paint/src/tile.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A grayscale bitmap, row-major, 1 = full. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct GrayTile { pub width: u32, pub height: u32, @@ -39,6 +39,75 @@ impl GrayTile { pub fn is_valid(&self) -> bool { self.width > 0 && self.height > 0 && self.data.len() == self.width as usize * self.height as usize } + + /// A box-filtered copy whose larger side is at most `max_side` (the whole tile when it is + /// already that small), quantised to 8-bit levels so it stores compactly. Thumbnails and the + /// Brushes panel draw this instead of a full tip that may be thousands of pixels across. + /// Deterministic: the same tile always gives the same preview. An invalid tile gives 1×1. + pub fn preview(&self, max_side: u32) -> GrayTile { + let q8 = |v: u32| ((v + 128) / 257 * 257) as u16; + if !self.is_valid() { + return GrayTile { width: 1, height: 1, data: vec![0] }; + } + let (w, h) = (self.width as usize, self.height as usize); + let max = max_side.max(1) as usize; + let f = w.max(h).div_ceil(max).max(1); + let (nw, nh) = (w.div_ceil(f), h.div_ceil(f)); + let mut sum = vec![0u64; nw * nh]; + let mut cnt = vec![0u32; nw * nh]; + for (y, row) in self.data.chunks_exact(w).enumerate() { + let base = (y / f) * nw; + for (x, v) in row.iter().enumerate() { + if let (Some(s), Some(c)) = (sum.get_mut(base + x / f), cnt.get_mut(base + x / f)) { + *s += u64::from(*v); + *c += 1; + } + } + } + let data = sum.iter().zip(&cnt).map(|(s, c)| q8(if *c > 0 { (*s / u64::from(*c)) as u32 } else { 0 })).collect(); + GrayTile { width: nw as u32, height: nh as u32, data } + } +} + +/// Larger side of the previews kept for tips that are loaded on demand ([`StoredTile`]). +pub const PREVIEW_SIDE: u32 = 96; + +/// A tip or texture bitmap kept outside the brush settings by its owner and loaded only when the +/// brush is used (#1843): the desktop preset store keeps imported tips on disk, so a library of a +/// thousand big sampled brushes costs a few small previews in memory, not gigabytes of bitmaps. +/// +/// `preview` is a small copy ([`GrayTile::preview`]) for thumbnails and stroke previews; `full` +/// is the bitmap once the owner has loaded it (shared with the owner's cache, never serialised). +/// Brush rendering uses [`StoredTile::bitmap`]: the full bitmap, or the preview when the owner +/// could not load it, so a missing file degrades the stroke instead of failing it. +/// +/// Two stored tiles are equal when their key and size are (the key names the content). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredTile { + /// The owner's key for the bitmap (the preset store uses the content hash). + pub key: String, + pub width: u32, + pub height: u32, + pub preview: std::sync::Arc, + #[serde(skip)] + pub full: Option>, +} + +impl StoredTile { + /// The full bitmap when loaded, else the preview. + pub fn bitmap(&self) -> &GrayTile { + self.full.as_deref().unwrap_or(&self.preview) + } + pub fn is_loaded(&self) -> bool { + self.full.is_some() + } +} + +impl PartialEq for StoredTile { + fn eq(&self, other: &Self) -> bool { + self.key == other.key && self.width == other.width && self.height == other.height + } } #[inline] @@ -250,6 +319,42 @@ mod tests { assert!(serde_json::from_str::(r#"{"width":2,"height":2,"data":[0.0,1.0]}"#).is_err()); } + #[test] + fn preview_downscales_deterministically_and_tolerates_bad_tiles() { + let t = GrayTile::from_fn(1000, 400, |x, _| if x < 500 { 1.0 } else { 0.0 }); + let p = t.preview(96); + assert!(p.is_valid() && p.width <= 96 && p.height <= 96, "{}×{}", p.width, p.height); + assert_eq!(p, t.preview(96)); + assert!(p.get(0, 0) > 0.99 && p.get(p.width - 1, 0) < 0.01); + assert!(p.data.iter().all(|v| v % 257 == 0), "8-bit levels"); + // Small tiles keep their size; invalid ones never panic. + assert_eq!(GrayTile::from_fn(5, 3, |_, _| 0.5).preview(96).width, 5); + assert!(GrayTile { width: 4, height: 4, data: vec![1] }.preview(96).is_valid()); + assert!(GrayTile { width: 0, height: 0, data: vec![] }.preview(0).is_valid()); + } + + #[test] + fn stored_tiles_compare_by_key_and_serialise_loaded_as_sampled() { + use crate::brush::{Pattern, TipShape}; + use std::sync::Arc; + let full = GrayTile::from_fn(300, 200, |x, y| ((x + y) % 7) as f32 / 7.0); + let r = StoredTile { key: "abc".into(), width: 300, height: 200, preview: Arc::new(full.preview(PREVIEW_SIDE)), full: None }; + let loaded = StoredTile { full: Some(Arc::new(full.clone())), ..r.clone() }; + assert_eq!(TipShape::Stored(r.clone()), TipShape::Stored(loaded.clone())); + assert_ne!(TipShape::Stored(r.clone()), TipShape::Sampled(full.clone())); + assert_eq!(r.bitmap().width, r.preview.width); + assert_eq!(loaded.bitmap(), &full); + // Unloaded: a small reference that reads back as one. + let j = serde_json::to_value(TipShape::Stored(r.clone())).unwrap(); + assert_eq!(j["stored"]["key"], "abc"); + assert!(j["stored"].get("full").is_none()); + assert_eq!(serde_json::from_value::(j).unwrap(), TipShape::Stored(r.clone())); + // Loaded: the same JSON as an embedded bitmap. + assert_eq!(serde_json::to_value(TipShape::Stored(loaded.clone())).unwrap(), serde_json::to_value(TipShape::Sampled(full.clone())).unwrap()); + assert_eq!(serde_json::to_value(Pattern::Stored(loaded)).unwrap(), serde_json::to_value(Pattern::Tile(full)).unwrap()); + assert_eq!(serde_json::to_value(Pattern::Stored(r)).unwrap()["stored"]["width"], 300); + } + #[test] fn mips_average() { let t = GrayTile::from_fn(4, 4, |x, _| if x < 2 { 1.0 } else { 0.0 }); diff --git a/crates/ui-egui/src/brush_preview.rs b/crates/ui-egui/src/brush_preview.rs index 3c2c54e9c4..a3d2d83121 100644 --- a/crates/ui-egui/src/brush_preview.rs +++ b/crates/ui-egui/src/brush_preview.rs @@ -61,10 +61,17 @@ fn tile_sig(h: &mut DefaultHasher, t: &paint::GrayTile) { t.data.iter().step_by(step).for_each(|v| v.hash(h)); } +/// A stored tile (#1843) by its key, size and whether its full bitmap is loaded (the preview +/// drawn until then is replaced once it is). +fn stored_sig(h: &mut DefaultHasher, r: &paint::StoredTile) { + (&r.key, r.width, r.height, r.is_loaded()).hash(h); +} + fn tip_shape_sig(h: &mut DefaultHasher, t: &TipShape) { match t { TipShape::Round => 0u8.hash(h), TipShape::Sampled(g) => tile_sig(h, g), + TipShape::Stored(r) => stored_sig(h, r), } } @@ -82,6 +89,7 @@ pub fn preview_sig(b: &BrushSettings) -> u64 { js(&mut h, &(t.enabled, t.invert, t.scale, t.brightness, t.contrast, t.each_tip, t.mode, t.depth, t.depth_jitter)); match &t.pattern { Pattern::Tile(g) => tile_sig(&mut h, g), + Pattern::Stored(r) => stored_sig(&mut h, r), p => js(&mut h, p), } h.finish() @@ -156,10 +164,8 @@ pub fn tip_alpha(tip: &TipShape, hardness: f32, angle: f32, roundness: f32, flip let r = c - 1.0; let (sin, cos) = angle.to_radians().sin_cos(); let round = roundness.clamp(0.01, 1.0); - let sampled = match tip { - TipShape::Sampled(g) => Some(shrink(g, n * 2)), - TipShape::Round => None, - }; + // A stored tip draws from its saved preview unless loaded (#1843). + let sampled = tip.bitmap().map(|g| shrink(g, n * 2)); let mut out = vec![0.0f32; (n * n) as usize]; // 2×2 supersampling keeps small thumbnails smooth. for y in 0..n { @@ -309,6 +315,26 @@ mod tests { eprintln!("strip render: {:.2} ms", t0.elapsed().as_secs_f64() * 1e3); } + #[test] + fn stored_tips_draw_their_saved_preview_until_loaded() { + // #1843: library presets keep a small preview, and thumbnails never need the full tip. + let full = paint::GrayTile::from_fn(900, 300, |x, _| if x < 450 { 1.0 } else { 0.4 }); + let r = + paint::StoredTile { key: "k".into(), width: 900, height: 300, preview: std::sync::Arc::new(full.preview(paint::tile::PREVIEW_SIDE)), full: None }; + let loaded = paint::StoredTile { full: Some(std::sync::Arc::new(full.clone())), ..r.clone() }; + let n = 36; + let thumb = |t: &TipShape| tip_alpha(t, 1.0, 0.0, 1.0, (false, false), n); + let (from_preview, from_full) = (thumb(&TipShape::Stored(r.clone())), thumb(&TipShape::Sampled(full))); + let worst = from_preview.iter().zip(&from_full).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst < 0.05, "preview thumbnail differs by {worst}"); + // Loading the tip re-renders the thumbnail; the stroke preview paints with the preview. + let sig = |t: paint::StoredTile| tip_sig(&TipShape::Stored(t), 1.0, 0.0, 1.0, (false, false)); + assert_ne!(sig(r.clone()), sig(loaded)); + let b = BrushSettings { tip: TipShape::Stored(r), size: 900.0, ..Default::default() }; + let px = preview_pixels(&b, 120, 40, [1.0, 0.0, 0.0, 1.0]); + assert!(px.as_chunks::<4>().0.iter().any(|p| p[3] > 128)); + } + #[test] fn tip_thumbnails_follow_shape_angle_and_roundness() { let n = 32; diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index 7e1076a03b..079e090627 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -233,6 +233,8 @@ fn same_tip(tip: &TipShape, size: f32, hardness: f32, p: &BrushPreset) -> bool { && match (tip, pt) { (TipShape::Round, TipShape::Round) => (hardness - ph).abs() < 0.01, (TipShape::Sampled(a), TipShape::Sampled(b)) => a.width == b.width && a.height == b.height && a.data == b.data, + // Stored tips (#1843) match by key: the bitmap is the same one. + (TipShape::Stored(a), TipShape::Stored(b)) => a == b, _ => false, } } @@ -291,10 +293,7 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) b.roundness = pr; b.spacing = psp; } - let orig = match &b.tip { - TipShape::Sampled(g) => Some(g.width.max(g.height) as f32), - TipShape::Round => None, - }; + let orig = b.tip.bitmap_size().map(|(w, h)| w.max(h) as f32); size_row(ui, tl!("Size"), &mut b.size, MAX_BRUSH_SIZE, orig); ui.horizontal(|ui| { widgets::checkbox(ui, &mut b.flip_x, tl!("Flip X")); @@ -379,10 +378,11 @@ fn pattern_swatch(ui: &mut egui::Ui, tx: &paint::Texture) { let mut h = std::collections::hash_map::DefaultHasher::new(); serde_json::to_vec(&( match &tx.pattern { - Pattern::Tile(g) => { - (g.width, g.height, g.data.len() as u32, g.data.iter().step_by((g.data.len() / 512).max(1)).map(|v| *v as u32).sum::()) - } Pattern::Procedural { style, size, seed } => (*style as u32, *size, *seed, 0), + // A bitmap tile (a stored one's preview until it is loaded, #1843). + p => p.bitmap().map_or((0, 0, 0, 0), |g| { + (g.width, g.height, g.data.len() as u32, g.data.iter().step_by((g.data.len() / 512).max(1)).map(|v| *v as u32).sum::()) + }), }, tx.invert, tx.brightness, @@ -396,8 +396,10 @@ fn pattern_swatch(ui: &mut egui::Ui, tx: &paint::Texture) { c.get("texture-swatch", sig, || { let img = match &tx.pattern { Pattern::Procedural { style, size, seed } => paint::procedural::pattern(*style, (*size).clamp(4, 1024), *seed), - Pattern::Tile(g) if g.is_valid() => paint::tile::PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() }, - Pattern::Tile(_) => paint::tile::PatternImage { width: 1, height: 1, data: vec![0.5] }, + p => match p.bitmap() { + Some(g) if g.is_valid() => paint::tile::PatternImage { width: g.width as usize, height: g.height as usize, data: g.to_f32() }, + _ => paint::tile::PatternImage { width: 1, height: 1, data: vec![0.5] }, + }, }; let (br, ct) = (tx.brightness.clamp(-1.0, 1.0), tx.contrast.clamp(-1.0, 1.0)); let px: Vec = (0..n * n) @@ -427,7 +429,7 @@ fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) { ui.vertical(|ui| { let mut style = match &tx.pattern { Pattern::Procedural { style, .. } => Some(*style), - Pattern::Tile(_) => None, + Pattern::Tile(_) | Pattern::Stored(_) => None, }; let mut opts: Vec<(Option, &str)> = PATTERNS.iter().map(|(s, l)| (Some(*s), *l)).collect(); if style.is_none() { @@ -438,7 +440,7 @@ fn texture(ui: &mut egui::Ui, b: &mut BrushSettings) { { let size = match &tx.pattern { Pattern::Procedural { size, .. } => *size, - Pattern::Tile(_) => 128, + Pattern::Tile(_) | Pattern::Stored(_) => 128, }; tx.pattern = Pattern::Procedural { style: s, size, seed: 1 }; } @@ -498,10 +500,7 @@ fn dual_brush(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset]) d.roundness = pr; d.spacing = psp; } - let orig = match &d.tip { - TipShape::Sampled(g) => Some(g.width.max(g.height) as f32), - TipShape::Round => None, - }; + let orig = d.tip.bitmap_size().map(|(w, h)| w.max(h) as f32); size_row(ui, tl!("Size"), &mut d.size, 2500.0, orig); signed_pct(ui, tl!("Spacing"), &mut d.spacing, 0.01, 10.0); pct(ui, tl!("Scatter"), &mut d.scatter, 10.0);