mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-12 21:23:02 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
20a5f193f8
commit
51f9abaad7
13 files changed
+1179
-132
No files matched your search
@@ -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 <abr dir> <store dir>
|
||||
//! cargo run --release -p photocraft-engine --example abr_memory -- reopen <store dir>
|
||||
//! ```
|
||||
//!
|
||||
//! 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::<Vec<_>>()
|
||||
.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<String> = 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<String> = 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 <abr dir> <store dir> | reopen <store dir>"),
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,8 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result<BrushSettings>
|
||||
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<Value> {
|
||||
"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::<Vec<_>>()
|
||||
@@ -703,6 +709,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -893,7 +893,13 @@ fn export_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<photocraft_paint::BrushPreset> =
|
||||
s.tools.presets.iter().filter(|b| !b.builtin || bool_or(p, "includeBuiltins", false)).cloned().collect();
|
||||
// The file embeds every bitmap: load the tips the preset store keeps (#1843).
|
||||
for b in &mut out {
|
||||
s.load_brush_tips(&mut b.brush).map_err(|e| bad(cmd, format!("brush preset `{}`: {e}", b.name)))?;
|
||||
}
|
||||
out
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -5,13 +5,22 @@
|
||||
//!
|
||||
//! - `<group>-<hash>.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/<hash>.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<u8> {
|
||||
|
||||
/// Inverse of [`encode_tip`]; any malformed, truncated or oversized input is an error.
|
||||
pub fn decode_tip(bytes: &[u8]) -> Result<GrayTile, String> {
|
||||
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<GrayTile, String> {
|
||||
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<GrayTile, String> {
|
||||
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<StoredPreset>,
|
||||
/// 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<String, TipInfo>,
|
||||
}
|
||||
|
||||
#[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<GrayTile>; 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<String, GrayTile>) -> Option<BrushSettings> {
|
||||
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<Bitmap<'_>>; 3] {
|
||||
fn tip(t: &TipShape) -> Option<Bitmap<'_>> {
|
||||
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<StoredTile>; 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<String, StoredTile>) -> Option<BrushSettings> {
|
||||
let get = |h: &Option<String>| -> Option<Option<StoredTile>> {
|
||||
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<BrushPreset>,
|
||||
/// Tip files the group uses (full tips and previews).
|
||||
tips: Vec<String>,
|
||||
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<String, (Arc<GrayTile>, u64)>,
|
||||
}
|
||||
|
||||
impl TipCache {
|
||||
fn get(&mut self, key: &str) -> Option<Arc<GrayTile>> {
|
||||
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<GrayTile>) {
|
||||
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<String>,
|
||||
/// Full tips loaded for brushes in use (behind a lock so `&Session` paths can load).
|
||||
cache: Mutex<TipCache>,
|
||||
}
|
||||
|
||||
/// A loaded store: the presets on disk plus what the session should hide.
|
||||
@@ -477,33 +606,47 @@ pub fn open(backend: Box<dyn PresetBackend>) -> 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<String> = {
|
||||
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<String, GrayTile> = 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<String, TipInfo> = 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<dyn PresetBackend>) -> 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<String, u64>) -> Vec<Result<GrayTile, String>> {
|
||||
let read = |h: &String| -> Result<Vec<u8>, 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<String, u64>,
|
||||
info: &HashMap<String, TipInfo>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> (HashMap<String, StoredTile>, HashSet<String>) {
|
||||
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<Result<Vec<u8>, String>> = hashes.iter().map(read).collect();
|
||||
let dec = |r: &Result<Vec<u8>, 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<Result<Vec<u8>, String>> = batch.iter().map(|h| backend.read(&tip_file(h), MAX_TIP_BYTES)).collect();
|
||||
let make = |r: &Result<Vec<u8>, 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::<u64>() + self.tips.values().sum::<u64>()
|
||||
}
|
||||
|
||||
/// 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<Arc<GrayTile>, 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<usize>)> = 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::<u64>() + self.tips.values().sum::<u64>();
|
||||
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<StoredTile>; 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<StoredTile>| 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<StoredTile> {
|
||||
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)]
|
||||
|
||||
@@ -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<String> = 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<BrushPreset> = 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()));
|
||||
}
|
||||
@@ -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 <dir>`
|
||||
//!
|
||||
//! 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<String> = 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<AbrSample> = 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::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
let presets: Vec<Descriptor> = 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()
|
||||
);
|
||||
}
|
||||
@@ -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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -97,8 +97,10 @@ fn prepare_pattern(b: &BrushSettings) -> Option<Arc<PatternImage>> {
|
||||
}
|
||||
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 {
|
||||
|
||||
+106
-1
@@ -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<GrayTile>,
|
||||
#[serde(skip)]
|
||||
pub full: Option<std::sync::Arc<GrayTile>>,
|
||||
}
|
||||
|
||||
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::<GrayTile>(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::<TipShape>(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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<u32>())
|
||||
}
|
||||
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::<u32>())
|
||||
}),
|
||||
},
|
||||
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<u8> = (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<PatternStyle>, &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);
|
||||
|
||||
Reference in new issue
Block a user