Browse / github.com/storytold/photocraft / patch
perf(paint): cache sampled-tip mipmaps between strokes
perf(paint): cache sampled-tip mipmaps between strokes Follow-up to #9382. ## Problem Repeated brush strokes with the same imported sampled brush rebuilt its float mip pyramid on every pen-down event and for each symmetry pass, introducing noticeable pen-down latency on large sampled tips (e.g., 34 ms on 5000² tips, or 137 ms with dual-axis symmetry). ## Change - Brush contexts now share prepared mip pyramids across strokes, primary/dual tips, and symmetry renderers. - An LRU cache holds up to 64 entries and 256 MiB of sample/mip buffers. - Cache keys use shared allocation identity and dimensions, avoiding content hashing on cache hits. Weak k
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- wanikhawar (imported from pull request #9617, open)
- Base
d42f9da94a421980d7130c63ca2953e47802cfbe- Size
- +504 −2 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
11cc14417497fd42b8d1225e
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/examples/bench_brush_mips.rs crates/engine/src/cmd/edit_menu_cmds/purge.rs crates/engine/src/cmd/edit_menu_cmds/tests.rs crates/paint/src/lib.rs crates/paint/src/mip_cache.rs crates/paint/src/mip_cache_render_tests.rs crates/paint/src/render.rs
new file mode 100644@@ -0,0 +1,117 @@+//! Sampled-tip preparation and live strokes on a materialized 24 MP layer. This harness also+//! builds against the pre-cache revision for alternating before/after runs on the same runner.+//! `cargo run --release -p photocraft-engine --example bench_brush_mips`+use std::hint::black_box;+use std::time::Instant;++use photocraft_color::{PixelFormat, SampleType};+use photocraft_engine::{EngineError, Session, brush_cmds::LiveStroke};+use photocraft_geom::Rect;+use photocraft_paint::{BrushContext, GrayTile, StrokePoint, TipShape};+use serde_json::json;++fn ms(start: Instant) -> f64 {+ start.elapsed().as_secs_f64() * 1e3+}++fn median(v: &mut [f64]) -> f64 {+ v.sort_by(f64::total_cmp);+ v.get(v.len() / 2).copied().unwrap_or(0.0)+}++fn main() -> Result<(), Box<dyn std::error::Error>> {+ for depth in [8, 16, 32] {+ let sample = match depth {+ 16 => SampleType::U16,+ 32 => SampleType::F32,+ _ => SampleType::U8,+ };+ let fmt = PixelFormat::RGBA8.with_sample(sample);+ let mut s = Session::new();+ s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth}))?;+ s.execute("layer.new.layer", json!({}))?;+ s.edit("materialize", |doc, active| {+ let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?;+ let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no pixel layer".into()))?;+ surf.fill_rect(Rect::new(0, 0, 6000, 4000), &photocraft_raster::from_rgba(&fmt, [0.3, 0.6, 0.8, 0.7]));+ Ok(())+ })?;+ for side in [0, 1024, 2048, 5000] {+ let original = (side > 0).then(|| GrayTile::from_fn(side, side, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0));+ for symmetry in ["off", "dual"] {+ if symmetry == "off" {+ if s.is_enabled("paint.symmetryDisable") {+ s.execute("paint.symmetryDisable", json!({}))?;+ }+ } else {+ s.execute("paint.setSymmetry", json!({"mode":symmetry}))?;+ }+ let (mut cold, mut warm, mut begin, mut draw, mut release) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());+ let mut fingerprint = None;+ // A fresh allocation makes every cold preparation a genuine miss. Copying+ // samples and materializing the document are outside all measured stages.+ for trial in 0..8 {+ s.tools.brush.tip = original+ .as_ref()+ .map_or(TipShape::Round, |t| TipShape::Sampled(GrayTile { width: t.width, height: t.height, data: t.data.as_ref().to_vec().into() }));+ let t = Instant::now();+ let ctx = BrushContext::new(&s.tools.brush);+ let cold_ms = ms(t);+ black_box(&ctx);+ drop(ctx);+ let t = Instant::now();+ let ctx = BrushContext::new(&s.tools.brush);+ let warm_ms = ms(t);+ black_box(&ctx);+ drop(ctx);+ let mut points: Vec<[f64; 3]> = (0..65).map(|i| [2600.0 + f64::from(i) * 5.0, 1800.0 + (f64::from(i) / 8.0).sin() * 30.0, 0.8]).collect();+ let mut params = json!({"points":[points[0]],"size":60,"flow":0.5,"smoothing":0.0,"seed":42});+ let t = Instant::now();+ let mut live = LiveStroke::begin(&s, ¶ms)?;+ let begin_ms = ms(t);+ let t = Instant::now();+ for batch in points.get(1..).unwrap_or_default().chunks(4) {+ let batch: Vec<_> = batch.iter().map(|p| StrokePoint::new(p[0], p[1], p[2] as f32)).collect();+ live.push(&batch)?;+ }+ let draw_ms = ms(t);+ let surface = live.doc.layers.iter().rev().find_map(|l| l.surface()).ok_or("no painted surface")?;+ let hash = surface+ .read_region(Rect::new(2500, 1700, 3500, 2300))+ .iter()+ .fold(0xcbf2_9ce4_8422_2325u64, |hash, v| (hash ^ u64::from(v.to_bits())).wrapping_mul(0x100_0000_01b3));+ if fingerprint.is_some_and(|previous| previous != hash) {+ return Err("stroke fingerprint changed across repeated trials".into());+ }+ fingerprint = Some(hash);+ params["points"] = json!(std::mem::take(&mut points));+ let t = Instant::now();+ s.offer_live_stroke(live)?;+ let result = s.execute("paint.stroke", params)?;+ let release_ms = ms(t);+ if result.get("fromLive").is_none() {+ return Err("commit did not take the live pixels".into());+ }+ s.execute("edit.undo", json!({}))?;+ if trial > 0 {+ cold.push(cold_ms);+ warm.push(warm_ms);+ begin.push(begin_ms);+ draw.push(draw_ms);+ release.push(release_ms);+ }+ }+ println!(+ "tip={side} depth={depth} symmetry={symmetry} cold_ms={:.6} warm_ms={:.6} begin_ms={:.6} draw_ms={:.6} release_ms={:.6} fingerprint={:016x}",+ median(&mut cold),+ median(&mut warm),+ median(&mut begin),+ median(&mut draw),+ median(&mut release),+ fingerprint.unwrap_or(0)+ );+ }+ }+ }+ Ok(())+}@@ -66,6 +66,11 @@ pub(super) fn purge(s: &mut Session, what: &str) -> Result<Value> { freed = freed.saturating_add(strokes); items.push("shape stroke cache"); }+ let mips = photocraft_paint::purge_mip_cache();+ if mips > 0 {+ freed = freed.saturating_add(mips);+ items.push("brush mip cache");+ } } s.edit_state.fade = None; let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) };@@ -86,7 +91,11 @@ pub(super) fn can_purge_histories(s: &Session) -> std::result::Result<(), String pub(super) fn can_purge_all(s: &Session) -> std::result::Result<(), String> { can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| {- if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 {+ if photocraft_compose::effect_cache_bytes() > 0+ || photocraft_compose::masks::cache_bytes() > 0+ || crate::smart_cmds::stack_cache_bytes() > 0+ || photocraft_paint::mip_cache_bytes() > 0+ { Ok(()) } else { Err("nothing to purge".into())@@ -1,6 +1,8 @@ use super::*; use photocraft_doc::vector::Subpath; +static PURGE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());+ fn session(depth: u32) -> Session { let mut s = Session::new(); s.execute("file.new", json!({"width": 64, "height": 48, "depth": depth, "background": "white"})).unwrap();@@ -154,6 +156,7 @@ fn fade_ignores_a_target_the_command_doesnt_take() { #[test] fn purge_commands() {+ let _purge_test_guard = PURGE_TEST_LOCK.lock().unwrap(); let mut s = session(8); assert!(!s.is_enabled("edit.purge.undo")); assert!(s.is_enabled("edit.purge.videoCache"));@@ -180,6 +183,30 @@ fn purge_commands() { let _ = s.execute("edit.purge.all", json!({})); } +#[test]+fn purge_all_releases_prepared_brush_tips_even_without_a_document() {+ let _purge_test_guard = PURGE_TEST_LOCK.lock().unwrap();+ use photocraft_paint::{BrushContext, BrushSettings, Dab, GrayTile, TipShape};+ let mut s = Session::new();+ let brush = BrushSettings { tip: TipShape::Sampled(GrayTile::from_fn(31, 17, |x, y| ((x + y) % 5) as f32 / 4.0)), ..Default::default() };+ let ctx = BrushContext::new(&brush);+ let dab = Dab::round(photocraft_geom::Point::new(12.5, 14.5), 10.0, 1.0);+ let rect = ctx.dab_rect(&dab, false);+ let mut before = Vec::new();+ ctx.rasterize(&dab, false, rect, &mut before);+ assert!(s.is_enabled("edit.purge.all"), "prepared tips alone enable All");+ let result = s.execute("edit.purge.all", json!({})).unwrap();+ assert!(result["purged"].as_array().unwrap().contains(&json!("brush mip cache")));+ assert!(result["bytes"].as_u64().unwrap() > 0);+ // An already prepared brush remains valid after the cache releases its Arc.+ let mut after = Vec::new();+ ctx.rasterize(&dab, false, rect, &mut after);+ assert_eq!(before, after);+ let next = BrushContext::new(&brush);+ next.rasterize(&dab, false, rect, &mut after);+ assert_eq!(before, after);+}+ /// White canvas with vertical black stripes, a red blob to remove, and a selection around it. fn blob_session(depth: u32) -> Session { let mut s = session(depth);@@ -22,6 +22,7 @@ pub mod bg_erase; pub mod brush; pub mod curve; pub mod dynamics;+mod mip_cache; pub mod mixer; pub mod outline; pub mod presets;@@ -38,6 +39,7 @@ pub use brush::{ Scattering, SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, }; pub use curve::ResponseCurve;+pub use mip_cache::{mip_cache_bytes, purge_mip_cache}; pub use mixer::MixerSettings; pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke}; pub use tile::{GrayTile, StoredTile};new file mode 100644@@ -0,0 +1,236 @@+//! Prepared sampled tips shared across strokes and symmetry passes. Identity follows the+//! immutable sample allocation, not preset names or brush settings: copy-on-write edits and+//! loading a stored tip's full bitmap must prepare fresh mips.++use std::collections::VecDeque;+use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};++use crate::tile::{GrayTile, Mips};++// One maximum-size 5000² tip and its float pyramid fit (~175 MiB). Count both the mip+// buffers and source storage: even a weak Arc key reserves its allocation until eviction.+// Active strokes may keep using evicted pyramids through their own Arcs.+const MAX_BYTES: usize = 256 * 1024 * 1024;+const MAX_ENTRIES: usize = 64;+static CACHE: OnceLock<Mutex<MipCache>> = OnceLock::new();++struct Entry {+ width: u32,+ height: u32,+ // A weak key detects dropped sources and prevents allocation reuse for a different tip.+ // Arc::make_mut dissociates weak pointers on an in-place edit.+ samples: Weak<[u16]>,+ mips: Arc<Mips>,+ bytes: usize,+}++struct MipCache {+ entries: VecDeque<Entry>,+ bytes: usize,+ max_bytes: usize,+ max_entries: usize,+}++impl MipCache {+ fn new(max_bytes: usize, max_entries: usize) -> Self {+ Self { entries: VecDeque::new(), bytes: 0, max_bytes, max_entries }+ }++ fn get(&mut self, tip: &GrayTile) -> Option<Arc<Mips>> {+ let samples = Arc::downgrade(&tip.data);+ let index = self.entries.iter().position(|e| e.width == tip.width && e.height == tip.height && e.samples.ptr_eq(&samples))?;+ let entry = self.entries.remove(index)?;+ let mips = Arc::clone(&entry.mips);+ self.entries.push_back(entry);+ Some(mips)+ }++ fn insert(&mut self, tip: &GrayTile, mips: Arc<Mips>) -> Arc<Mips> {+ // Another caller may have finished preparing the same tip while this one built it.+ if let Some(existing) = self.get(tip) {+ return existing;+ }+ self.entries.retain(|e| e.samples.strong_count() > 0);+ self.bytes = self.entries.iter().fold(0usize, |n, e| n.saturating_add(e.bytes));+ let source_bytes = tip.data.len().saturating_mul(size_of::<u16>());+ let bytes = mips.levels.iter().fold(source_bytes, |n, (_, _, values)| n.saturating_add(values.capacity().saturating_mul(size_of::<f32>())));+ // An oversized tip still renders normally, without evicting useful cached tips or+ // extending the retention budget. Invalid tips retain Mips::new's ordinary fallback.+ if bytes > self.max_bytes || self.max_entries == 0 || !tip.is_valid() {+ return mips;+ }+ while self.bytes > self.max_bytes - bytes || self.entries.len() >= self.max_entries {+ let Some(old) = self.entries.pop_front() else { break };+ self.bytes = self.bytes.saturating_sub(old.bytes);+ }+ self.bytes = self.bytes.saturating_add(bytes);+ self.entries.push_back(Entry { width: tip.width, height: tip.height, samples: Arc::downgrade(&tip.data), mips: Arc::clone(&mips), bytes });+ mips+ }++ fn purge(&mut self) -> usize {+ self.entries.clear();+ std::mem::take(&mut self.bytes)+ }+}++fn cached(cache: &Mutex<MipCache>, tip: &GrayTile) -> Arc<Mips> {+ if let Some(mips) = cache.lock().unwrap_or_else(PoisonError::into_inner).get(tip) {+ return mips;+ }+ // Preparing a large pyramid must not hold the global lock: hits for other brushes and+ // unrelated documents can proceed. Concurrent misses may build twice, but publish one Arc.+ let mips = Arc::new(Mips::new(tip));+ cache.lock().unwrap_or_else(PoisonError::into_inner).insert(tip, mips)+}++pub(crate) fn tip_mips(tip: &GrayTile) -> Arc<Mips> {+ cached(CACHE.get_or_init(|| Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES))), tip)+}++/// Sample and mip buffer bytes retained by the prepared-tip cache, including weak-key storage.+pub fn mip_cache_bytes() -> usize {+ CACHE.get().map_or(0, |cache| cache.lock().unwrap_or_else(PoisonError::into_inner).bytes)+}++/// Release prepared-tip cache ownership and return its accounted buffer bytes. Active strokes+/// keep their shared pyramids and continue painting unchanged; the next stroke can prepare anew.+pub fn purge_mip_cache() -> usize {+ CACHE.get().map_or(0, |cache| cache.lock().unwrap_or_else(PoisonError::into_inner).purge())+}++#[cfg(test)]+mod tests {+ use super::*;++ fn tip(w: u32, h: u32) -> GrayTile {+ GrayTile::from_fn(w, h, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0)+ }++ #[test]+ fn a_repeated_tip_reuses_every_level_after_the_first_stroke_drops() {+ let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES));+ let tip = tip(31, 17);+ let first = cached(&cache, &tip);+ let weak = Arc::downgrade(&first);+ assert_eq!(first.levels, Mips::new(&tip).levels);+ drop(first);+ let next = cached(&cache, &tip.clone());+ assert!(Arc::ptr_eq(&weak.upgrade().unwrap(), &next));+ assert_eq!(cache.lock().unwrap().entries.len(), 1);+ }++ #[test]+ fn dimensions_and_cow_sample_edits_invalidate_cached_mips() {+ let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES));+ let mut original = tip(8, 4);+ let first = cached(&cache, &original);+ let reshaped = GrayTile { width: 4, height: 8, data: Arc::clone(&original.data) };+ let changed_shape = cached(&cache, &reshaped);+ assert!(!Arc::ptr_eq(&first, &changed_shape));+ assert_eq!(changed_shape.levels, Mips::new(&reshaped).levels);+ let saved = original.clone();+ Arc::make_mut(&mut original.data)[0] = 65535;+ let changed_samples = cached(&cache, &original);+ assert!(!Arc::ptr_eq(&first, &changed_samples));+ assert_eq!(changed_samples.levels, Mips::new(&original).levels);+ assert!(Arc::ptr_eq(&first, &cached(&cache, &saved)));+ // With no other strong source owner, make_mut must also invalidate a weak cache key.+ let mut sole_owner = tip(5, 3);+ let old = cached(&cache, &sole_owner);+ Arc::make_mut(&mut sole_owner.data)[0] = 65535;+ assert!(!Arc::ptr_eq(&old, &cached(&cache, &sole_owner)));+ }++ #[test]+ fn retention_is_lru_bounded_by_both_bytes_and_entry_count() {+ let (a, b, c) = (tip(4, 4), tip(4, 4), tip(4, 4));+ let bytes = 16 * size_of::<u16>() + (16 + 4 + 1) * size_of::<f32>();+ let cache = Mutex::new(MipCache::new(2 * bytes, 64));+ let ma = cached(&cache, &a);+ let mb = cached(&cache, &b);+ assert!(Arc::ptr_eq(&ma, &cached(&cache, &a)));+ cached(&cache, &c);+ assert!(cache.lock().unwrap().get(&b).is_none());+ assert!(Arc::ptr_eq(&ma, &cached(&cache, &a)));+ assert_eq!(cache.lock().unwrap().bytes, 2 * bytes);+ assert_eq!(mb.levels, Mips::new(&b).levels, "eviction leaves active strokes valid");+ let cache = Mutex::new(MipCache::new(MAX_BYTES, 1));+ cached(&cache, &a);+ cached(&cache, &b);+ assert_eq!(cache.lock().unwrap().entries.len(), 1);+ assert!(cache.lock().unwrap().get(&a).is_none());+ }++ #[test]+ fn dead_sources_are_pruned_and_oversized_or_invalid_tips_bypass_the_cache() {+ let cache = Mutex::new(MipCache::new(116, 8));+ let small = tip(4, 4);+ let weak = Arc::downgrade(&small.data);+ cached(&cache, &small);+ drop(small);+ assert!(weak.upgrade().is_none());+ let live = tip(4, 4);+ let kept = cached(&cache, &live);+ assert_eq!(cache.lock().unwrap().entries.len(), 1);+ let large = tip(8, 8);+ let uncached = cached(&cache, &large);+ assert!(!Arc::ptr_eq(&uncached, &cached(&cache, &large)));+ assert!(Arc::ptr_eq(&kept, &cached(&cache, &live)));+ assert_eq!(cache.lock().unwrap().bytes, 116);+ let invalid = GrayTile { width: u32::MAX, height: u32::MAX, data: vec![1].into() };+ assert_eq!(cached(&cache, &invalid).levels, Mips::new(&invalid).levels);+ assert_eq!(cache.lock().unwrap().entries.len(), 1);+ }++ #[test]+ fn purging_releases_retention_without_invalidating_an_active_stroke() {+ let cache = Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES));+ let tip = tip(8, 4);+ let active = cached(&cache, &tip);+ assert!(cache.lock().unwrap().purge() > 0);+ assert_eq!(cache.lock().unwrap().bytes, 0);+ assert!(cache.lock().unwrap().entries.is_empty());+ let next = cached(&cache, &tip);+ assert!(!Arc::ptr_eq(&active, &next));+ assert_eq!(active.levels, next.levels);+ }++ #[cfg(not(target_arch = "wasm32"))]+ #[test]+ fn a_poisoned_cache_remains_usable() {+ let cache = Arc::new(Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)));+ let other = Arc::clone(&cache);+ assert!(+ std::thread::spawn(move || {+ let _guard = other.lock().unwrap();+ panic!("test cache poison recovery");+ })+ .join()+ .is_err()+ );+ let tip = tip(4, 4);+ let first = cached(&cache, &tip);+ assert!(Arc::ptr_eq(&first, &cached(&cache, &tip)));+ }++ #[cfg(not(target_arch = "wasm32"))]+ #[test]+ fn simultaneous_callers_publish_and_reuse_one_pyramid() {+ let cache = Arc::new(Mutex::new(MipCache::new(MAX_BYTES, MAX_ENTRIES)));+ let barrier = Arc::new(std::sync::Barrier::new(4));+ let tip = tip(257, 129);+ let workers: Vec<_> = (0..4)+ .map(|_| {+ let (cache, barrier, tip) = (Arc::clone(&cache), Arc::clone(&barrier), tip.clone());+ std::thread::spawn(move || {+ barrier.wait();+ cached(&cache, &tip)+ })+ })+ .collect();+ let results: Vec<_> = workers.into_iter().map(|w| w.join().unwrap()).collect();+ assert!(results.iter().all(|m| Arc::ptr_eq(m, &results[0])));+ assert_eq!(cache.lock().unwrap().entries.len(), 1);+ }+}new file mode 100644@@ -0,0 +1,107 @@+//! Exercise the production cache through brush contexts and native-depth stroke rendering.+use super::*;+use crate::brush::{DualBrush, Smoothing};+use crate::tile::{GrayTile, StoredTile};+use photocraft_geom::Point;++#[test]+fn contexts_share_primary_and_dual_mips_across_settings_and_symmetry_passes() {+ let tip = GrayTile::from_fn(31, 17, |x, y| ((x + y) % 5) as f32 / 4.0);+ let brush = BrushSettings {+ tip: TipShape::Sampled(tip.clone()),+ dual_brush: DualBrush { enabled: true, tip: TipShape::Sampled(tip), ..Default::default() },+ ..Default::default()+ };+ let ctx = BrushContext::new(&brush);+ assert!(Arc::ptr_eq(ctx.tip.as_ref().unwrap(), ctx.dual_tip.as_ref().unwrap()));+ // Symmetry constructs a renderer per pass; later strokes can change size, angle and flow.+ for pass in 0..4 {+ let changed = BrushSettings { size: 10.0 + pass as f32 * 11.0, angle: pass as f32 * 0.3, flow: 0.2, ..brush.clone() };+ let renderer = StrokeRenderer::new(&changed, Some(PixelFormat::RGBA8), 1.0);+ assert!(Arc::ptr_eq(ctx.tip.as_ref().unwrap(), renderer.ctx.tip.as_ref().unwrap()));+ assert!(Arc::ptr_eq(ctx.dual_tip.as_ref().unwrap(), renderer.ctx.dual_tip.as_ref().unwrap()));+ }+ assert!(BrushContext::new(&BrushSettings::default()).tip.is_none());+}++#[test]+fn loading_or_replacing_a_stored_tip_never_reuses_its_preview_mips() {+ let preview = GrayTile::from_fn(8, 8, |_, _| 0.1);+ let stored = StoredTile { key: "unchanged-key".into(), width: 32, height: 32, preview: Arc::new(preview), full: None };+ let mut brush = BrushSettings { tip: TipShape::Stored(stored.clone()), ..Default::default() };+ let before = BrushContext::new(&brush);+ let full = GrayTile::from_fn(32, 32, |_, _| 0.9);+ brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(full.clone())), ..stored.clone() });+ let loaded = BrushContext::new(&brush);+ assert!(!Arc::ptr_eq(before.tip.as_ref().unwrap(), loaded.tip.as_ref().unwrap()));+ assert_eq!(loaded.tip.as_ref().unwrap().levels, Mips::new(&full).levels);+ let replacement = GrayTile::from_fn(32, 32, |_, _| 0.3);+ brush.tip = TipShape::Stored(StoredTile { full: Some(Arc::new(replacement.clone())), ..stored });+ let replaced = BrushContext::new(&brush);+ assert!(!Arc::ptr_eq(loaded.tip.as_ref().unwrap(), replaced.tip.as_ref().unwrap()));+ assert_eq!(replaced.tip.as_ref().unwrap().levels, Mips::new(&replacement).levels);+}++#[test]+fn cached_and_uncached_strokes_match_exactly_at_native_depths_and_modes() {+ let tip = GrayTile::from_fn(31, 17, |x, y| ((x * 13 + y * 7) % 31) as f32 / 30.0);+ let brush = BrushSettings {+ tip: TipShape::Sampled(tip.clone()),+ dual_brush: DualBrush { enabled: true, size: 19.0, tip: TipShape::Sampled(tip.clone()), ..Default::default() },+ size: 27.0,+ angle: 0.4,+ roundness: 0.7,+ flow: 0.5,+ opacity: 0.7,+ wet_edges: true,+ noise: true,+ smoothing: Smoothing { amount: 0.49, ..Default::default() },+ seed: 42,+ ..Default::default()+ };+ let bounds = Rect::new(-32, -32, 96, 96);+ let selection = Surface::with_default(PixelFormat::GRAY8, &[0.6]);+ for fmt in [PixelFormat::RGBA8, PixelFormat::GRAY8, PixelFormat::CMYKA8] {+ for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {+ let fmt = fmt.with_sample(sample);+ let mut pre = Surface::new(fmt);+ pre.fill_rect(bounds, &photocraft_raster::from_rgba(&fmt, [0.3, 0.6, 0.8, 0.7]));+ let render = |uncached: bool| {+ let mut r = StrokeRenderer::new(&brush, Some(fmt), 1.0);+ if uncached {+ r.ctx.tip = Some(Arc::new(Mips::new(&tip)));+ r.ctx.dual_tip = Some(Arc::new(Mips::new(&tip)));+ }+ let mut target = pre.clone();+ for i in 0..8 {+ r.push(&[StrokePoint::new(-3.0 + f64::from(i) * 8.0, 24.0 + f64::from(i % 3) * 4.0, 0.4 + i as f32 * 0.07)]);+ r.composite(&pre, &mut target, Some(&selection), false, false);+ if let Some(mut tail) = r.tail_preview() {+ tail.composite(&pre, &mut target, Some(&selection), false, false);+ }+ }+ r.finish();+ r.composite(&pre, &mut target, Some(&selection), false, true);+ (r.bounds(), target.read_region(bounds))+ };+ let cold = render(false);+ let uncached = render(true);+ let warm = render(false);+ assert_eq!(uncached.0, cold.0);+ assert_eq!(cold.0, warm.0);+ assert!(uncached.1.iter().zip(&cold.1).all(|(a, b)| a.to_bits() == b.to_bits()), "cold {fmt:?}");+ assert!(cold.1.iter().zip(&warm.1).all(|(a, b)| a.to_bits() == b.to_bits()), "warm {fmt:?}");+ assert_ne!(cold.1, pre.read_region(bounds), "the regression must paint pixels");+ }+ }+ // A public-context caller still gets the identical sampled coverage at sub-pixel centres.+ let ctx = BrushContext::new(&brush);+ let dab = Dab::round(Point::new(31.25, 12.75), 9.5, 0.8);+ let mut cached = Vec::new();+ ctx.rasterize(&dab, false, ctx.dab_rect(&dab, false), &mut cached);+ let mut reference = ctx.clone();+ reference.tip = Some(Arc::new(Mips::new(&tip)));+ let mut uncached = Vec::new();+ reference.rasterize(&dab, false, reference.dab_rect(&dab, false), &mut uncached);+ assert!(cached.iter().zip(&uncached).all(|(a, b)| a.to_bits() == b.to_bits()));+}@@ -233,7 +233,7 @@ impl BrushContext { pub fn new(brush: &BrushSettings) -> Self { let brush = brush.bounded_for_render(); let mips = |t: &TipShape| match t.bitmap() {- Some(g) if g.is_valid() => Some(Arc::new(Mips::new(g))),+ Some(g) if g.is_valid() => Some(crate::mip_cache::tip_mips(g)), _ => None, }; Self {@@ -1294,3 +1294,7 @@ impl RoundTip { #[cfg(test)] mod reference;++#[cfg(test)]+#[path = "mip_cache_render_tests.rs"]+mod mip_cache_tests;