Skip to main content

taffy/compute/grid/types/
cell_occupancy.rs

1//! Contains CellOccupancyMatrix used to track occupied cells during grid placement
2use super::TrackCounts;
3use crate::compute::grid::OriginZeroLine;
4use crate::geometry::AbsoluteAxis;
5use crate::geometry::Line;
6use crate::util::sys::{new_vec_with_capacity, Vec};
7use core::cmp::{max, min};
8use core::fmt::Debug;
9use core::ops::Range;
10use smallvec::SmallVec;
11
12/// The occupancy state of a single grid cell
13#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
14pub(crate) enum CellOccupancyState {
15    #[default]
16    /// Indicates that a grid cell is unoccupied
17    Unoccupied,
18    /// Indicates that a grid cell is occupied by a definitely placed item
19    DefinitelyPlaced,
20    /// Indicates that a grid cell is occupied by an item that was placed by the auto placement algorithm
21    AutoPlaced,
22}
23
24/// A run of occupied cells within a single track. The range is in OriginZero line coordinates
25/// along the track (so for a row track, the range spans column lines and vice versa).
26#[derive(Debug, Clone, PartialEq, Eq)]
27struct OccupiedInterval {
28    /// The range of cells covered by this interval (start line..end line in OriginZero coordinates)
29    range: Range<i16>,
30    /// The occupancy state of every cell within this interval
31    state: CellOccupancyState,
32}
33
34impl OccupiedInterval {
35    /// Whether this interval overlaps the given range
36    fn overlaps(&self, range: &Range<i16>) -> bool {
37        self.range.start < range.end && self.range.end > range.start
38    }
39}
40
41/// The occupied cells of a single track, stored as a sorted list of disjoint intervals in
42/// OriginZero line coordinates. Gaps between intervals are unoccupied. Touching intervals
43/// with the same state are merged.
44#[derive(Debug, Clone, Default)]
45struct TrackIntervals {
46    /// The sorted, disjoint list of occupied intervals within the track
47    intervals: SmallVec<[OccupiedInterval; 2]>,
48}
49
50impl TrackIntervals {
51    /// Whether the track contains any occupied cells
52    fn is_empty(&self) -> bool {
53        self.intervals.is_empty()
54    }
55
56    /// The occupancy state of the cell whose start line is at `coordinate`
57    fn state_at(&self, coordinate: i16) -> CellOccupancyState {
58        self.intervals
59            .iter()
60            .find(|interval| interval.range.start <= coordinate && coordinate < interval.range.end)
61            .map(|interval| interval.state)
62            .unwrap_or(CellOccupancyState::Unoccupied)
63    }
64
65    /// Set the cells covered by `range` to `state`, overwriting the state of any already-occupied
66    /// cells within the range (matching the overwrite semantics of a dense matrix of cells)
67    fn paint(&mut self, range: Range<i16>, state: CellOccupancyState) {
68        if range.start >= range.end {
69            return;
70        }
71
72        // Fast path: the painted range lies entirely after all existing intervals
73        // (the common case, as placement queries and inserts mostly advance forwards)
74        match self.intervals.last_mut() {
75            None => {
76                self.intervals.push(OccupiedInterval { range, state });
77                return;
78            }
79            Some(last) if last.range.end <= range.start => {
80                if last.state == state && last.range.end == range.start {
81                    last.range.end = range.end;
82                } else {
83                    self.intervals.push(OccupiedInterval { range, state });
84                }
85                return;
86            }
87            _ => {}
88        }
89
90        let mut result: SmallVec<[OccupiedInterval; 2]> = SmallVec::new();
91
92        // Intervals (or partial intervals) entirely before the painted range
93        for interval in &self.intervals {
94            if interval.range.end <= range.start {
95                result.push(interval.clone());
96            } else if interval.range.start < range.start {
97                result.push(OccupiedInterval { range: interval.range.start..range.start, state: interval.state });
98            }
99        }
100
101        // The painted range, merged with the preceding interval if it touches and has the same state
102        match result.last_mut() {
103            Some(last) if last.state == state && last.range.end == range.start => last.range.end = range.end,
104            _ => result.push(OccupiedInterval { range: range.clone(), state }),
105        }
106
107        // Intervals (or partial intervals) entirely after the painted range
108        for interval in &self.intervals {
109            let trimmed = if interval.range.start >= range.end {
110                interval.clone()
111            } else if interval.range.end > range.end {
112                OccupiedInterval { range: range.end..interval.range.end, state: interval.state }
113            } else {
114                continue;
115            };
116            match result.last_mut() {
117                Some(last) if last.state == trimmed.state && last.range.end == trimmed.range.start => {
118                    last.range.end = trimmed.range.end
119                }
120                _ => result.push(trimmed),
121            }
122        }
123
124        self.intervals = result;
125    }
126
127    /// Find the extent of the occupied interval which an auto-placement search along the track
128    /// would collide with last: the end of the last overlapping interval when searching forwards,
129    /// or the start of the first overlapping interval when searching backwards (`reversed ==
130    /// true`). Returns the extremal occupied cell of that interval (which may lie outside
131    /// `range`: every search position before the returned extent also collides with the
132    /// interval), or `None` if the range is entirely unoccupied.
133    fn collision_extent(&self, range: &Range<i16>, reversed: bool) -> Option<i16> {
134        if reversed {
135            let interval = self.intervals.iter().find(|interval| interval.overlaps(range))?;
136            Some(interval.range.start)
137        } else {
138            let interval = self.intervals.iter().rev().find(|interval| interval.overlaps(range))?;
139            Some(interval.range.end - 1)
140        }
141    }
142
143    /// The start line of the first (lowest coordinate) cell with the specified state, if any
144    fn first_of_state(&self, state: CellOccupancyState) -> Option<i16> {
145        self.intervals.iter().find(|interval| interval.state == state).map(|interval| interval.range.start)
146    }
147
148    /// The start line of the last (highest coordinate) cell with the specified state, if any
149    fn last_of_state(&self, state: CellOccupancyState) -> Option<i16> {
150        self.intervals.iter().rev().find(|interval| interval.state == state).map(|interval| interval.range.end - 1)
151    }
152}
153
154/// A dynamically sized matrix (2d grid) which tracks the occupancy of each grid cell during auto-placement.
155/// It also keeps tabs on how many tracks there are and which tracks are implicit and which are explicit.
156///
157/// Occupancy is stored sparsely as per-track interval lists (in both orientations), so memory usage
158/// is proportional to the number of placed items rather than the total number of grid cells.
159pub(crate) struct CellOccupancyMatrix {
160    /// The counts of implicit and explicit columns
161    columns: TrackCounts,
162    /// The counts of implicit and explicit rows
163    rows: TrackCounts,
164    /// For each row track: the occupied intervals within that row (in column coordinates)
165    row_intervals: Vec<TrackIntervals>,
166    /// For each column track: the occupied intervals within that column (in row coordinates)
167    column_intervals: Vec<TrackIntervals>,
168}
169
170/// Debug impl that represents the matrix in a compact 2d text format
171impl Debug for CellOccupancyMatrix {
172    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
173        writeln!(
174            f,
175            "Rows: neg_implicit={} explicit={} pos_implicit={}",
176            self.rows.negative_implicit, self.rows.explicit, self.rows.positive_implicit
177        )?;
178        writeln!(
179            f,
180            "Cols: neg_implicit={} explicit={} pos_implicit={}",
181            self.columns.negative_implicit, self.columns.explicit, self.columns.positive_implicit
182        )?;
183        if self.rows.len() > 100 || self.columns.len() > 100 {
184            writeln!(f, "State: (not printed: more than 100 tracks)")?;
185            return Ok(());
186        }
187        writeln!(f, "State:")?;
188
189        for row in &self.row_intervals {
190            for column_index in 0..self.columns.len() {
191                let coordinate = self.columns.track_to_prev_oz_line(column_index as u16);
192                let letter = match row.state_at(coordinate.0) {
193                    CellOccupancyState::Unoccupied => '_',
194                    CellOccupancyState::DefinitelyPlaced => 'D',
195                    CellOccupancyState::AutoPlaced => 'A',
196                };
197                write!(f, "{letter}")?;
198            }
199            writeln!(f)?;
200        }
201
202        Ok(())
203    }
204}
205
206impl CellOccupancyMatrix {
207    /// Create a CellOccupancyMatrix given a set of provisional track counts. The grid can expand as needed to fit more tracks,
208    /// the provisional track counts represent a best effort attempt to avoid the extra allocations this requires.
209    pub fn with_track_counts(columns: TrackCounts, rows: TrackCounts) -> Self {
210        let mut row_intervals = new_vec_with_capacity(rows.len());
211        row_intervals.resize(rows.len(), TrackIntervals::default());
212        let mut column_intervals = new_vec_with_capacity(columns.len());
213        column_intervals.resize(columns.len(), TrackIntervals::default());
214        Self { rows, columns, row_intervals, column_intervals }
215    }
216
217    /// The per-track interval lists for tracks in the specified axis. Each row track's intervals
218    /// are in column coordinates and vice versa.
219    fn track_lists(&self, track_axis: AbsoluteAxis) -> &[TrackIntervals] {
220        match track_axis {
221            AbsoluteAxis::Horizontal => &self.column_intervals,
222            AbsoluteAxis::Vertical => &self.row_intervals,
223        }
224    }
225
226    /// Expands the grid (potentially in all 4 directions) in order to ensure that the specified
227    /// spans (in OriginZero coordinates) fit within the tracked tracks
228    fn expand_to_fit_range(&mut self, row_span: Line<OriginZeroLine>, col_span: Line<OriginZeroLine>) {
229        // Calculate number of rows and columns missing to accommodate ranges (if any)
230        let req_negative_rows = max(-(self.rows.negative_implicit as i16) - row_span.start.0, 0);
231        let req_positive_rows = max(row_span.end.0 - self.rows.implicit_end_line().0, 0);
232        let req_negative_cols = max(-(self.columns.negative_implicit as i16) - col_span.start.0, 0);
233        let req_positive_cols = max(col_span.end.0 - self.columns.implicit_end_line().0, 0);
234
235        // Add empty tracks to the front and/or back of the per-track interval lists.
236        // Interval contents are stored in OriginZero coordinates, so they do not need to shift.
237        if req_negative_rows > 0 {
238            self.row_intervals
239                .splice(0..0, core::iter::repeat_with(TrackIntervals::default).take(req_negative_rows as usize));
240        }
241        if req_positive_rows > 0 {
242            let new_len = self.row_intervals.len() + req_positive_rows as usize;
243            self.row_intervals.resize(new_len, TrackIntervals::default());
244        }
245        if req_negative_cols > 0 {
246            self.column_intervals
247                .splice(0..0, core::iter::repeat_with(TrackIntervals::default).take(req_negative_cols as usize));
248        }
249        if req_positive_cols > 0 {
250            let new_len = self.column_intervals.len() + req_positive_cols as usize;
251            self.column_intervals.resize(new_len, TrackIntervals::default());
252        }
253
254        self.rows.negative_implicit += req_negative_rows as u16;
255        self.rows.positive_implicit += req_positive_rows as u16;
256        self.columns.negative_implicit += req_negative_cols as u16;
257        self.columns.positive_implicit += req_positive_cols as u16;
258    }
259
260    /// Mark an area of the matrix as occupied, expanding the allocated space as necessary to accommodate the passed area.
261    pub fn mark_area_as(
262        &mut self,
263        primary_axis: AbsoluteAxis,
264        primary_span: Line<OriginZeroLine>,
265        secondary_span: Line<OriginZeroLine>,
266        value: CellOccupancyState,
267    ) {
268        let (row_span, column_span) = match primary_axis {
269            AbsoluteAxis::Horizontal => (secondary_span, primary_span),
270            AbsoluteAxis::Vertical => (primary_span, secondary_span),
271        };
272
273        self.expand_to_fit_range(row_span, column_span);
274
275        let row_range = self.rows.oz_line_range_to_track_range(row_span);
276        let col_range = self.columns.oz_line_range_to_track_range(column_span);
277        for row_index in row_range {
278            self.row_intervals[row_index as usize].paint(column_span.start.0..column_span.end.0, value);
279        }
280        for column_index in col_range {
281            self.column_intervals[column_index as usize].paint(row_span.start.0..row_span.end.0, value);
282        }
283    }
284
285    /// Determines whether a grid area specified by the bounding grid lines in OriginZero coordinates
286    /// is entirely unnocupied. Returns true if all grid cells within the grid area are unnocupied, else false.
287    #[cfg(test)]
288    pub fn line_area_is_unoccupied(
289        &self,
290        primary_axis: AbsoluteAxis,
291        primary_span: Line<OriginZeroLine>,
292        secondary_span: Line<OriginZeroLine>,
293    ) -> bool {
294        self.line_area_collision_jump(primary_axis, primary_span, secondary_span, false).is_none()
295    }
296
297    /// Checks the specified area for occupied cells (`primary_span` and `secondary_span` are
298    /// bounding grid lines in OriginZero coordinates). Returns `None` if the area is entirely
299    /// unoccupied. Otherwise returns the next search position (in OriginZero coordinates, along
300    /// `primary_axis`) that is not guaranteed to collide with the occupied cells found in the
301    /// area. This allows the auto-placement search cursor to jump past collisions rather than
302    /// advancing one track at a time.
303    pub fn line_area_collision_jump(
304        &self,
305        primary_axis: AbsoluteAxis,
306        primary_span: Line<OriginZeroLine>,
307        secondary_span: Line<OriginZeroLine>,
308        reversed: bool,
309    ) -> Option<OriginZeroLine> {
310        let track_lists = self.track_lists(primary_axis.other_axis());
311        let secondary_counts = self.track_counts(primary_axis.other_axis());
312        let secondary_range = secondary_counts.oz_line_range_to_track_range(secondary_span);
313
314        // Out of bounds cells are considered unoccupied, so clamp the secondary range to the
315        // tracks which actually exist
316        let secondary_start = max(secondary_range.start, 0);
317        let secondary_end = min(secondary_range.end, track_lists.len() as i16);
318
319        let primary_range = primary_span.start.0..primary_span.end.0;
320
321        let mut extent: Option<i16> = None;
322        for secondary_index in secondary_start..secondary_end {
323            let Some(cell) = track_lists[secondary_index as usize].collision_extent(&primary_range, reversed) else {
324                continue;
325            };
326            extent = Some(match extent {
327                None => cell,
328                Some(best) => {
329                    if reversed {
330                        min(best, cell)
331                    } else {
332                        max(best, cell)
333                    }
334                }
335            });
336        }
337
338        extent.map(|cell| if reversed { OriginZeroLine(cell - 1) } else { OriginZeroLine(cell + 1) })
339    }
340
341    /// Given a span of tracks in `axis` (in OriginZero coordinates), returns the next search
342    /// position past all non-empty tracks within the span, or `None` if all tracks within the
343    /// span are entirely unoccupied. Used to place items which span every track in the other
344    /// axis (such items can only fit in a stripe of entirely unoccupied tracks).
345    pub fn occupied_track_jump(
346        &self,
347        axis: AbsoluteAxis,
348        span: Line<OriginZeroLine>,
349        reversed: bool,
350    ) -> Option<OriginZeroLine> {
351        let counts = self.track_counts(axis);
352        let track_lists = self.track_lists(axis);
353        let range = counts.oz_line_range_to_track_range(span);
354        let start = max(range.start, 0);
355        let end = min(range.end, track_lists.len() as i16);
356        let found = if !reversed {
357            (start..end).rev().find(|&index| !track_lists[index as usize].is_empty())
358        } else {
359            (start..end).find(|&index| !track_lists[index as usize].is_empty())
360        };
361        found.map(|track_index| {
362            let line = counts.track_to_prev_oz_line(track_index as u16);
363            if reversed {
364                OriginZeroLine(line.0 - 1)
365            } else {
366                line + 1
367            }
368        })
369    }
370
371    /// Determines whether the specified row contains any items
372    pub fn row_is_occupied(&self, row_index: usize) -> bool {
373        self.track_lists(AbsoluteAxis::Vertical).get(row_index).is_some_and(|track| !track.is_empty())
374    }
375
376    /// Determines whether the specified column contains any items
377    pub fn column_is_occupied(&self, column_index: usize) -> bool {
378        self.track_lists(AbsoluteAxis::Horizontal).get(column_index).is_some_and(|track| !track.is_empty())
379    }
380
381    /// Returns the track counts of this CellOccunpancyMatrix in the relevant axis
382    pub fn track_counts(&self, track_type: AbsoluteAxis) -> &TrackCounts {
383        match track_type {
384            AbsoluteAxis::Horizontal => &self.columns,
385            AbsoluteAxis::Vertical => &self.rows,
386        }
387    }
388
389    /// Given an axis and a track index
390    /// Search backwards from the end of the track and find the last grid cell matching the specified state (if any)
391    /// Return the index of that cell or None.
392    pub fn last_of_type(
393        &self,
394        track_type: AbsoluteAxis,
395        start_at: OriginZeroLine,
396        kind: CellOccupancyState,
397    ) -> Option<OriginZeroLine> {
398        let track_counts = self.track_counts(track_type.other_axis());
399        let track_computed_index = track_counts.oz_line_to_next_track(start_at);
400        let track_lists = self.track_lists(track_type.other_axis());
401        if track_computed_index < 0 || track_computed_index >= track_lists.len() as i16 {
402            // Index out of bounds: no tracks to search
403            return None;
404        }
405        track_lists[track_computed_index as usize].last_of_state(kind).map(OriginZeroLine)
406    }
407
408    /// Given an axis and a track index
409    /// Search forwards from the start of the track and find the first grid cell matching the specified state (if any)
410    /// Return the index of that cell or None.
411    pub fn first_of_type(
412        &self,
413        track_type: AbsoluteAxis,
414        start_at: OriginZeroLine,
415        kind: CellOccupancyState,
416    ) -> Option<OriginZeroLine> {
417        let track_counts = self.track_counts(track_type.other_axis());
418        let track_computed_index = track_counts.oz_line_to_next_track(start_at);
419        let track_lists = self.track_lists(track_type.other_axis());
420        if track_computed_index < 0 || track_computed_index >= track_lists.len() as i16 {
421            // Index out of bounds: no tracks to search
422            return None;
423        }
424        track_lists[track_computed_index as usize].first_of_state(kind).map(OriginZeroLine)
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    fn interval(range: Range<i16>, state: CellOccupancyState) -> OccupiedInterval {
433        OccupiedInterval { range, state }
434    }
435
436    mod track_intervals {
437        use super::*;
438        use CellOccupancyState::{AutoPlaced, DefinitelyPlaced};
439
440        #[test]
441        fn paint_merges_touching_same_state_intervals() {
442            let mut track = TrackIntervals::default();
443            track.paint(0..2, AutoPlaced);
444            track.paint(4..6, AutoPlaced);
445            track.paint(2..4, AutoPlaced);
446            assert_eq!(track.intervals.as_slice(), &[interval(0..6, AutoPlaced)]);
447        }
448
449        #[test]
450        fn paint_does_not_merge_different_states() {
451            let mut track = TrackIntervals::default();
452            track.paint(0..2, AutoPlaced);
453            track.paint(2..4, DefinitelyPlaced);
454            assert_eq!(track.intervals.as_slice(), &[interval(0..2, AutoPlaced), interval(2..4, DefinitelyPlaced)]);
455        }
456
457        #[test]
458        fn paint_overwrites_overlapped_cells() {
459            let mut track = TrackIntervals::default();
460            track.paint(0..6, AutoPlaced);
461            track.paint(2..4, DefinitelyPlaced);
462            assert_eq!(
463                track.intervals.as_slice(),
464                &[interval(0..2, AutoPlaced), interval(2..4, DefinitelyPlaced), interval(4..6, AutoPlaced)]
465            );
466
467            // Painting over everything replaces all intervals
468            track.paint(-1..7, AutoPlaced);
469            assert_eq!(track.intervals.as_slice(), &[interval(-1..7, AutoPlaced)]);
470        }
471
472        #[test]
473        fn paint_overwrites_multiple_intervals() {
474            let mut track = TrackIntervals::default();
475            track.paint(0..2, AutoPlaced);
476            track.paint(3..5, DefinitelyPlaced);
477            track.paint(6..8, AutoPlaced);
478            track.paint(1..7, DefinitelyPlaced);
479            assert_eq!(
480                track.intervals.as_slice(),
481                &[interval(0..1, AutoPlaced), interval(1..7, DefinitelyPlaced), interval(7..8, AutoPlaced)]
482            );
483        }
484
485        #[test]
486        fn collision_extent_finds_extremal_occupied_cell() {
487            let mut track = TrackIntervals::default();
488            track.paint(2..4, AutoPlaced);
489            track.paint(6..8, DefinitelyPlaced);
490            // Forward search: the last cell of the last overlapping interval
491            assert_eq!(track.collision_extent(&(0..10), false), Some(7));
492            assert_eq!(track.collision_extent(&(0..7), false), Some(7));
493            assert_eq!(track.collision_extent(&(0..6), false), Some(3));
494            assert_eq!(track.collision_extent(&(4..6), false), None);
495            // Reverse search: the first cell of the first overlapping interval
496            assert_eq!(track.collision_extent(&(0..10), true), Some(2));
497            assert_eq!(track.collision_extent(&(3..10), true), Some(2));
498            assert_eq!(track.collision_extent(&(4..10), true), Some(6));
499        }
500
501        #[test]
502        fn first_and_last_of_state_ignore_other_states() {
503            let mut track = TrackIntervals::default();
504            track.paint(0..2, DefinitelyPlaced);
505            track.paint(2..4, AutoPlaced);
506            track.paint(6..8, AutoPlaced);
507            track.paint(8..9, DefinitelyPlaced);
508            assert_eq!(track.first_of_state(AutoPlaced), Some(2));
509            assert_eq!(track.last_of_state(AutoPlaced), Some(7));
510            assert_eq!(track.first_of_state(DefinitelyPlaced), Some(0));
511            assert_eq!(track.last_of_state(DefinitelyPlaced), Some(8));
512        }
513
514        #[test]
515        fn definitely_placed_overwrite_hides_auto_placed_cells() {
516            let mut track = TrackIntervals::default();
517            track.paint(0..4, CellOccupancyState::AutoPlaced);
518            track.paint(2..4, CellOccupancyState::DefinitelyPlaced);
519            assert_eq!(track.last_of_state(CellOccupancyState::AutoPlaced), Some(1));
520        }
521    }
522
523    mod cell_occupancy_matrix {
524        use super::*;
525        use crate::geometry::AbsoluteAxis::{Horizontal, Vertical};
526        use CellOccupancyState::AutoPlaced;
527
528        fn line(start: i16, end: i16) -> Line<OriginZeroLine> {
529            Line { start: OriginZeroLine(start), end: OriginZeroLine(end) }
530        }
531
532        #[test]
533        fn negative_expansion_preserves_occupancy() {
534            let mut matrix =
535                CellOccupancyMatrix::with_track_counts(TrackCounts::from_raw(0, 2, 0), TrackCounts::from_raw(0, 2, 0));
536            matrix.mark_area_as(Horizontal, line(0, 1), line(0, 1), AutoPlaced);
537            // Expand by marking an area in negative tracks
538            matrix.mark_area_as(Horizontal, line(-2, -1), line(-1, 0), AutoPlaced);
539
540            assert_eq!(*matrix.track_counts(Horizontal), TrackCounts::from_raw(2, 2, 0));
541            assert_eq!(*matrix.track_counts(Vertical), TrackCounts::from_raw(1, 2, 0));
542
543            // Original cell still occupied at the same OriginZero coordinates
544            assert!(!matrix.line_area_is_unoccupied(Horizontal, line(0, 1), line(0, 1)));
545            assert!(!matrix.line_area_is_unoccupied(Horizontal, line(-2, -1), line(-1, 0)));
546            assert!(matrix.line_area_is_unoccupied(Horizontal, line(-1, 0), line(0, 1)));
547
548            // Matrix-index based queries account for the shifted origin
549            assert!(matrix.column_is_occupied(0)); // OriginZero column -2
550            assert!(!matrix.column_is_occupied(1)); // OriginZero column -1
551            assert!(matrix.column_is_occupied(2)); // OriginZero column 0
552            assert!(matrix.row_is_occupied(0)); // OriginZero row -1
553            assert!(matrix.row_is_occupied(1)); // OriginZero row 0
554            assert!(!matrix.row_is_occupied(2)); // OriginZero row 1
555        }
556
557        #[test]
558        fn collision_jump_returns_next_search_position() {
559            let mut matrix =
560                CellOccupancyMatrix::with_track_counts(TrackCounts::from_raw(0, 4, 0), TrackCounts::from_raw(0, 4, 0));
561            matrix.mark_area_as(Horizontal, line(1, 3), line(0, 1), AutoPlaced);
562
563            // Forwards: jump past the end of the last colliding interval
564            assert_eq!(
565                matrix.line_area_collision_jump(Horizontal, line(0, 2), line(0, 1), false),
566                Some(OriginZeroLine(3))
567            );
568            assert_eq!(
569                matrix.line_area_collision_jump(Horizontal, line(0, 4), line(0, 1), false),
570                Some(OriginZeroLine(3))
571            );
572            // Backwards: jump past the start of the first colliding interval
573            assert_eq!(
574                matrix.line_area_collision_jump(Horizontal, line(2, 4), line(0, 1), true),
575                Some(OriginZeroLine(0))
576            );
577            assert_eq!(
578                matrix.line_area_collision_jump(Horizontal, line(0, 4), line(0, 1), true),
579                Some(OriginZeroLine(0))
580            );
581            // No collision in a different row
582            assert_eq!(matrix.line_area_collision_jump(Horizontal, line(0, 4), line(1, 2), false), None);
583        }
584
585        #[test]
586        fn occupied_track_jump_skips_non_empty_tracks() {
587            let mut matrix =
588                CellOccupancyMatrix::with_track_counts(TrackCounts::from_raw(0, 4, 0), TrackCounts::from_raw(0, 4, 0));
589            matrix.mark_area_as(Horizontal, line(0, 1), line(1, 2), AutoPlaced);
590
591            // Vertical (row) tracks: row 1 is occupied
592            assert_eq!(matrix.occupied_track_jump(Vertical, line(0, 4), false), Some(OriginZeroLine(2)));
593            assert_eq!(matrix.occupied_track_jump(Vertical, line(0, 4), true), Some(OriginZeroLine(0)));
594            assert_eq!(matrix.occupied_track_jump(Vertical, line(2, 4), false), None);
595        }
596    }
597}