Skip to main content

long_screen/kernels/
temporal.rs

1//! Temporal-conflict compositing: pure algorithms behind `Compositor` (src/core/compositor.ts) that do not
2//! themselves touch KV storage. `components`, the pixel-write half of `overwritePatch` (`overwrite_tile`), the
3//! in-memory temporal index (`TemporalIndex`) and `resolveTemporal`'s decision (`TemporalIndex::decide` +
4//! `mask_complete` + `TemporalIndex::commit`) all live here (see docs/ARCHITECTURE.md §十 "合成与瞬态像素" for
5//! the invariants they keep — `complete` means "rewrote every previously covered pixel of the block", maskComplete
6//! is all-or-nothing over the whole connected component, frozen blocks still heal provisional pixels).
7
8use crate::kernels::geometry::{js_round, Rect};
9use std::collections::{HashMap, HashSet};
10
11pub const QUALITY_BLOCK: usize = 16;
12
13#[inline]
14fn bit(bits: &[u8], p: usize) -> bool {
15    bits[p >> 3] & (1 << (p & 7)) != 0
16}
17#[inline]
18fn set_bit(bits: &mut [u8], p: usize) {
19    bits[p >> 3] |= 1 << (p & 7);
20}
21#[inline]
22fn clear_bit(bits: &mut [u8], p: usize) {
23    bits[p >> 3] &= !(1 << (p & 7));
24}
25
26/// Mutable view of the tile buffers `overwrite_tile` touches. `frozen` is written here (unlike
27/// `compositor::TileBuffers`, where `composite_tile` only ever reads it) because `overwritePatch` sets it under
28/// the 'stable' policy.
29pub struct OverwriteTile<'a> {
30    pub size: usize,
31    pub pixels: &'a mut [u8],
32    pub coverage: &'a mut [u8],
33    pub provisional: &'a mut [u8],
34    pub quality: &'a mut [u8],
35    pub conflicts: &'a mut [u8],
36    pub owner: &'a mut [u32],
37    pub frozen: &'a mut [u8],
38}
39
40#[derive(Default, Debug, PartialEq)]
41pub struct OverwriteStats {
42    pub added: u32,
43    /// Every pixel written inside `blocks`, not only pixels whose value actually differed — mirrors
44    /// `PatchResult.conflictPixels` in src/core/compositor.ts.
45    pub conflict_pixels: u32,
46    pub provisional_delta: i32,
47    pub changed: bool,
48}
49
50/// Mirrors `Compositor.overwritePatch`'s per-tile inner loop (src/core/compositor.ts): unconditionally copies
51/// every pixel of `blocks` (absolute block coordinates already restricted to this tile) from `rgba` into the
52/// tile, marking coverage, healing any provisional bit, and bumping owner/quality/conflicts/frozen evidence.
53/// The caller (`resolveTemporal`) has already proven the whole written set world-consistent (`maskComplete`)
54/// before calling this, so every pixel here is guaranteed in-bounds — this mirrors, not re-derives, that guarantee.
55#[allow(clippy::too_many_arguments)]
56pub fn overwrite_tile(
57    tile: &mut OverwriteTile<'_>,
58    rgba: &[u8],
59    img_width: i64,
60    blocks: &[(i64, i64)],
61    ox: i64,
62    oy: i64,
63    tx: i64,
64    ty: i64,
65    frame: u32,
66    confidence: f64,
67    stable: bool,
68) -> OverwriteStats {
69    let size = tile.size as i64;
70    let b = QUALITY_BLOCK as i64;
71    let per_tile = size / b;
72    let mut stats = OverwriteStats::default();
73    // Same rounding as `composite_tile`'s quality byte (rust/long-screen/src/kernels/compositor.rs): Math.round, not Rust's
74    // round-half-away-from-zero, for the .5 case.
75    let quality = js_round(confidence * 255.0) as u8;
76    for &(bx, by) in blocks {
77        for y in by * b..by * b + b {
78            for x in bx * b..bx * b + b {
79                let src = (((y - oy) * img_width + (x - ox)) * 4) as usize;
80                let dst = (((y - ty * size) * size + (x - tx * size)) * 4) as usize;
81                tile.pixels[dst..dst + 4].copy_from_slice(&rgba[src..src + 4]);
82                let px = dst / 4;
83                if !bit(tile.coverage, px) {
84                    set_bit(tile.coverage, px);
85                    stats.added += 1;
86                }
87                if bit(tile.provisional, px) {
88                    clear_bit(tile.provisional, px);
89                    stats.provisional_delta -= 1;
90                }
91                stats.changed = true;
92                stats.conflict_pixels += 1;
93            }
94        }
95        let local_bx = bx - tx * per_tile;
96        let local_by = by - ty * per_tile;
97        let q = (local_by * per_tile + local_bx) as usize;
98        tile.owner[q] = frame + 1;
99        tile.quality[q] = quality;
100        tile.conflicts[q] = 1;
101        if stable {
102            tile.frozen[q] = 1;
103        }
104    }
105    stats
106}
107
108/// One 8-connected component of conflicting `size`-px blocks: its bounding rect in native pixels, and every
109/// block absolute coordinate that belongs to it (never just the bounding box — a concave, e.g. L-shaped,
110/// conflict must not drag a pixel-identical block in its bounding box into the patch).
111pub struct Component {
112    pub x0: i32,
113    pub y0: i32,
114    pub width: i32,
115    pub height: i32,
116    pub blocks: Vec<(i32, i32)>,
117}
118
119/// Mirrors `Compositor.components` (src/core/compositor.ts): 8-connected components of `cells`, absolute block
120/// coordinates. `cells` must already be in the caller's chosen seed order (TS sorts by tile-then-local-block
121/// order before calling); a `HashSet` has no ordering guarantee, so a component's seed is the first `cells`
122/// entry not yet claimed by an earlier component, walked in that same order — matching
123/// `cells.values().next().value` on a `Set` built from the same sorted array. Within a component, block order
124/// does not affect the result (the caller only ever treats it as a set).
125pub fn components(cells: &[(i32, i32)], size: i32) -> Vec<Component> {
126    let mut remaining: HashSet<(i32, i32)> = cells.iter().copied().collect();
127    let mut out = Vec::new();
128    for &seed in cells {
129        if !remaining.remove(&seed) {
130            continue;
131        }
132        let mut queue = vec![seed];
133        let mut i = 0;
134        let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
135        while i < queue.len() {
136            let (x, y) = queue[i];
137            x0 = x0.min(x);
138            x1 = x1.max(x);
139            y0 = y0.min(y);
140            y1 = y1.max(y);
141            for dy in -1..=1 {
142                for dx in -1..=1 {
143                    let n = (x + dx, y + dy);
144                    if remaining.remove(&n) {
145                        queue.push(n);
146                    }
147                }
148            }
149            i += 1;
150        }
151        out.push(Component {
152            x0: x0 * size,
153            y0: y0 * size,
154            width: (x1 - x0 + 1) * size,
155            height: (y1 - y0 + 1) * size,
156            blocks: queue,
157        });
158    }
159    out
160}
161
162// --- Temporal index + resolveTemporal's decision (mirrors src/core/compositor.ts's `TemporalIndex`,
163// `resolveTemporal`, `persistedBlocks`). ---
164
165fn rect_union(a: Rect, b: Rect) -> Rect {
166    if a.width <= 0.0 || a.height <= 0.0 {
167        return b;
168    }
169    let (x, y) = (a.x.min(b.x), a.y.min(b.y));
170    Rect {
171        x,
172        y,
173        width: (a.x + a.width).max(b.x + b.width) - x,
174        height: (a.y + a.height).max(b.y + b.height) - y,
175    }
176}
177
178fn rect_intersect(a: Rect, b: Rect) -> Rect {
179    let (x, y) = (a.x.max(b.x), a.y.max(b.y));
180    Rect {
181        x,
182        y,
183        width: ((a.x + a.width).min(b.x + b.width) - x).max(0.0),
184        height: ((a.y + a.height).min(b.y + b.height) - y).max(0.0),
185    }
186}
187
188/// Persisted block order: ascending integer keys are (by, bx) row-major (`persistedBlocks` in
189/// src/core/compositor.ts, which sorts the packed `blockKey` — packing was only a JS Set/Map key-typing
190/// necessity; the same (by, bx) row-major order is expressed directly here).
191fn persisted_blocks(keys: &HashSet<(i32, i32)>) -> Vec<(i32, i32)> {
192    let mut v: Vec<(i32, i32)> = keys.iter().copied().collect();
193    v.sort_by_key(|&(bx, by)| (by, bx));
194    v
195}
196
197/// One in-memory temporal record (`ResidentTemporal` in src/core/compositor.ts — `blocks` there is the
198/// persisted, derived-on-write form of `keys` here).
199#[derive(Clone, Debug, PartialEq)]
200pub struct TemporalRecord {
201    pub id: String,
202    pub rect: Rect,
203    pub keys: HashSet<(i32, i32)>,
204    pub chosen_frame: u32,
205    pub chosen_time: f64,
206    pub complete: bool,
207    pub revisions: u32,
208}
209
210/// An insertion-ordered set of record ids: mirrors a JS `Set<string>` (`index.dirty` / `index.deleted`).
211/// `insert` of an id already present is a no-op (never moves it, matching `Set.add`); `remove` deletes it
212/// wherever it sits without disturbing the order of what remains (matching `Set.delete`); a genuinely new id is
213/// appended at the end. Deliberately NOT an indexmap `IndexSet` with `swap_remove` (which reorders on removal) —
214/// see the row-order tripwire in tests/unit/compositor.test.ts.
215#[derive(Default, Clone)]
216struct OrderedIds {
217    order: Vec<String>,
218}
219impl OrderedIds {
220    fn insert(&mut self, id: &str) {
221        if !self.order.iter().any(|x| x == id) {
222            self.order.push(id.to_string());
223        }
224    }
225    fn remove(&mut self, id: &str) {
226        if let Some(pos) = self.order.iter().position(|x| x == id) {
227            self.order.remove(pos);
228        }
229    }
230    fn take(&mut self) -> Vec<String> {
231        std::mem::take(&mut self.order)
232    }
233}
234
235/// Mirrors a JS `Map<string, TemporalRecord>` (`index.records`): `set` on an existing key overwrites the value
236/// in place without moving it; `set` on a new key appends it; `delete` removes it without reordering the rest;
237/// `values_in_order` walks insertion order, exactly `for (const t of index.records.values())`.
238#[derive(Default)]
239struct RecordMap {
240    order: Vec<String>,
241    map: HashMap<String, TemporalRecord>,
242}
243impl RecordMap {
244    fn get(&self, id: &str) -> Option<&TemporalRecord> {
245        self.map.get(id)
246    }
247    fn set(&mut self, record: TemporalRecord) {
248        if !self.map.contains_key(&record.id) {
249            self.order.push(record.id.clone());
250        }
251        self.map.insert(record.id.clone(), record);
252    }
253    fn delete(&mut self, id: &str) {
254        if self.map.remove(id).is_some() {
255            if let Some(pos) = self.order.iter().position(|x| x == id) {
256                self.order.remove(pos);
257            }
258        }
259    }
260    fn values_in_order(&self) -> impl Iterator<Item = &TemporalRecord> {
261        self.order.iter().map(move |id| &self.map[id])
262    }
263}
264
265/// State stashed between `TemporalIndex::decide` and `TemporalIndex::commit` for the one component currently
266/// being resolved (never more than one outstanding at a time — `resolveTemporal` is synchronous end to end
267/// apart from the pixel write, which happens between the two Rust calls, never overlapping them).
268struct PendingResolve {
269    record: TemporalRecord,
270    old_existed: bool,
271    expanded: bool,
272    choose: bool,
273    olds_to_delete: Vec<String>,
274    rect: Rect,
275    write_blocks: Vec<(i32, i32)>,
276    frame: u32,
277    time: f64,
278}
279
280#[derive(Default)]
281pub struct TemporalIndex {
282    records: RecordMap,
283    dirty: OrderedIds,
284    deleted: OrderedIds,
285    pending: Option<PendingResolve>,
286}
287
288pub struct CommitOut {
289    pub chosen: bool,
290    pub emit_incomplete: bool,
291    pub rect: Rect,
292}
293
294impl TemporalIndex {
295    pub fn new() -> Self {
296        Self::default()
297    }
298
299    /// Seeds one persisted row (`temporalIndex()`'s KV load loop in src/core/compositor.ts calls this once per
300    /// row, in KV scan order — ascending id).
301    pub fn load(&mut self, record: TemporalRecord) {
302        self.records.set(record);
303    }
304
305    /// The decision half of `resolveTemporal` up to (not including) the maskComplete walk — everything that
306    /// does not need atlas/occlusion/consistency pixels. Stashes the candidate record and returns how many
307    /// blocks `mask_complete` + the second call will need a buffer for (0 means this component's record cannot
308    /// be chosen this frame, regardless of what the mask check would say), plus the (possibly incremented)
309    /// fresh-id sequence counter.
310    ///
311    /// `next_seq` is NOT this index's own state: `Compositor.temporalSequence` in src/core/compositor.ts is a
312    /// single counter shared across every canvas the Compositor ever touches (not reset per canvas), so the
313    /// caller threads it through every `decide()` call on every canvas's handle and keeps the returned value —
314    /// never derived from loaded ids, which is why a fresh id can collide with a still-loaded row (see the
315    /// id-collision tripwire tests in tests/unit/compositor.test.ts).
316    #[allow(clippy::too_many_arguments)]
317    pub fn decide(
318        &mut self,
319        comp_bounds: Rect,
320        comp_blocks: &[(i32, i32)],
321        frame: u32,
322        time: f64,
323        visible: Rect,
324        latest_policy: bool,
325        mut next_seq: u64,
326    ) -> (usize, u64) {
327        // `olds`: every existing record whose ±16px expanded rect overlaps this component's bounds, in
328        // `index.records` iteration (insertion) order — never just `olds[0]`, several nearby records can merge.
329        let mut olds: Vec<String> = Vec::new();
330        for rec in self.records.values_in_order() {
331            let expanded_rect = Rect {
332                x: rec.rect.x - 16.0,
333                y: rec.rect.y - 16.0,
334                width: rec.rect.width + 32.0,
335                height: rec.rect.height + 32.0,
336            };
337            let ix = rect_intersect(expanded_rect, comp_bounds);
338            if ix.width > 0.0 && ix.height > 0.0 {
339                olds.push(rec.id.clone());
340            }
341        }
342        let old_id = olds.first().cloned();
343        // Union is built into the LARGEST overlapping record's own key set to avoid re-hashing tens of
344        // thousands of keys per component — a JS `Set` reuse in the original; Rust ownership makes true
345        // in-place aliasing across `records.delete` below awkward, so this clones it once instead (harmless:
346        // nothing else observes that record's old key set again after this call).
347        let mut rect = comp_bounds;
348        let mut previous_size: usize = 0;
349        let mut largest_id: Option<String> = None;
350        let mut largest_size: usize = 0;
351        for id in &olds {
352            let rec = self.records.get(id).unwrap();
353            rect = rect_union(rect, rec.rect);
354            previous_size += rec.keys.len();
355            if largest_id.is_none() || rec.keys.len() > largest_size {
356                largest_size = rec.keys.len();
357                largest_id = Some(id.clone());
358            }
359        }
360        let mut mask: HashSet<(i32, i32)> = largest_id
361            .as_ref()
362            .map(|id| self.records.get(id).unwrap().keys.clone())
363            .unwrap_or_default();
364        for id in &olds {
365            if Some(id) != largest_id.as_ref() {
366                for k in &self.records.get(id).unwrap().keys {
367                    mask.insert(*k);
368                }
369            }
370        }
371        for k in comp_blocks {
372            mask.insert(*k);
373        }
374        let old_rec = old_id.as_ref().and_then(|id| self.records.get(id)).cloned();
375        let geometry_changed = match &old_rec {
376            None => true,
377            Some(o) => {
378                rect.x != o.rect.x
379                    || rect.y != o.rect.y
380                    || rect.width != o.rect.width
381                    || rect.height != o.rect.height
382            }
383        };
384        // The union contains every previous block by construction, so membership differs exactly when the
385        // size grew — comparing against the plain sum (`previous_size`), computed BEFORE the union, not the
386        // union's own size.
387        let membership_changed = match &old_rec {
388            None => true,
389            Some(_) => mask.len() != previous_size,
390        };
391        let expanded = geometry_changed || membership_changed;
392        let mut record = match &old_rec {
393            Some(o) => TemporalRecord {
394                id: o.id.clone(),
395                rect,
396                keys: mask.clone(),
397                chosen_frame: o.chosen_frame,
398                chosen_time: o.chosen_time,
399                complete: o.complete,
400                revisions: o.revisions,
401            },
402            None => {
403                let id = format!("{:010}", next_seq);
404                next_seq += 1;
405                TemporalRecord {
406                    id,
407                    rect,
408                    keys: mask.clone(),
409                    chosen_frame: frame,
410                    chosen_time: time,
411                    complete: false,
412                    revisions: 0,
413                }
414            }
415        };
416        if expanded {
417            record.complete = false;
418        }
419        let iv = rect_intersect(rect, visible);
420        let complete_visible = iv.width == rect.width && iv.height == rect.height;
421        let choose = complete_visible
422            && (old_rec.is_none()
423                || !old_rec.as_ref().unwrap().complete
424                || expanded
425                || latest_policy);
426        let write_blocks = if choose {
427            persisted_blocks(&mask)
428        } else {
429            Vec::new()
430        };
431        let write_block_count = write_blocks.len();
432        let olds_to_delete: Vec<String> = olds.into_iter().filter(|id| *id != record.id).collect();
433        self.pending = Some(PendingResolve {
434            record,
435            old_existed: old_rec.is_some(),
436            expanded,
437            choose,
438            olds_to_delete,
439            rect,
440            write_blocks,
441            frame,
442            time,
443        });
444        (write_block_count, next_seq)
445    }
446
447    /// The write-block set `decide` computed for the currently pending component (empty if none is pending, or
448    /// if it decided not to choose this component this frame) — read by the caller to run `mask_complete`
449    /// before calling `commit`.
450    pub fn pending_write_blocks(&self) -> &[(i32, i32)] {
451        self.pending
452            .as_ref()
453            .map(|p| p.write_blocks.as_slice())
454            .unwrap_or(&[])
455    }
456
457    /// Finalises the component `decide` staged: mirrors `resolveTemporal`'s tail (record commit into
458    /// `records`/`dirty`/`deleted`, and the `INCOMPLETE_TEMPORAL_PATCH` condition). `mask_complete_ok` is the
459    /// result of running `mask_complete` over `pending_write_blocks()` — irrelevant when `decide` already chose
460    /// not to attempt this component (`chosen` is then `false` regardless of this argument).
461    pub fn commit(&mut self, mask_complete_ok: bool) -> Option<CommitOut> {
462        let mut pending = self.pending.take()?;
463        let chosen = pending.choose && mask_complete_ok;
464        if chosen {
465            pending.record.chosen_frame = pending.frame;
466            pending.record.chosen_time = pending.time;
467            pending.record.complete = true;
468            pending.record.revisions += 1;
469        }
470        let emit_incomplete =
471            !pending.record.complete && (!pending.old_existed || pending.expanded);
472        let rect = pending.rect;
473        let record_id = pending.record.id.clone();
474        self.records.set(pending.record);
475        self.dirty.insert(&record_id);
476        self.deleted.remove(&record_id);
477        for id in &pending.olds_to_delete {
478            self.records.delete(id);
479            self.dirty.remove(id);
480            self.deleted.insert(id);
481        }
482        Some(CommitOut {
483            chosen,
484            emit_incomplete,
485            rect,
486        })
487    }
488
489    /// Sizes for the buffers `flush_take` will need: how many ids `deleted` (persisted rows to delete) and
490    /// `dirty` (persisted rows to (re)write) hold, and the total block count across every dirty record's
491    /// current key set (`persistedBlocks` output length, summed) — flush() writes exactly these rows, deletions
492    /// first, in this order, mirrors `Compositor.flush()`.
493    pub fn flush_sizes(&self) -> (usize, usize, usize) {
494        let dirty_block_total: usize = self
495            .dirty
496            .order
497            .iter()
498            .map(|id| self.records.get(id).map(|r| r.keys.len()).unwrap_or(0))
499            .sum();
500        (
501            self.deleted.order.len(),
502            self.dirty.order.len(),
503            dirty_block_total,
504        )
505    }
506
507    /// Drains `deleted` (in order) and `dirty` (in order, with each record's current content and
508    /// persisted-order block list), clearing both. The counts must match a `flush_sizes()` call made with no
509    /// `decide`/`commit`/`load` in between (the caller sizes its output buffers from that call).
510    pub fn flush_take(&mut self) -> (Vec<String>, Vec<DirtyRow>) {
511        let deleted = self.deleted.take();
512        let dirty_ids = self.dirty.take();
513        let dirty = dirty_ids
514            .into_iter()
515            .map(|id| {
516                let rec = self.records.get(&id).unwrap().clone();
517                let blocks = persisted_blocks(&rec.keys);
518                (rec, blocks)
519            })
520            .collect();
521        (deleted, dirty)
522    }
523}
524
525/// One dirty record ready to persist: its current content plus its block set in persisted (ascending (by, bx))
526/// order.
527pub type DirtyRow = (TemporalRecord, Vec<(i32, i32)>);
528
529/// Native-screen occluder membership (mirrors `occluded` in src/core/compositor.ts): occlusions are always
530/// full-width bands, so rect containment on (sx, sy) is exact.
531fn occluded(occlusions: &[Rect], sx: f64, sy: f64) -> bool {
532    occlusions
533        .iter()
534        .any(|o| sx >= o.x && sx < o.x + o.width && sy >= o.y && sy < o.y + o.height)
535}
536
537/// The pixel-level completeness walk from `resolveTemporal` (src/core/compositor.ts): every pixel of every
538/// `write_blocks` block must fall inside the region (`labels[y*atlas_width+x] == code`, treating out-of-bounds
539/// as not contained), be unoccluded, and — when a consistency mask is given — be flagged consistent. All-or-
540/// nothing: the first failing pixel stops the walk (mirrors the original's early-exit `maskComplete` flag; no
541/// side effects happen in the loop besides that flag, so early-exit and scan-to-completion agree).
542#[allow(clippy::too_many_arguments)]
543pub fn mask_complete(
544    write_blocks: &[(i32, i32)],
545    labels: &[u8],
546    atlas_width: i64,
547    atlas_height: i64,
548    code: u8,
549    occlusions: &[Rect],
550    consistent: Option<&[u8]>,
551    ox: i64,
552    oy: i64,
553) -> bool {
554    // `consistent` (when given) is always image-sized, and `image.width`/`image.height` are asserted equal to
555    // the atlas's own dimensions at every call site (`Compositor.add`, src/core/compositor.ts), so `atlas_width`
556    // is also `consistent`'s row stride — no separate `image_width` needed. `contains` below already proves
557    // `sx < atlas_width && sy < atlas_height` before any `consistent` index is taken.
558    let b = QUALITY_BLOCK as i64;
559    for &(bx, by) in write_blocks {
560        let (bx, by) = (bx as i64, by as i64);
561        for y in by * b..by * b + b {
562            let sy = y - oy;
563            for x in bx * b..bx * b + b {
564                let sx = x - ox;
565                let contains = sx >= 0
566                    && sy >= 0
567                    && sx < atlas_width
568                    && sy < atlas_height
569                    && labels[(sy * atlas_width + sx) as usize] == code;
570                if !contains || occluded(occlusions, sx as f64, sy as f64) {
571                    return false;
572                }
573                if let Some(c) = consistent {
574                    if c[(sy * atlas_width + sx) as usize] == 0 {
575                        return false;
576                    }
577                }
578            }
579        }
580    }
581    true
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    #[test]
589    fn single_block() {
590        let comps = components(&[(3, 4)], 16);
591        assert_eq!(comps.len(), 1);
592        assert_eq!(
593            (comps[0].x0, comps[0].y0, comps[0].width, comps[0].height),
594            (48, 64, 16, 16)
595        );
596        assert_eq!(comps[0].blocks, vec![(3, 4)]);
597    }
598
599    #[test]
600    fn l_shape_stays_concave() {
601        // An L: (0,0), (1,0), (0,1) — 8-connected, one component, but its bounding box (2x2) must not
602        // silently gain the empty (1,1) cell in the output block list.
603        let comps = components(&[(0, 0), (1, 0), (0, 1)], 16);
604        assert_eq!(comps.len(), 1);
605        assert_eq!(comps[0].blocks.len(), 3);
606        assert_eq!((comps[0].width, comps[0].height), (32, 32));
607    }
608
609    #[test]
610    fn diagonal_touch_connects() {
611        // 8-connected: a purely diagonal touch still joins one component.
612        let comps = components(&[(0, 0), (1, 1)], 16);
613        assert_eq!(comps.len(), 1);
614    }
615
616    #[test]
617    fn disjoint_cells_split_and_keep_seed_order() {
618        let comps = components(&[(0, 0), (10, 10), (0, 1)], 16);
619        assert_eq!(comps.len(), 2);
620        // Seed order: (0,0) is claimed first and absorbs (0,1); (10,10) starts the second component.
621        assert_eq!(comps[0].blocks.len(), 2);
622        assert_eq!(comps[1].blocks, vec![(10, 10)]);
623    }
624
625    #[test]
626    fn overwrite_writes_pixels_and_evidence() {
627        let size = 32usize;
628        let blocks = (size / QUALITY_BLOCK).pow(2);
629        let mut pixels = vec![0u8; size * size * 4];
630        let mut coverage = vec![0u8; size * size / 8];
631        let mut provisional = vec![0xffu8; size * size / 8]; // every pixel provisional beforehand
632        let (mut quality, mut conflicts, mut owner, mut frozen) = (
633            vec![0u8; blocks],
634            vec![0u8; blocks],
635            vec![0u32; blocks],
636            vec![0u8; blocks],
637        );
638        let mut tile = OverwriteTile {
639            size,
640            pixels: &mut pixels,
641            coverage: &mut coverage,
642            provisional: &mut provisional,
643            quality: &mut quality,
644            conflicts: &mut conflicts,
645            owner: &mut owner,
646            frozen: &mut frozen,
647        };
648        let img_width = 32i64;
649        let rgba: Vec<u8> = (0..img_width * img_width)
650            .flat_map(|i| [i as u8, 1, 2, 255])
651            .collect();
652        let stats = overwrite_tile(
653            &mut tile,
654            &rgba,
655            img_width,
656            &[(0, 0)],
657            0,
658            0,
659            0,
660            0,
661            3,
662            0.7,
663            true,
664        );
665        assert_eq!(stats.added, 256);
666        assert_eq!(stats.conflict_pixels, 256);
667        assert_eq!(stats.provisional_delta, -256);
668        assert!(stats.changed);
669        assert_eq!(tile.owner[0], 4);
670        assert_eq!(tile.quality[0], 179); // js_round(0.7 * 255 = 178.5) rounds half up
671        assert_eq!(tile.conflicts[0], 1);
672        assert_eq!(tile.frozen[0], 1);
673        assert_eq!(tile.pixels[0], 0);
674        assert_eq!(tile.pixels[4], 1);
675    }
676
677    #[test]
678    fn overwrite_respects_non_stable_policy() {
679        let size = 16usize;
680        let mut pixels = vec![0u8; size * size * 4];
681        let mut coverage = vec![0u8; size * size / 8];
682        let mut provisional = vec![0u8; size * size / 8];
683        let (mut quality, mut conflicts, mut owner, mut frozen) =
684            (vec![0u8; 1], vec![0u8; 1], vec![0u32; 1], vec![0u8; 1]);
685        let mut tile = OverwriteTile {
686            size,
687            pixels: &mut pixels,
688            coverage: &mut coverage,
689            provisional: &mut provisional,
690            quality: &mut quality,
691            conflicts: &mut conflicts,
692            owner: &mut owner,
693            frozen: &mut frozen,
694        };
695        let rgba = vec![9u8; 16 * 16 * 4];
696        overwrite_tile(&mut tile, &rgba, 16, &[(0, 0)], 0, 0, 0, 0, 0, 0.0, false);
697        assert_eq!(tile.frozen[0], 0);
698        assert_eq!(tile.quality[0], 0); // round(0.0 * 255)
699    }
700
701    // --- TemporalIndex: mirrors the row-order/id-collision tripwires in tests/unit/compositor.test.ts against
702    // the pure Rust index directly, without going through the FFI/Compositor layer. ---
703
704    fn block_rect(bx: i32, by: i32, w: i32, h: i32) -> Rect {
705        Rect {
706            x: (bx * 16) as f64,
707            y: (by * 16) as f64,
708            width: (w * 16) as f64,
709            height: (h * 16) as f64,
710        }
711    }
712    fn keys(blocks: &[(i32, i32)]) -> HashSet<(i32, i32)> {
713        blocks.iter().copied().collect()
714    }
715    /// Runs `decide` → (a fully permissive `mask_complete`, since these tests only exercise index bookkeeping)
716    /// → `commit`, exactly the two-call sequence `Compositor.resolveTemporal` performs per component. `seq`
717    /// mirrors `Compositor.temporalSequence` (src/core/compositor.ts): a counter threaded through every
718    /// `decide()` call, never owned by `TemporalIndex` itself (it is shared across every canvas's handle).
719    fn resolve(
720        index: &mut TemporalIndex,
721        comp_blocks: &[(i32, i32)],
722        frame: u32,
723        visible: Rect,
724        seq: &mut u64,
725    ) -> CommitOut {
726        let bounds = comp_blocks
727            .iter()
728            .fold(None::<Rect>, |acc, &(bx, by)| {
729                let r = block_rect(bx, by, 1, 1);
730                Some(acc.map_or(r, |a| rect_union(a, r)))
731            })
732            .unwrap();
733        let (_, next_seq) = index.decide(
734            bounds,
735            comp_blocks,
736            frame,
737            frame as f64,
738            visible,
739            false,
740            *seq,
741        );
742        *seq = next_seq;
743        index.commit(true).unwrap()
744    }
745
746    #[test]
747    fn flush_order_survives_a_delete_then_reinsert_shaped_sequence() {
748        // Mirrors "compositor: flush() persists temporal rows in exact index insertion/deletion order" in
749        // tests/unit/compositor.test.ts: records A,B,C,D created in order at blocks 0,4,8,12 (visible span 0..13).
750        let visible = block_rect(0, 0, 13, 1);
751        let mut index = TemporalIndex::new();
752        let mut seq = 0u64;
753        for &b in &[0, 4, 8, 12] {
754            resolve(&mut index, &[(b, 0)], b as u32, visible, &mut seq);
755        }
756        let ids = |n: u64| format!("{:010}", n);
757        assert_eq!(index.records.order, vec![ids(0), ids(1), ids(2), ids(3)]);
758        index.flush_take(); // matches the TS test's `await compositor.flush()` here, clearing dirty/deleted
759                            // Frame 5: a bridge (blocks 4..8) merges B and C (old = B, since B is iterated before C); a standalone
760                            // touch at block 12 (D) does not expand/re-choose it (already complete under 'stable') but still dirties it.
761        let out_bridge = resolve(
762            &mut index,
763            &[(4, 0), (5, 0), (6, 0), (7, 0), (8, 0)],
764            5,
765            visible,
766            &mut seq,
767        );
768        assert!(out_bridge.chosen);
769        index.decide(
770            block_rect(12, 0, 1, 1),
771            &[(12, 0)],
772            5,
773            5.0,
774            visible,
775            false,
776            seq,
777        );
778        assert_eq!(
779            index.pending_write_blocks().len(),
780            0,
781            "D neither expands nor re-chooses — decide() must not offer a mask to check"
782        );
783        let out_d = index.commit(false).unwrap();
784        assert!(!out_d.chosen);
785        assert_eq!(index.dirty.order, vec![ids(1), ids(3)]);
786        assert_eq!(index.deleted.order, vec![ids(2)]);
787        // Frame 6: a mega-conflict spanning blocks 0..12 absorbs A, merged-B, and D into one record kept at A's id.
788        let all_blocks: Vec<(i32, i32)> = (0..=12).map(|bx| (bx, 0)).collect();
789        let out_mega = resolve(&mut index, &all_blocks, 6, visible, &mut seq);
790        assert!(out_mega.chosen);
791        assert_eq!(index.dirty.order, vec![ids(0)], "B and D — both still dirty and never flushed — are deleted before ever reaching a flush");
792        assert_eq!(index.deleted.order, vec![ids(2), ids(1), ids(3)]);
793        let (deleted, dirty) = index.flush_take();
794        assert_eq!(deleted, vec![ids(2), ids(1), ids(3)]);
795        assert_eq!(dirty.len(), 1);
796        assert_eq!(dirty[0].0.id, ids(0));
797        assert_eq!(dirty[0].1.len(), 13);
798        assert_eq!(index.dirty.order.len(), 0);
799        assert_eq!(index.deleted.order.len(), 0);
800    }
801
802    #[test]
803    fn fresh_id_colliding_with_a_live_record_overwrites_it_in_place() {
804        let visible = block_rect(0, 0, 6, 1);
805        let mut index = TemporalIndex::new();
806        index.load(TemporalRecord {
807            id: "0000000000".into(),
808            rect: block_rect(5, 0, 1, 1),
809            keys: keys(&[(5, 0)]),
810            chosen_frame: 0,
811            chosen_time: 0.0,
812            complete: true,
813            revisions: 1,
814        });
815        let mut seq = 0u64;
816        let out = resolve(&mut index, &[(0, 0)], 1, visible, &mut seq);
817        assert!(out.chosen);
818        assert_eq!(
819            index.records.order,
820            vec!["0000000000".to_string()],
821            "no second row — the id collision clobbers the loaded one in place"
822        );
823        assert_eq!(
824            index.records.get("0000000000").unwrap().rect,
825            block_rect(0, 0, 1, 1)
826        );
827        assert_eq!(index.deleted.order.len(), 0);
828    }
829
830    #[test]
831    fn fresh_id_colliding_with_a_deleted_id_is_appended_and_undeletes_it() {
832        let visible = block_rect(0, 0, 12, 1);
833        let mut index = TemporalIndex::new();
834        index.load(TemporalRecord {
835            id: "0000000000".into(),
836            rect: block_rect(0, 0, 1, 1),
837            keys: keys(&[(0, 0)]),
838            chosen_frame: 0,
839            chosen_time: 0.0,
840            complete: true,
841            revisions: 1,
842        });
843        index.load(TemporalRecord {
844            id: "0000000001".into(),
845            rect: block_rect(1, 0, 1, 1),
846            keys: keys(&[(1, 0)]),
847            chosen_frame: 0,
848            chosen_time: 0.0,
849            complete: true,
850            revisions: 1,
851        });
852        let mut seq = 0u64;
853        // Merge blocks 0 and 1: old = "0000000000" (olds[0] is always the first-iterated overlap), "0000000001" deleted.
854        resolve(&mut index, &[(0, 0), (1, 0)], 1, visible, &mut seq);
855        assert_eq!(index.deleted.order, vec!["0000000001".to_string()]);
856        // Two disjoint, non-overlapping conflicts (blocks 5 and 9): fresh ids pad(0)="0000000000" (live — clobbered
857        // in place) then pad(1)="0000000001" (deleted — appended at the end and un-deleted).
858        resolve(&mut index, &[(5, 0)], 2, visible, &mut seq);
859        resolve(&mut index, &[(9, 0)], 2, visible, &mut seq);
860        assert_eq!(
861            index.deleted.order.len(),
862            0,
863            "no delete() should ever be emitted for \"0000000001\" now"
864        );
865        assert_eq!(
866            index.records.order,
867            vec!["0000000000".to_string(), "0000000001".to_string()]
868        );
869        assert_eq!(
870            index.records.get("0000000001").unwrap().rect,
871            block_rect(9, 0, 1, 1)
872        );
873        let (deleted, dirty) = index.flush_take();
874        assert_eq!(deleted.len(), 0);
875        assert_eq!(
876            dirty.iter().map(|(r, _)| r.id.clone()).collect::<Vec<_>>(),
877            vec!["0000000000".to_string(), "0000000001".to_string()]
878        );
879    }
880}