Skip to main content

dotloom_render/
cache.rs

1//! Renderer-side scene cache: items, draw order, chunked batches.
2//!
3//! Items are kept in draw order (layer, then the scene's order) and grouped into
4//! consecutive *chunks*. Each chunk is one set of GPU buffers with a world-space
5//! bounding box used for viewport culling. Inside a chunk, geometry is grouped by
6//! kind (fills, lines, glyphs) into *runs*: a new run starts only when an item
7//! would otherwise be drawn below something that precedes it and overlaps it, so
8//! batching never changes the visible stacking order.
9//!
10//! Item meshes are cached and only re-tessellated when the item changes, when a
11//! curve item is drawn at a different zoom bucket, or when the glyph atlas is reset.
12
13use std::collections::HashMap;
14use std::collections::hash_map::DefaultHasher;
15use std::hash::{Hash, Hasher};
16use std::ops::Range;
17
18use dotloom_geometry::Aabb;
19use dotloom_scene::{SceneDelta, SceneItem};
20
21use crate::color::Theme;
22use crate::gpu::ChunkBuffers;
23use crate::tess::{
24    FillVertex, GlyphInstance, LineInstance, Look, Mesh, item_has_curves, item_has_text, tessellate_item,
25};
26use crate::text::TextSystem;
27
28/// Maximum items per chunk.
29pub const CHUNK_ITEMS: usize = 256;
30/// Maximum world extent of a chunk (keeps `f32` offsets precise to ~8 µm).
31pub const MAX_CHUNK_EXTENT: f64 = 1.0e5;
32
33struct Entry {
34    item: SceneItem,
35    version: u64,
36    mesh: Option<CachedMesh>,
37    curves: bool,
38    text: bool,
39}
40
41struct CachedMesh {
42    mesh: Mesh,
43    lod: i32,
44    text_gen: u64,
45}
46
47/// A batch of draws that preserves stacking order.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub(crate) struct Run {
50    /// Fill index range.
51    pub fills: Range<u32>,
52    /// Line instance range.
53    pub lines: Range<u32>,
54    /// Glyph instance range.
55    pub glyphs: Range<u32>,
56}
57
58/// CPU geometry of a chunk.
59#[derive(Debug, Default)]
60pub(crate) struct ChunkData {
61    pub origin: [f64; 2],
62    pub lines: Vec<LineInstance>,
63    pub fill_vertices: Vec<FillVertex>,
64    pub fill_indices: Vec<u32>,
65    pub glyphs: Vec<GlyphInstance>,
66    pub runs: Vec<Run>,
67}
68
69/// One chunk of consecutive items.
70pub(crate) struct Chunk {
71    pub ids: Vec<u64>,
72    pub bbox: Aabb,
73    /// Contains items without a finite bounding box (never culled).
74    pub unbounded: bool,
75    /// Identity of the content (IDs and versions).
76    pub key: u64,
77    pub curves: bool,
78    pub text: bool,
79    /// Signature of the data currently built (`key`, lod, text generation, theme epoch).
80    pub built: Option<(u64, i32, u64, u64)>,
81    pub data: Option<ChunkData>,
82    /// GPU buffers and the signature they hold.
83    pub gpu: ChunkBuffers,
84    pub uploaded: Option<(u64, i32, u64, u64)>,
85}
86
87impl Chunk {
88    fn new(ids: Vec<u64>, key: u64, bbox: Aabb, unbounded: bool, curves: bool, text: bool) -> Self {
89        Self {
90            ids,
91            bbox,
92            unbounded,
93            key,
94            curves,
95            text,
96            built: None,
97            data: None,
98            gpu: ChunkBuffers::default(),
99            uploaded: None,
100        }
101    }
102
103    fn wanted(&self, lod: i32, text_gen: u64, epoch: u64) -> (u64, i32, u64, u64) {
104        (self.key, if self.curves { lod } else { 0 }, if self.text { text_gen } else { 0 }, epoch)
105    }
106}
107
108/// Per-frame inputs of [`SceneCache::prepare`].
109#[derive(Clone, Copy)]
110pub(crate) struct FrameParams<'a> {
111    /// Visible world rectangle.
112    pub visible: Aabb,
113    /// Extra world margin around it.
114    pub margin: f64,
115    /// Zoom bucket.
116    pub lod: i32,
117    /// Flatten tolerance of the bucket.
118    pub lod_tol: f64,
119    /// Colors.
120    pub theme: &'a Theme,
121}
122
123/// Counters for diagnostics.
124#[derive(Debug, Default, Clone, Copy)]
125pub(crate) struct PrepareStats {
126    pub rebuilt_chunks: u32,
127    pub tessellated_items: u32,
128}
129
130/// Scene state held by the renderer.
131#[derive(Default)]
132pub struct SceneCache {
133    entries: HashMap<u64, Entry>,
134    order: Vec<u64>,
135    next_version: u64,
136    revision: u64,
137    pub(crate) chunks: Vec<Chunk>,
138    chunk_of: HashMap<u64, usize>,
139    partition_dirty: bool,
140    /// Bumped on theme changes (all meshes stale).
141    epoch: u64,
142}
143
144impl std::fmt::Debug for SceneCache {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("SceneCache")
147            .field("items", &self.entries.len())
148            .field("chunks", &self.chunks.len())
149            .field("revision", &self.revision)
150            .finish()
151    }
152}
153
154fn finite_box(b: Aabb) -> Option<Aabb> {
155    (!b.is_empty() && b.min.is_finite() && b.max.is_finite()).then_some(b)
156}
157
158impl SceneCache {
159    /// Number of items.
160    #[must_use]
161    pub fn len(&self) -> usize {
162        self.entries.len()
163    }
164
165    /// Whether the scene is empty.
166    #[must_use]
167    pub fn is_empty(&self) -> bool {
168        self.entries.is_empty()
169    }
170
171    /// Revision of the last applied committed delta.
172    #[must_use]
173    pub fn revision(&self) -> u64 {
174        self.revision
175    }
176
177    /// An item by ID.
178    #[must_use]
179    pub fn item(&self, id: u64) -> Option<&SceneItem> {
180        self.entries.get(&id).map(|e| &e.item)
181    }
182
183    /// Union of all item boxes.
184    #[must_use]
185    pub fn bounds(&self) -> Aabb {
186        self.entries.values().filter_map(|e| finite_box(e.item.bbox)).fold(Aabb::EMPTY, Aabb::union)
187    }
188
189    /// Apply a scene delta.
190    pub fn apply(&mut self, delta: SceneDelta) {
191        if delta.reset {
192            self.entries.clear();
193            self.order.clear();
194            self.partition_dirty = true;
195        }
196        for id in &delta.removals {
197            if self.entries.remove(id).is_some() {
198                self.partition_dirty = true;
199            }
200        }
201        for item in delta.upserts {
202            self.next_version += 1;
203            let id = item.id;
204            let curves = item_has_curves(&item);
205            let text = item_has_text(&item);
206            let layer_changed = self.entries.get(&id).is_none_or(|e| e.item.layer != item.layer);
207            if layer_changed {
208                self.partition_dirty = true;
209            }
210            // Box changes can move the chunk's culling box; content key changes.
211            if !self.partition_dirty
212                && let Some(&ci) = self.chunk_of.get(&id)
213                && let Some(c) = self.chunks.get_mut(ci)
214            {
215                c.key = 0;
216            }
217            self.entries.insert(id, Entry { item, version: self.next_version, mesh: None, curves, text });
218        }
219        if let Some(order) = delta.order {
220            self.order = order;
221            self.partition_dirty = true;
222        }
223        if !delta.preview {
224            self.revision = delta.revision;
225        }
226        if !self.partition_dirty && self.chunks.iter().any(|c| c.key == 0) {
227            // Recompute keys/boxes of touched chunks without re-partitioning.
228            for ci in 0..self.chunks.len() {
229                if self.chunks[ci].key == 0 {
230                    self.refresh_chunk(ci);
231                }
232            }
233        }
234    }
235
236    /// Theme changed: every mesh must be rebuilt.
237    pub(crate) fn invalidate_all(&mut self) {
238        self.epoch += 1;
239        for e in self.entries.values_mut() {
240            e.mesh = None;
241        }
242    }
243
244    fn refresh_chunk(&mut self, ci: usize) {
245        let Some(c) = self.chunks.get(ci) else { return };
246        let ids = c.ids.clone();
247        let (key, bbox, unbounded, curves, text) = self.describe(&ids);
248        if let Some(c) = self.chunks.get_mut(ci) {
249            c.key = key;
250            c.bbox = bbox;
251            c.unbounded = unbounded;
252            c.curves = curves;
253            c.text = text;
254        }
255    }
256
257    fn describe(&self, ids: &[u64]) -> (u64, Aabb, bool, bool, bool) {
258        let mut h = DefaultHasher::new();
259        let mut bbox = Aabb::EMPTY;
260        let mut unbounded = false;
261        let (mut curves, mut text) = (false, false);
262        for id in ids {
263            id.hash(&mut h);
264            if let Some(e) = self.entries.get(id) {
265                e.version.hash(&mut h);
266                match finite_box(e.item.bbox) {
267                    Some(b) => bbox = bbox.union(b),
268                    None => unbounded |= !e.item.prims.is_empty(),
269                }
270                curves |= e.curves;
271                text |= e.text;
272            }
273        }
274        // 0 is reserved for "stale".
275        (h.finish().max(1), bbox, unbounded, curves, text)
276    }
277
278    fn sorted_ids(&self) -> Vec<u64> {
279        let pos: HashMap<u64, usize> = self.order.iter().enumerate().map(|(i, id)| (*id, i)).collect();
280        let mut ids: Vec<u64> = self.entries.keys().copied().collect();
281        ids.sort_by_key(|id| {
282            let layer = self.entries.get(id).map_or(u32::MAX, |e| e.item.layer);
283            (layer, pos.get(id).copied().unwrap_or(usize::MAX), *id)
284        });
285        ids
286    }
287
288    fn repartition(&mut self) {
289        let ids = self.sorted_ids();
290        let mut groups: Vec<Vec<u64>> = Vec::new();
291        let mut cur: Vec<u64> = Vec::new();
292        let mut cur_box = Aabb::EMPTY;
293        for id in ids {
294            let b = self.entries.get(&id).and_then(|e| finite_box(e.item.bbox));
295            let grown = b.map_or(cur_box, |b| cur_box.union(b));
296            let too_wide = !grown.is_empty() && grown.width().max(grown.height()) > MAX_CHUNK_EXTENT;
297            if !cur.is_empty() && (cur.len() >= CHUNK_ITEMS || too_wide) {
298                groups.push(core::mem::take(&mut cur));
299                cur_box = Aabb::EMPTY;
300            }
301            if let Some(b) = b {
302                cur_box = cur_box.union(b);
303            }
304            cur.push(id);
305        }
306        if !cur.is_empty() {
307            groups.push(cur);
308        }
309        let mut old: HashMap<u64, Chunk> = self.chunks.drain(..).map(|c| (c.key, c)).collect();
310        let mut chunks = Vec::with_capacity(groups.len());
311        for g in groups {
312            let (key, bbox, unbounded, curves, text) = self.describe(&g);
313            match old.remove(&key) {
314                Some(mut c) if c.ids == g => {
315                    c.bbox = bbox;
316                    c.unbounded = unbounded;
317                    chunks.push(c);
318                }
319                _ => chunks.push(Chunk::new(g, key, bbox, unbounded, curves, text)),
320            }
321        }
322        // Reuse GPU buffers of dropped chunks for new ones (avoids reallocations).
323        let mut spare: Vec<ChunkBuffers> = old.into_values().map(|c| c.gpu).collect();
324        for c in &mut chunks {
325            if c.uploaded.is_none()
326                && c.gpu.is_empty()
327                && let Some(b) = spare.pop()
328            {
329                c.gpu = b;
330            }
331        }
332        for b in spare {
333            b.destroy();
334        }
335        self.chunk_of.clear();
336        for (ci, c) in chunks.iter().enumerate() {
337            for id in &c.ids {
338                self.chunk_of.insert(*id, ci);
339            }
340        }
341        self.chunks = chunks;
342        self.partition_dirty = false;
343    }
344
345    fn mesh_ok(e: &Entry, lod: i32, text_gen: u64) -> bool {
346        e.mesh.as_ref().is_some_and(|m| (!e.curves || m.lod == lod) && (!e.text || m.text_gen == text_gen))
347    }
348
349    /// Make sure visible chunks have up-to-date CPU data. Returns visible chunk
350    /// indices in draw order.
351    pub(crate) fn prepare(
352        &mut self,
353        fp: &FrameParams<'_>,
354        ts: &mut TextSystem,
355        stats: &mut PrepareStats,
356    ) -> Vec<usize> {
357        let FrameParams { visible, margin, lod, lod_tol, theme } = *fp;
358        if self.partition_dirty {
359            self.repartition();
360        }
361        let view = visible.inflate(margin);
362        let vis: Vec<usize> = (0..self.chunks.len())
363            .filter(|&ci| {
364                let c = &self.chunks[ci];
365                c.unbounded || (!c.bbox.is_empty() && c.bbox.intersects(view))
366            })
367            .collect();
368        // The atlas may be reset while laying out text; retry once with the new generation.
369        for _ in 0..3 {
370            let text_gen = ts.generation;
371            let mut reset = false;
372            for &ci in &vis {
373                let want = self.chunks[ci].wanted(lod, text_gen, self.epoch);
374                if self.chunks[ci].built == Some(want) {
375                    continue;
376                }
377                let ids = self.chunks[ci].ids.clone();
378                for id in &ids {
379                    let Some(e) = self.entries.get_mut(id) else { continue };
380                    if Self::mesh_ok(e, lod, text_gen) {
381                        continue;
382                    }
383                    match tessellate_item(&e.item, Look { flags: e.item.flags }, lod_tol, theme, ts) {
384                        Some(mesh) => {
385                            e.mesh = Some(CachedMesh { mesh, lod, text_gen });
386                            stats.tessellated_items += 1;
387                        }
388                        None => {
389                            reset = true;
390                            break;
391                        }
392                    }
393                }
394                if reset {
395                    break;
396                }
397                let data = self.assemble(&ids);
398                let c = &mut self.chunks[ci];
399                c.data = Some(data);
400                c.built = Some(want);
401                stats.rebuilt_chunks += 1;
402            }
403            if !reset {
404                break;
405            }
406        }
407        vis
408    }
409
410    fn assemble(&self, ids: &[u64]) -> ChunkData {
411        let meshes: Vec<(&Mesh, Aabb)> = ids
412            .iter()
413            .filter_map(|id| self.entries.get(id))
414            .filter_map(|e| e.mesh.as_ref().map(|m| (&m.mesh, e.item.bbox)))
415            .collect();
416        let origin = {
417            let b = meshes.iter().filter_map(|(_, b)| finite_box(*b)).fold(Aabb::EMPTY, Aabb::union);
418            if b.is_empty() {
419                meshes.first().map_or([0.0, 0.0], |(m, _)| m.origin)
420            } else {
421                let c = b.center();
422                [c.x, c.y]
423            }
424        };
425        let mut d = ChunkData { origin, ..ChunkData::default() };
426        let mut run = Run::default();
427        // Boxes of items in the current run that drew lines/glyphs, and glyphs only.
428        let mut line_or_glyph: Vec<Aabb> = Vec::new();
429        let mut glyph_boxes: Vec<Aabb> = Vec::new();
430        let overlaps = |list: &[Aabb], b: Option<Aabb>| match b {
431            Some(b) => list.iter().any(|x| x.intersects(b)),
432            None => !list.is_empty(),
433        };
434        for (m, bbox) in meshes {
435            if m.is_empty() {
436                continue;
437            }
438            let b = finite_box(bbox).map(|b| b.inflate(1e-9));
439            let has_fill = !m.fill_indices.is_empty();
440            let has_line = !m.lines.is_empty();
441            let has_glyph = !m.glyphs.is_empty();
442            let split = (has_fill && overlaps(&line_or_glyph, b)) || (has_line && overlaps(&glyph_boxes, b));
443            if split {
444                d.runs.push(core::mem::take(&mut run));
445                let (f, l, g) = (d.fill_indices.len() as u32, d.lines.len() as u32, d.glyphs.len() as u32);
446                run = Run { fills: f..f, lines: l..l, glyphs: g..g };
447                line_or_glyph.clear();
448                glyph_boxes.clear();
449            }
450            let off = [(m.origin[0] - origin[0]) as f32, (m.origin[1] - origin[1]) as f32];
451            let add = |p: [f32; 2]| [p[0] + off[0], p[1] + off[1]];
452            let base = d.fill_vertices.len() as u32;
453            d.fill_vertices.extend(m.fill_vertices.iter().map(|v| FillVertex { pos: add(v.pos), ..*v }));
454            d.fill_indices.extend(m.fill_indices.iter().map(|i| i + base));
455            d.lines.extend(m.lines.iter().map(|l| LineInstance { p0: add(l.p0), p1: add(l.p1), ..*l }));
456            d.glyphs.extend(m.glyphs.iter().map(|g| GlyphInstance { origin: add(g.origin), ..*g }));
457            run.fills.end = d.fill_indices.len() as u32;
458            run.lines.end = d.lines.len() as u32;
459            run.glyphs.end = d.glyphs.len() as u32;
460            if let Some(b) = b {
461                if has_line || has_glyph {
462                    line_or_glyph.push(b);
463                }
464                if has_glyph {
465                    glyph_boxes.push(b);
466                }
467            } else {
468                // Unknown extent: be conservative.
469                if has_line || has_glyph {
470                    line_or_glyph.push(Aabb::from_corners(
471                        dotloom_geometry::Point::new(f64::MIN, f64::MIN),
472                        dotloom_geometry::Point::new(f64::MAX, f64::MAX),
473                    ));
474                }
475            }
476        }
477        if run != Run::default() {
478            d.runs.push(run);
479        }
480        d.runs.retain(|r| !(r.fills.is_empty() && r.lines.is_empty() && r.glyphs.is_empty()));
481        d
482    }
483
484    /// Release GPU buffers of all chunks.
485    pub(crate) fn destroy_gpu(&mut self) {
486        for c in &mut self.chunks {
487            core::mem::take(&mut c.gpu).destroy();
488            c.uploaded = None;
489        }
490    }
491
492    /// IDs in draw order (for tests and diagnostics).
493    #[must_use]
494    pub fn draw_order(&mut self) -> Vec<u64> {
495        if self.partition_dirty {
496            self.repartition();
497        }
498        self.chunks.iter().flat_map(|c| c.ids.iter().copied()).collect()
499    }
500
501    /// Chunk IDs (for tests).
502    #[cfg(test)]
503    pub(crate) fn chunk_ids(&mut self) -> Vec<Vec<u64>> {
504        if self.partition_dirty {
505            self.repartition();
506        }
507        self.chunks.iter().map(|c| c.ids.clone()).collect()
508    }
509
510    /// Number of distinct chunk keys (used to check reuse).
511    #[cfg(test)]
512    pub(crate) fn keys(&self) -> std::collections::HashSet<u64> {
513        self.chunks.iter().map(|c| c.key).collect()
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use dotloom_geometry::{Point, Rect, Segment, Shape};
520    use dotloom_scene::{Primitive, Stroke};
521
522    use super::*;
523
524    fn line_item(id: u64, x: f64, layer: u32) -> SceneItem {
525        let shape = Shape::Line(Segment::new(Point::new(x, 0.0), Point::new(x + 1.0, 0.0)));
526        SceneItem {
527            id,
528            layer,
529            bbox: shape.bbox(),
530            flags: 0,
531            prims: vec![Primitive::Shape {
532                shape,
533                stroke: Some(Stroke { color: 0, width: 1.0, dash: vec![] }),
534                fill: None,
535            }],
536        }
537    }
538
539    fn rect_item(id: u64, x: f64, fill: bool, stroke: bool) -> SceneItem {
540        let shape = Shape::Rect(Rect { origin: Point::new(x, 0.0), width: 10.0, height: 10.0 });
541        SceneItem {
542            id,
543            layer: 0,
544            bbox: shape.bbox(),
545            flags: 0,
546            prims: vec![Primitive::Shape {
547                shape,
548                stroke: stroke.then(|| Stroke { color: 0, width: 1.0, dash: vec![] }),
549                fill: fill.then_some(0xff00_00ff),
550            }],
551        }
552    }
553
554    fn delta(upserts: Vec<SceneItem>, order: Option<Vec<u64>>) -> SceneDelta {
555        SceneDelta { revision: 1, preview: false, reset: false, upserts, removals: vec![], order }
556    }
557
558    fn prepare(c: &mut SceneCache) -> Vec<usize> {
559        let mut ts = TextSystem::with_default_font().unwrap();
560        let mut st = PrepareStats::default();
561        let all = Aabb::from_corners(Point::new(-1e7, -1e7), Point::new(1e7, 1e7));
562        c.prepare(
563            &FrameParams { visible: all, margin: 0.0, lod: 0, lod_tol: 0.1, theme: &Theme::light() },
564            &mut ts,
565            &mut st,
566        )
567    }
568
569    #[test]
570    fn order_follows_layer_then_scene_order() {
571        let mut c = SceneCache::default();
572        c.apply(delta(vec![line_item(1, 0.0, 1), line_item(2, 0.0, 0), line_item(3, 0.0, 0)], Some(vec![1, 3, 2])));
573        assert_eq!(c.draw_order(), vec![3, 2, 1]);
574    }
575
576    #[test]
577    fn chunks_split_by_count_and_reuse_unchanged_chunks() {
578        let mut c = SceneCache::default();
579        let n = CHUNK_ITEMS as u64 * 2 + 10;
580        let items: Vec<SceneItem> = (1..=n).map(|i| line_item(i, i as f64, 0)).collect();
581        c.apply(delta(items, Some((1..=n).collect())));
582        let ids = c.chunk_ids();
583        assert_eq!(ids.len(), 3);
584        let before = c.keys();
585        // Appending an item only changes the last chunk.
586        c.apply(delta(vec![line_item(n + 1, 0.0, 0)], Some((1..=n + 1).collect())));
587        c.chunk_ids();
588        let after = c.keys();
589        assert_eq!(before.intersection(&after).count(), 2);
590        // Updating one item changes exactly one chunk key without repartitioning.
591        c.apply(delta(vec![line_item(5, 99.0, 0)], None));
592        let changed = c.keys();
593        assert_eq!(after.intersection(&changed).count(), 2);
594    }
595
596    #[test]
597    fn far_apart_items_get_separate_chunks() {
598        let mut c = SceneCache::default();
599        c.apply(delta(vec![line_item(1, 0.0, 0), line_item(2, 1.0e6, 0)], Some(vec![1, 2])));
600        assert_eq!(c.chunk_ids().len(), 2);
601    }
602
603    #[test]
604    fn runs_preserve_stacking() {
605        let mut c = SceneCache::default();
606        // 1: outlined rect, 2: filled rect overlapping 1 → its fill must come after 1's lines.
607        // 3: filled rect far away → can join the same run.
608        c.apply(delta(
609            vec![rect_item(1, 0.0, false, true), rect_item(2, 5.0, true, false), rect_item(3, 100.0, true, false)],
610            Some(vec![1, 2, 3]),
611        ));
612        let vis = prepare(&mut c);
613        assert_eq!(vis, vec![0]);
614        let d = c.chunks[0].data.as_ref().unwrap();
615        assert_eq!(d.runs.len(), 2, "{:?}", d.runs);
616        assert_eq!(d.runs[0].lines.len(), 4);
617        assert!(d.runs[0].fills.is_empty());
618        assert_eq!(d.runs[1].fills.len(), 12);
619    }
620
621    #[test]
622    fn culling_and_lod_rebuilds() {
623        let mut c = SceneCache::default();
624        let circle = Shape::Circle(dotloom_geometry::Circle::new(Point::new(0.0, 0.0), 50.0).unwrap());
625        let it = SceneItem {
626            id: 1,
627            layer: 0,
628            bbox: circle.bbox(),
629            flags: 0,
630            prims: vec![Primitive::Shape {
631                shape: circle,
632                stroke: Some(Stroke { color: 0, width: 1.0, dash: vec![] }),
633                fill: None,
634            }],
635        };
636        c.apply(delta(vec![it, line_item(2, 1.0e6, 0)], Some(vec![1, 2])));
637        let mut ts = TextSystem::with_default_font().unwrap();
638        let mut st = PrepareStats::default();
639        let view = Aabb::from_corners(Point::new(-100.0, -100.0), Point::new(100.0, 100.0));
640        let vis = c.prepare(
641            &FrameParams { visible: view, margin: 0.0, lod: 0, lod_tol: 0.25, theme: &Theme::light() },
642            &mut ts,
643            &mut st,
644        );
645        assert_eq!(vis.len(), 1, "far chunk culled");
646        assert_eq!(st.tessellated_items, 1);
647        let n0 = c.chunks[vis[0]].data.as_ref().unwrap().lines.len();
648        // Same LOD: nothing rebuilt.
649        let mut st2 = PrepareStats::default();
650        c.prepare(
651            &FrameParams { visible: view, margin: 0.0, lod: 0, lod_tol: 0.25, theme: &Theme::light() },
652            &mut ts,
653            &mut st2,
654        );
655        assert_eq!(st2.rebuilt_chunks, 0);
656        // Zoom in 8×: curve re-tessellated with more segments.
657        let mut st3 = PrepareStats::default();
658        c.prepare(
659            &FrameParams { visible: view, margin: 0.0, lod: 3, lod_tol: 0.25 / 8.0, theme: &Theme::light() },
660            &mut ts,
661            &mut st3,
662        );
663        assert_eq!(st3.rebuilt_chunks, 1);
664        assert!(c.chunks[vis[0]].data.as_ref().unwrap().lines.len() > n0 * 2);
665    }
666
667    #[test]
668    fn removal_and_reset() {
669        let mut c = SceneCache::default();
670        c.apply(delta(vec![line_item(1, 0.0, 0), line_item(2, 0.0, 0)], Some(vec![1, 2])));
671        c.apply(SceneDelta { revision: 2, removals: vec![1], order: Some(vec![2]), ..SceneDelta::default() });
672        assert_eq!(c.draw_order(), vec![2]);
673        assert_eq!(c.revision(), 2);
674        c.apply(SceneDelta { revision: 3, reset: true, ..SceneDelta::default() });
675        assert!(c.is_empty());
676        assert!(c.draw_order().is_empty());
677    }
678}