collective

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

diff --git a/crates/engine/examples/bench_brush_mips.rs b/crates/engine/examples/bench_brush_mips.rsnew file mode 100644--- /dev/null+++ b/crates/engine/examples/bench_brush_mips.rs@@ -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, &params)?;+                    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(())+}diff --git a/crates/engine/src/cmd/edit_menu_cmds/purge.rs b/crates/engine/src/cmd/edit_menu_cmds/purge.rs--- a/crates/engine/src/cmd/edit_menu_cmds/purge.rs+++ b/crates/engine/src/cmd/edit_menu_cmds/purge.rs@@ -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())diff --git a/crates/engine/src/cmd/edit_menu_cmds/tests.rs b/crates/engine/src/cmd/edit_menu_cmds/tests.rs--- a/crates/engine/src/cmd/edit_menu_cmds/tests.rs+++ b/crates/engine/src/cmd/edit_menu_cmds/tests.rs@@ -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);diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs--- a/crates/paint/src/lib.rs+++ b/crates/paint/src/lib.rs@@ -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};diff --git a/crates/paint/src/mip_cache.rs b/crates/paint/src/mip_cache.rsnew file mode 100644--- /dev/null+++ b/crates/paint/src/mip_cache.rs@@ -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);+    }+}diff --git a/crates/paint/src/mip_cache_render_tests.rs b/crates/paint/src/mip_cache_render_tests.rsnew file mode 100644--- /dev/null+++ b/crates/paint/src/mip_cache_render_tests.rs@@ -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()));+}diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs--- a/crates/paint/src/render.rs+++ b/crates/paint/src/render.rs@@ -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;