Skip to main content

sl_map_apis/
coverage.rs

1//! Pre-render occupancy analysis: which of nine fixed anchor positions on a map
2//! are free of overlay content (routes, GLW shapes/labels) so additional,
3//! position-flexible elements (a legend, logo or label) can be placed there.
4//!
5//! The occupancy is computed by drawing the overlays onto a transparent
6//! [`crate::map_tiles::Map::blank`] map and treating any pixel with a non-zero
7//! alpha as occupied (see [`OccupancyGrid::from_map`]); no base map tiles are
8//! fetched, so this can run before the final render and be offered to a user
9//! choosing where to place those extra elements.
10//!
11//! The image is reduced to a coarse boolean [`OccupancyGrid`] and each of the
12//! nine [`PlacementSlot`]s is evaluated for the largest empty rectangle that can
13//! be anchored within its own third of the map
14//! ([`OccupancyGrid::evaluate_slots`]). The nine thirds tile the image exactly
15//! (the centre third on each axis absorbs the division remainder so the two edge
16//! thirds stay equal), so the per-slot rectangles never overlap and can be
17//! assigned to independent elements. Adjacent anchors that share one contiguous
18//! free area are reported in [`PlacementSlotInfo::connected_neighbours`], and a
19//! `span_fill` element may grow across them ([`OccupancyGrid::spanned_region`])
20//! into one larger rectangle that takes the minimum extent of the slots it
21//! crosses on the perpendicular axis.
22
23use crate::map_tiles::MapLike;
24
25/// default number of grid cells along the longer image dimension used by
26/// [`OccupancyGrid::from_map`]
27pub const DEFAULT_COVERAGE_GRID: u32 = 64;
28
29/// how a slot is anchored along one axis of the image
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum AxisMode {
32    /// against the low edge (left or top): grows toward the high edge
33    Start,
34    /// centred on the axis: grows symmetrically toward both edges
35    Center,
36    /// against the high edge (right or bottom): grows toward the low edge
37    End,
38}
39
40/// horizontal alignment of content within an available span
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum HAlign {
43    /// against the left edge of the span
44    Left,
45    /// centred within the span
46    Center,
47    /// against the right edge of the span
48    Right,
49}
50
51/// vertical alignment of content within an available span
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum VAlign {
54    /// against the top edge of the span
55    Top,
56    /// centred within the span
57    Center,
58    /// against the bottom edge of the span
59    Bottom,
60}
61
62impl HAlign {
63    /// pixel offset of `content`-wide content within an `available`-wide span for
64    /// this alignment, clamped so the content never starts past the span end
65    #[must_use]
66    pub const fn offset(self, content: u32, available: u32) -> u32 {
67        let slack = available.saturating_sub(content);
68        match self {
69            Self::Left => 0,
70            Self::Center => slack / 2,
71            Self::Right => slack,
72        }
73    }
74}
75
76impl VAlign {
77    /// pixel offset of `content`-tall content within an `available`-tall span for
78    /// this alignment, clamped so the content never starts past the span end
79    #[must_use]
80    pub const fn offset(self, content: u32, available: u32) -> u32 {
81        let slack = available.saturating_sub(content);
82        match self {
83            Self::Top => 0,
84            Self::Center => slack / 2,
85            Self::Bottom => slack,
86        }
87    }
88}
89
90/// pixel origin of `content`-sized content within a `total`-sized image axis for
91/// the given anchoring mode: `Start` hugs the low edge by `margin`, `End` hugs
92/// the high edge by `margin`, `Center` centres; all clamped to stay inside
93const fn axis_origin(mode: AxisMode, content: u32, total: u32, margin: u32) -> u32 {
94    let slack = total.saturating_sub(content);
95    match mode {
96        // hug the low edge, but never start so far in that the content overflows
97        AxisMode::Start => {
98            if margin < slack {
99                margin
100            } else {
101                slack
102            }
103        }
104        AxisMode::Center => slack / 2,
105        // hug the high edge, leaving `margin` if there is room
106        AxisMode::End => slack.saturating_sub(margin),
107    }
108}
109
110/// the widest contiguous run of `true` (free) entries in `col_free`, positioned
111/// per the anchoring mode: `Start` hugs index `0`, `End` hugs the last index,
112/// `Center` centres the run within the slice. Returned as `(offset, width)` into
113/// the slice (`width == 0` when nothing is free in the required position).
114fn run_for_mode(col_free: &[bool], mode: AxisMode) -> (u32, u32) {
115    let n = u32::try_from(col_free.len()).unwrap_or(u32::MAX);
116    match mode {
117        AxisMode::Start => {
118            let mut w = 0;
119            while w < n && col_free.get(w as usize).copied().unwrap_or(false) {
120                w += 1;
121            }
122            (0, w)
123        }
124        AxisMode::End => {
125            let mut w = 0;
126            while w < n && col_free.get((n - 1 - w) as usize).copied().unwrap_or(false) {
127                w += 1;
128            }
129            (n - w, w)
130        }
131        AxisMode::Center => {
132            for w in (1..=n).rev() {
133                let c0 = (n - w) / 2;
134                if (c0..c0 + w).all(|c| col_free.get(c as usize).copied().unwrap_or(false)) {
135                    return (c0, w);
136                }
137            }
138            (n / 2, 0)
139        }
140    }
141}
142
143/// one of the nine fixed candidate anchor positions on a map, laid out as a
144/// conceptual 3x3 grid (the four corners, the four side midpoints and the
145/// centre)
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
147pub enum PlacementSlot {
148    /// top-left corner
149    TopLeft,
150    /// middle of the top edge
151    TopCenter,
152    /// top-right corner
153    TopRight,
154    /// middle of the left edge
155    MiddleLeft,
156    /// centre of the map
157    Center,
158    /// middle of the right edge
159    MiddleRight,
160    /// bottom-left corner
161    BottomLeft,
162    /// middle of the bottom edge
163    BottomCenter,
164    /// bottom-right corner
165    BottomRight,
166}
167
168impl PlacementSlot {
169    /// all nine anchors, in reading order (top row left-to-right, then middle,
170    /// then bottom)
171    pub const ALL: [Self; 9] = [
172        Self::TopLeft,
173        Self::TopCenter,
174        Self::TopRight,
175        Self::MiddleLeft,
176        Self::Center,
177        Self::MiddleRight,
178        Self::BottomLeft,
179        Self::BottomCenter,
180        Self::BottomRight,
181    ];
182
183    /// the stable snake_case name for this slot (`top_left` … `bottom_right`).
184    /// This is the inverse of the [`FromStr`](std::str::FromStr) impl and the
185    /// value used in JSON; [`Display`](std::fmt::Display) yields the same text.
186    #[must_use]
187    pub const fn as_str(self) -> &'static str {
188        match self {
189            Self::TopLeft => "top_left",
190            Self::TopCenter => "top_center",
191            Self::TopRight => "top_right",
192            Self::MiddleLeft => "middle_left",
193            Self::Center => "center",
194            Self::MiddleRight => "middle_right",
195            Self::BottomLeft => "bottom_left",
196            Self::BottomCenter => "bottom_center",
197            Self::BottomRight => "bottom_right",
198        }
199    }
200
201    /// the position of this anchor in the conceptual 3x3 layout as
202    /// `(horizontal_index, vertical_index)`, each in `0..3` with `0` against the
203    /// low edge
204    #[must_use]
205    pub const fn cell(self) -> (u8, u8) {
206        match self {
207            Self::TopLeft => (0, 0),
208            Self::TopCenter => (1, 0),
209            Self::TopRight => (2, 0),
210            Self::MiddleLeft => (0, 1),
211            Self::Center => (1, 1),
212            Self::MiddleRight => (2, 1),
213            Self::BottomLeft => (0, 2),
214            Self::BottomCenter => (1, 2),
215            Self::BottomRight => (2, 2),
216        }
217    }
218
219    /// how this anchor is positioned along the horizontal axis
220    #[must_use]
221    const fn horizontal(self) -> AxisMode {
222        match self.cell().0 {
223            0 => AxisMode::Start,
224            1 => AxisMode::Center,
225            _ => AxisMode::End,
226        }
227    }
228
229    /// how this anchor is positioned along the vertical axis
230    #[must_use]
231    const fn vertical(self) -> AxisMode {
232        match self.cell().1 {
233            0 => AxisMode::Start,
234            1 => AxisMode::Center,
235            _ => AxisMode::End,
236        }
237    }
238
239    /// the natural alignment for content placed at this anchor: the slot's own
240    /// 3x3 position, biased to the outside of the image (e.g. `TopLeft` →
241    /// left/top, `Center` → center/center, `BottomRight` → right/bottom). Used
242    /// as the default alignment for text labels, overridable per axis.
243    #[must_use]
244    pub const fn default_alignment(self) -> (HAlign, VAlign) {
245        let (h, v) = self.cell();
246        let ha = match h {
247            0 => HAlign::Left,
248            1 => HAlign::Center,
249            _ => HAlign::Right,
250        };
251        let va = match v {
252            0 => VAlign::Top,
253            1 => VAlign::Center,
254            _ => VAlign::Bottom,
255        };
256        (ha, va)
257    }
258
259    /// pixel origin (top-left) at which to place a `content_w` × `content_h` box
260    /// anchored at this slot within a `img_w` × `img_h` image: corners hug their
261    /// edges leaving `margin` px, edge-midpoints and the centre centre the box on
262    /// the relevant axis. All clamped to keep the box inside the image. This is
263    /// the single source of truth for positioning a fixed-size element (e.g. the
264    /// legend) at a slot.
265    #[must_use]
266    pub const fn anchored_origin(
267        self,
268        content_w: u32,
269        content_h: u32,
270        img_w: u32,
271        img_h: u32,
272        margin: u32,
273    ) -> (u32, u32) {
274        let x = axis_origin(self.horizontal(), content_w, img_w, margin);
275        let y = axis_origin(self.vertical(), content_h, img_h, margin);
276        (x, y)
277    }
278
279    /// whether a set of slots forms a solid, axis-aligned rectangle in the 3x3
280    /// layout — i.e. their cells exactly fill a contiguous block of columns by
281    /// rows with no gaps. An L-shape or a diagonal pair is not a rectangle; an
282    /// empty or single-slot group trivially is. This is the rule that combined
283    /// (multi-slot) placements must satisfy, enforced by both the CLI and the
284    /// web server.
285    #[must_use]
286    pub fn slots_form_rectangle(slots: &[Self]) -> bool {
287        let mut cells: Vec<(u8, u8)> = slots.iter().map(|s| s.cell()).collect();
288        cells.sort_unstable();
289        cells.dedup();
290        if cells.len() <= 1 {
291            return true;
292        }
293        let c_min = cells.iter().map(|&(c, _)| c).min().unwrap_or(0);
294        let c_max = cells.iter().map(|&(c, _)| c).max().unwrap_or(0);
295        let r_min = cells.iter().map(|&(_, r)| r).min().unwrap_or(0);
296        let r_max = cells.iter().map(|&(_, r)| r).max().unwrap_or(0);
297        let width = u32::from(c_max - c_min) + 1;
298        let height = u32::from(r_max - r_min) + 1;
299        // distinct cells exactly filling the bounding box (count == area) is both
300        // necessary and sufficient for a gap-free, contiguous rectangle
301        u32::try_from(cells.len()).unwrap_or(u32::MAX) == width * height
302    }
303
304    /// the anchors orthogonally adjacent to this one in the 3x3 layout (the
305    /// up/down/left/right neighbours, never the diagonals)
306    #[must_use]
307    pub fn neighbours(self) -> Vec<Self> {
308        let (h, v) = self.cell();
309        Self::ALL
310            .into_iter()
311            .filter(|other| {
312                let (oh, ov) = other.cell();
313                let dh = (i16::from(h) - i16::from(oh)).abs();
314                let dv = (i16::from(v) - i16::from(ov)).abs();
315                dh + dv == 1
316            })
317            .collect()
318    }
319}
320
321impl std::fmt::Display for PlacementSlot {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        f.write_str(self.as_str())
324    }
325}
326
327/// Error returned by [`PlacementSlot`]'s [`FromStr`](std::str::FromStr) impl
328/// when the string is not one of the nine snake_case slot names.
329#[derive(Debug, Clone, thiserror::Error)]
330#[error("`{0}` is not a valid placement slot name")]
331pub struct ParsePlacementSlotError(String);
332
333impl std::str::FromStr for PlacementSlot {
334    type Err = ParsePlacementSlotError;
335
336    fn from_str(s: &str) -> Result<Self, Self::Err> {
337        Ok(match s {
338            "top_left" => Self::TopLeft,
339            "top_center" => Self::TopCenter,
340            "top_right" => Self::TopRight,
341            "middle_left" => Self::MiddleLeft,
342            "center" => Self::Center,
343            "middle_right" => Self::MiddleRight,
344            "bottom_left" => Self::BottomLeft,
345            "bottom_center" => Self::BottomCenter,
346            "bottom_right" => Self::BottomRight,
347            other => return Err(ParsePlacementSlotError(other.to_owned())),
348        })
349    }
350}
351
352/// an axis-aligned rectangle in image pixel coordinates (origin top-left)
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub struct PixelRect {
355    /// x coordinate of the left edge in pixels
356    pub x: u32,
357    /// y coordinate of the top edge in pixels
358    pub y: u32,
359    /// width in pixels
360    pub width: u32,
361    /// height in pixels
362    pub height: u32,
363}
364
365/// the result of evaluating one [`PlacementSlot`] against an [`OccupancyGrid`]
366#[derive(Debug, Clone, PartialEq)]
367pub struct PlacementSlotInfo {
368    /// which slot this describes
369    pub slot: PlacementSlot,
370    /// whether there is any free space at this anchor at all (i.e. the anchor
371    /// itself is not covered by overlay content)
372    pub available: bool,
373    /// the largest empty rectangle that can be placed anchored at this slot,
374    /// confined to the slot's own third of the map so the nine slot rectangles
375    /// never overlap, in pixel coordinates, or `None` if the slot's third has no
376    /// free space at the anchor
377    pub free_rect: Option<PixelRect>,
378    /// convenience `(width, height)` of [`Self::free_rect`] in pixels, `(0, 0)`
379    /// when there is no free rectangle
380    pub free_size: (u32, u32),
381    /// fraction (`0.0..=1.0`) of the local third-of-the-map region around this
382    /// anchor that is covered by overlay content, as a density hint
383    pub occupied_fraction: f32,
384    /// the orthogonally adjacent anchors that share one contiguous free area
385    /// with this anchor, so they could be combined for a larger element
386    pub connected_neighbours: Vec<PlacementSlot>,
387}
388
389/// a coarse boolean occupancy grid downsampled from an overlay coverage mask
390///
391/// Cells are stored row-major with row `0` at the top of the image (pixel
392/// `y == 0`). A cell is occupied if *any* pixel inside it is covered, so the
393/// grid never reports covered space as free.
394#[derive(Debug, Clone)]
395pub struct OccupancyGrid {
396    /// number of cell columns
397    cols: u32,
398    /// number of cell rows
399    rows: u32,
400    /// side length of a (square) cell in pixels
401    cell_size: u32,
402    /// image width in pixels
403    img_width: u32,
404    /// image height in pixels
405    img_height: u32,
406    /// row-major occupancy, length `cols * rows`
407    occupied: Vec<bool>,
408}
409
410impl OccupancyGrid {
411    /// derive the cell grid dimensions for an image of the given size, aiming
412    /// for roughly `grid_resolution` cells along the longer dimension with
413    /// square cells
414    fn grid_params(img_width: u32, img_height: u32, grid_resolution: u32) -> (u32, u32, u32) {
415        if img_width == 0 || img_height == 0 {
416            return (0, 0, 1);
417        }
418        let resolution = grid_resolution.max(1);
419        let longer = img_width.max(img_height);
420        let cell_size = longer.div_ceil(resolution).max(1);
421        let cols = img_width.div_ceil(cell_size).max(1);
422        let rows = img_height.div_ceil(cell_size).max(1);
423        (cols, rows, cell_size)
424    }
425
426    /// build an occupancy grid from a map by treating any pixel with a non-zero
427    /// alpha channel as covered
428    ///
429    /// Intended to be used with a [`crate::map_tiles::Map::blank`] map onto
430    /// which the overlay content (route, GLW shapes and labels) has been drawn,
431    /// so that exactly the overlay pixels are occupied and the (absent) base map
432    /// is not.
433    #[must_use]
434    pub fn from_map<M: MapLike + ?Sized>(map: &M, grid_resolution: u32) -> Self {
435        let rgba = map.image().to_rgba8();
436        let (img_width, img_height) = rgba.dimensions();
437        let (cols, rows, cell_size) = Self::grid_params(img_width, img_height, grid_resolution);
438        let mut occupied = vec![false; (cols * rows) as usize];
439        if cols > 0 && rows > 0 {
440            for (x, y, pixel) in rgba.enumerate_pixels() {
441                if pixel[3] != 0 {
442                    let col = (x / cell_size).min(cols - 1);
443                    let row = (y / cell_size).min(rows - 1);
444                    if let Some(cell) = occupied.get_mut((row * cols + col) as usize) {
445                        *cell = true;
446                    }
447                }
448            }
449        }
450        Self {
451            cols,
452            rows,
453            cell_size,
454            img_width,
455            img_height,
456            occupied,
457        }
458    }
459
460    /// whether the cell at `(row, col)` is free (out-of-bounds cells count as
461    /// occupied)
462    fn is_free(&self, row: u32, col: u32) -> bool {
463        !self
464            .occupied
465            .get((row * self.cols + col) as usize)
466            .copied()
467            .unwrap_or(true)
468    }
469
470    /// convert an exclusive cell rectangle `[r0, r1) x [c0, c1)` to a pixel
471    /// rectangle, clamped to the image bounds
472    fn cell_rect_to_pixels(&self, r0: u32, r1: u32, c0: u32, c1: u32) -> PixelRect {
473        let x = c0 * self.cell_size;
474        let y = r0 * self.cell_size;
475        let width = (c1 * self.cell_size).min(self.img_width).saturating_sub(x);
476        let height = (r1 * self.cell_size).min(self.img_height).saturating_sub(y);
477        PixelRect {
478            x,
479            y,
480            width,
481            height,
482        }
483    }
484
485    /// the half-open range `[lo, hi)` of grid columns covered by the third-of-
486    /// the-grid at horizontal index `h3` (`0..3`). The two edge thirds each get
487    /// `cols / 3` columns and the centre third absorbs the division remainder,
488    /// so the left and right thirds are always equal in size. The three ranges
489    /// tile `0..cols` exactly, sharing boundaries with no gap or overlap.
490    const fn col_band(&self, h3: u32) -> (u32, u32) {
491        let edge = self.cols / 3;
492        match h3 {
493            0 => (0, edge),
494            1 => (edge, self.cols - edge),
495            _ => (self.cols - edge, self.cols),
496        }
497    }
498
499    /// the half-open range `[lo, hi)` of grid rows covered by the third-of-the-
500    /// grid at vertical index `v3` (`0..3`); the centre third absorbs the
501    /// remainder so the top and bottom thirds stay equal. See [`Self::col_band`].
502    const fn row_band(&self, v3: u32) -> (u32, u32) {
503        let edge = self.rows / 3;
504        match v3 {
505            0 => (0, edge),
506            1 => (edge, self.rows - edge),
507            _ => (self.rows - edge, self.rows),
508        }
509    }
510
511    /// the largest-area axis-anchored rectangle of cells satisfying `free`,
512    /// confined to the column range `[c_lo, c_hi)` and row range `[r_lo, r_hi)`,
513    /// returned as an exclusive cell rectangle `(r0, r1, c0, c1)`, or `None` when
514    /// the band is empty or holds no qualifying cell. This is the shared
515    /// height-scan packing loop behind both [`Self::largest_free_rect_in`] (which
516    /// counts genuinely-free cells) and [`Self::subset_rect`] (which additionally
517    /// restricts to an allowed mask); they differ only in the `free` predicate
518    /// and what they map the result onto.
519    fn best_anchored_rect(
520        c_lo: u32,
521        c_hi: u32,
522        r_lo: u32,
523        r_hi: u32,
524        hmode: AxisMode,
525        vmode: AxisMode,
526        free: impl Fn(u32, u32) -> bool,
527    ) -> Option<(u32, u32, u32, u32)> {
528        if c_lo >= c_hi || r_lo >= r_hi {
529            return None;
530        }
531        let band_rows = r_hi - r_lo;
532        let mut best: Option<(u32, u32, u32, u32, u32)> = None;
533        for h in 1..=band_rows {
534            let (r0, r1) = match vmode {
535                AxisMode::Start => (r_lo, r_lo + h),
536                AxisMode::End => (r_hi - h, r_hi),
537                AxisMode::Center => {
538                    let r0 = r_lo + (band_rows - h) / 2;
539                    (r0, r0 + h)
540                }
541            };
542            let col_free: Vec<bool> = (c_lo..c_hi).map(|c| (r0..r1).all(|r| free(r, c))).collect();
543            let (off, w) = run_for_mode(&col_free, hmode);
544            if w == 0 {
545                continue;
546            }
547            let area = w * h;
548            if best.is_none_or(|b| area > b.0) {
549                best = Some((area, r0, r1, c_lo + off, c_lo + off + w));
550            }
551        }
552        best.map(|(_, r0, r1, c0, c1)| (r0, r1, c0, c1))
553    }
554
555    /// the largest all-free cell rectangle anchored per the given axis modes,
556    /// confined to the column range `[c_lo, c_hi)` and row range `[r_lo, r_hi)`,
557    /// returned as an exclusive cell rectangle `(r0, r1, c0, c1)`. Confining the
558    /// search to a slot's own third (see [`Self::col_band`] / [`Self::row_band`])
559    /// keeps the nine per-slot rectangles from overlapping one another.
560    fn largest_free_rect_in(
561        &self,
562        c_lo: u32,
563        c_hi: u32,
564        r_lo: u32,
565        r_hi: u32,
566        hmode: AxisMode,
567        vmode: AxisMode,
568    ) -> Option<(u32, u32, u32, u32)> {
569        Self::best_anchored_rect(c_lo, c_hi, r_lo, r_hi, hmode, vmode, |r, c| {
570            self.is_free(r, c)
571        })
572    }
573
574    /// the largest all-free cell rectangle anchored per the given axis modes,
575    /// searched over the whole grid (the basis for a `span_fill` placement, which
576    /// may grow across slot boundaries), returned as an exclusive cell rectangle
577    /// `(r0, r1, c0, c1)`. Because it is a single contiguous rectangle it spans
578    /// neighbouring thirds only where their free space touches and is limited to
579    /// the minimum extent of those thirds on the perpendicular axis.
580    fn largest_free_rect(&self, hmode: AxisMode, vmode: AxisMode) -> Option<(u32, u32, u32, u32)> {
581        self.largest_free_rect_in(0, self.cols, 0, self.rows, hmode, vmode)
582    }
583
584    /// the cell `(row, col)` that the given anchor sits in
585    fn anchor_cell(&self, anchor: PlacementSlot) -> (u32, u32) {
586        let col = match anchor.horizontal() {
587            AxisMode::Start => 0,
588            AxisMode::Center => self.img_width / 2 / self.cell_size,
589            AxisMode::End => self.img_width.saturating_sub(1) / self.cell_size,
590        }
591        .min(self.cols.saturating_sub(1));
592        let row = match anchor.vertical() {
593            AxisMode::Start => 0,
594            AxisMode::Center => self.img_height / 2 / self.cell_size,
595            AxisMode::End => self.img_height.saturating_sub(1) / self.cell_size,
596        }
597        .min(self.rows.saturating_sub(1));
598        (row, col)
599    }
600
601    /// the fraction of the local third-of-the-map block around the anchor that
602    /// is occupied
603    fn local_occupied_fraction(&self, anchor: PlacementSlot) -> f32 {
604        if self.cols == 0 || self.rows == 0 {
605            return 0f32;
606        }
607        let (hi, vi) = anchor.cell();
608        let (c0, c1) = self.col_band(u32::from(hi));
609        let (r0, r1) = self.row_band(u32::from(vi));
610        let mut total = 0u32;
611        let mut occ = 0u32;
612        for r in r0..r1 {
613            for c in c0..c1 {
614                total += 1;
615                if !self.is_free(r, c) {
616                    occ += 1;
617                }
618            }
619        }
620        if total == 0 {
621            0f32
622        } else {
623            f32::from(u16::try_from(occ).unwrap_or(u16::MAX))
624                / f32::from(u16::try_from(total).unwrap_or(u16::MAX))
625        }
626    }
627
628    /// label every free cell with a connected-component id (4-connectivity),
629    /// occupied cells get `-1`
630    fn free_components(&self) -> Vec<i32> {
631        let mut comp = vec![-1i32; (self.cols * self.rows) as usize];
632        let mut next = 0i32;
633        for start_row in 0..self.rows {
634            for start_col in 0..self.cols {
635                if !self.is_free(start_row, start_col) {
636                    continue;
637                }
638                let start_idx = (start_row * self.cols + start_col) as usize;
639                if comp.get(start_idx).copied().unwrap_or(0) != -1 {
640                    continue;
641                }
642                if let Some(cell) = comp.get_mut(start_idx) {
643                    *cell = next;
644                }
645                let mut stack = vec![(start_row, start_col)];
646                while let Some((r, c)) = stack.pop() {
647                    let mut neighbours: Vec<(u32, u32)> = Vec::with_capacity(4);
648                    if r > 0 {
649                        neighbours.push((r - 1, c));
650                    }
651                    if r + 1 < self.rows {
652                        neighbours.push((r + 1, c));
653                    }
654                    if c > 0 {
655                        neighbours.push((r, c - 1));
656                    }
657                    if c + 1 < self.cols {
658                        neighbours.push((r, c + 1));
659                    }
660                    for (rr, cc) in neighbours {
661                        let i = (rr * self.cols + cc) as usize;
662                        if self.is_free(rr, cc) && comp.get(i).copied().unwrap_or(0) == -1 {
663                            if let Some(cell) = comp.get_mut(i) {
664                                *cell = next;
665                            }
666                            stack.push((rr, cc));
667                        }
668                    }
669                }
670                next += 1;
671            }
672        }
673        comp
674    }
675
676    /// the connected-component id of the cell the anchor sits in, or `None` if
677    /// that cell is occupied (or the grid is empty)
678    fn anchor_component(&self, anchor: PlacementSlot, comp: &[i32]) -> Option<i32> {
679        if self.cols == 0 || self.rows == 0 {
680            return None;
681        }
682        let (row, col) = self.anchor_cell(anchor);
683        let id = comp
684            .get((row * self.cols + col) as usize)
685            .copied()
686            .unwrap_or(-1);
687        if id < 0 { None } else { Some(id) }
688    }
689
690    /// the orthogonally adjacent anchors that share a contiguous free area with
691    /// the given anchor
692    fn connected_neighbours(&self, anchor: PlacementSlot, comp: &[i32]) -> Vec<PlacementSlot> {
693        let Some(my) = self.anchor_component(anchor, comp) else {
694            return Vec::new();
695        };
696        anchor
697            .neighbours()
698            .into_iter()
699            .filter(|&nb| self.anchor_component(nb, comp) == Some(my))
700            .collect()
701    }
702
703    /// evaluate all nine anchors against this grid
704    #[must_use]
705    pub fn evaluate_slots(&self) -> Vec<PlacementSlotInfo> {
706        let components = self.free_components();
707        PlacementSlot::ALL
708            .into_iter()
709            .map(|anchor| {
710                let (h3, v3) = anchor.cell();
711                let (c_lo, c_hi) = self.col_band(u32::from(h3));
712                let (r_lo, r_hi) = self.row_band(u32::from(v3));
713                let free = self.largest_free_rect_in(
714                    c_lo,
715                    c_hi,
716                    r_lo,
717                    r_hi,
718                    anchor.horizontal(),
719                    anchor.vertical(),
720                );
721                let free_rect =
722                    free.map(|(r0, r1, c0, c1)| self.cell_rect_to_pixels(r0, r1, c0, c1));
723                let free_size = free_rect.map_or((0, 0), |r| (r.width, r.height));
724                let available = free_size.0 > 0 && free_size.1 > 0;
725                PlacementSlotInfo {
726                    slot: anchor,
727                    available,
728                    free_rect,
729                    free_size,
730                    occupied_fraction: self.local_occupied_fraction(anchor),
731                    connected_neighbours: self.connected_neighbours(anchor, &components),
732                }
733            })
734            .collect()
735    }
736
737    /// The slots and pixel rectangle a *spanning* element anchored at
738    /// `anchor` would occupy.
739    ///
740    /// Returns every [`PlacementSlot`] whose anchor cell lies in the same
741    /// connected free region as `anchor` (so all three bottom slots are
742    /// returned when the whole bottom edge is free), paired with the largest
743    /// all-free rectangle anchored at `anchor` — which extends across that
744    /// region. Returns `None` if the anchor's own cell is covered.
745    ///
746    /// Unlike [`Self::evaluate_slots`], which reports each slot independently,
747    /// this is the basis for *reserving* a contiguous block of slots for one
748    /// element so neighbouring placements cannot overlap it.
749    #[must_use]
750    pub fn spanned_region(&self, anchor: PlacementSlot) -> Option<(Vec<PlacementSlot>, PixelRect)> {
751        let components = self.free_components();
752        let my = self.anchor_component(anchor, &components)?;
753        let slots: Vec<PlacementSlot> = PlacementSlot::ALL
754            .into_iter()
755            .filter(|&slot| self.anchor_component(slot, &components) == Some(my))
756            .collect();
757        let (r0, r1, c0, c1) = self.largest_free_rect(anchor.horizontal(), anchor.vertical())?;
758        Some((slots, self.cell_rect_to_pixels(r0, r1, c0, c1)))
759    }
760
761    /// The largest all-free pixel rectangle confined to the union of the thirds
762    /// of the given `slots`.
763    ///
764    /// Unlike [`Self::spanned_region`] (which grows across a whole connected
765    /// free region), this is restricted to *exactly* the slots the caller
766    /// joined: cells outside the chosen slots' thirds are treated as occupied,
767    /// so an L-shaped union yields the largest rectangle that fits inside it.
768    /// The rectangle is anchored to the edges the *group* touches (a
769    /// centre+right group hugs the right edge, a top-left 2×2 hugs the top
770    /// left, a full row centres), rather than to any one slot's own anchoring,
771    /// so a combined slot never centres itself away from the edges its members
772    /// already reached. Returns `None` when the union is empty or fully covered.
773    #[must_use]
774    pub fn subset_rect(&self, slots: &[PlacementSlot]) -> Option<PixelRect> {
775        if self.cols == 0 || self.rows == 0 || slots.is_empty() {
776            return None;
777        }
778        // Allowed-cell mask = union of the chosen slots' thirds, plus its
779        // bounding cell band, plus which outer thirds the group touches.
780        let mut allowed = vec![false; (self.cols * self.rows) as usize];
781        let (mut c_lo, mut c_hi, mut r_lo, mut r_hi) = (self.cols, 0u32, self.rows, 0u32);
782        let (mut has_left, mut has_right, mut has_top, mut has_bottom) =
783            (false, false, false, false);
784        for &s in slots {
785            let (h3, v3) = s.cell();
786            match h3 {
787                0 => has_left = true,
788                2 => has_right = true,
789                _ => {}
790            }
791            match v3 {
792                0 => has_top = true,
793                2 => has_bottom = true,
794                _ => {}
795            }
796            let (cl, ch) = self.col_band(u32::from(h3));
797            let (rl, rh) = self.row_band(u32::from(v3));
798            c_lo = c_lo.min(cl);
799            c_hi = c_hi.max(ch);
800            r_lo = r_lo.min(rl);
801            r_hi = r_hi.max(rh);
802            for r in rl..rh {
803                for c in cl..ch {
804                    if let Some(cell) = allowed.get_mut((r * self.cols + c) as usize) {
805                        *cell = true;
806                    }
807                }
808            }
809        }
810        if c_lo >= c_hi || r_lo >= r_hi {
811            return None;
812        }
813        let free_allowed = |r: u32, c: u32| {
814            self.is_free(r, c)
815                && allowed
816                    .get((r * self.cols + c) as usize)
817                    .copied()
818                    .unwrap_or(false)
819        };
820        // Anchor to whichever outer edge(s) the group reaches (centre only when
821        // it reaches both or neither), so the combined rectangle stays against
822        // those edges instead of centring within the union band.
823        let hmode = match (has_left, has_right) {
824            (true, false) => AxisMode::Start,
825            (false, true) => AxisMode::End,
826            _ => AxisMode::Center,
827        };
828        let vmode = match (has_top, has_bottom) {
829            (true, false) => AxisMode::Start,
830            (false, true) => AxisMode::End,
831            _ => AxisMode::Center,
832        };
833        Self::best_anchored_rect(c_lo, c_hi, r_lo, r_hi, hmode, vmode, free_allowed)
834            .map(|(r0, r1, c0, c1)| self.cell_rect_to_pixels(r0, r1, c0, c1))
835    }
836}
837
838#[cfg(test)]
839mod test {
840    use super::*;
841
842    /// build an occupancy grid directly from a boolean cell layout (row-major,
843    /// row 0 at the top) with a fixed cell size, for testing the slot evaluation
844    /// in isolation from any image
845    fn grid_from_cells(cols: u32, rows: u32, cell_size: u32, occupied: Vec<bool>) -> OccupancyGrid {
846        assert_eq!(occupied.len(), (cols * rows) as usize);
847        OccupancyGrid {
848            cols,
849            rows,
850            cell_size,
851            img_width: cols * cell_size,
852            img_height: rows * cell_size,
853            occupied,
854        }
855    }
856
857    fn slot(
858        slots: &[PlacementSlotInfo],
859        anchor: PlacementSlot,
860    ) -> Result<PlacementSlotInfo, String> {
861        slots
862            .iter()
863            .find(|s| s.slot == anchor)
864            .cloned()
865            .ok_or_else(|| format!("anchor {anchor:?} should be evaluated"))
866    }
867
868    #[test]
869    fn empty_grid_confines_each_slot_to_its_third() -> Result<(), Box<dyn std::error::Error>> {
870        // 8 cells per side, 10 px each = 80 px. 8 / 3 = 2 edge cells, so the
871        // column/row bands are 2 | 4 | 2 cells -> 20 | 40 | 20 px.
872        let grid = grid_from_cells(8, 8, 10, vec![false; 64]);
873        let slots = grid.evaluate_slots();
874        assert_eq!(slots.len(), 9);
875        for s in &slots {
876            assert!(s.available, "{:?} should be available", s.slot);
877            assert!(s.occupied_fraction.abs() < f32::EPSILON);
878        }
879        // on an empty map each slot fills exactly its own third (no overlap into
880        // neighbouring thirds), the centre third being the wider one.
881        let size = |a| slot(&slots, a).map(|s| s.free_size);
882        assert_eq!(size(PlacementSlot::TopLeft)?, (20, 20));
883        assert_eq!(size(PlacementSlot::TopCenter)?, (40, 20));
884        assert_eq!(size(PlacementSlot::TopRight)?, (20, 20));
885        assert_eq!(size(PlacementSlot::MiddleLeft)?, (20, 40));
886        assert_eq!(size(PlacementSlot::Center)?, (40, 40));
887        assert_eq!(size(PlacementSlot::BottomRight)?, (20, 20));
888        Ok(())
889    }
890
891    #[test]
892    fn empty_grid_slot_rects_tile_without_overlap() -> Result<(), Box<dyn std::error::Error>> {
893        // the nine free rectangles of an empty map must partition the whole
894        // image: pairwise disjoint and their areas summing to the full image.
895        let grid = grid_from_cells(8, 8, 10, vec![false; 64]);
896        let rects: Vec<PixelRect> = grid
897            .evaluate_slots()
898            .into_iter()
899            .filter_map(|s| s.free_rect)
900            .collect();
901        assert_eq!(rects.len(), 9);
902        let overlaps = |a: &PixelRect, b: &PixelRect| {
903            a.x < b.x + b.width
904                && b.x < a.x + a.width
905                && a.y < b.y + b.height
906                && b.y < a.y + a.height
907        };
908        for (i, a) in rects.iter().enumerate() {
909            for b in rects.iter().skip(i + 1) {
910                assert!(!overlaps(a, b), "{a:?} overlaps {b:?}");
911            }
912        }
913        let total: u32 = rects.iter().map(|r| r.width * r.height).sum();
914        assert_eq!(total, 80 * 80, "the nine thirds tile the whole image");
915        Ok(())
916    }
917
918    #[test]
919    fn full_grid_no_slot_available() {
920        let grid = grid_from_cells(8, 8, 10, vec![true; 64]);
921        let slots = grid.evaluate_slots();
922        for s in &slots {
923            assert!(!s.available, "{:?} should not be available", s.slot);
924            assert_eq!(s.free_rect, None);
925            assert_eq!(s.free_size, (0, 0));
926            assert!(s.connected_neighbours.is_empty());
927        }
928    }
929
930    #[test]
931    fn central_vertical_stripe_frees_the_sides() -> Result<(), Box<dyn std::error::Error>> {
932        // occupy the two middle columns (col 3 and 4) of an 8-wide grid
933        let occupied: Vec<bool> = (0..64u32).map(|i| matches!(i % 8, 3 | 4)).collect();
934        let grid = grid_from_cells(8, 8, 10, occupied);
935        let slots = grid.evaluate_slots();
936        assert!(slot(&slots, PlacementSlot::MiddleLeft)?.available);
937        assert!(slot(&slots, PlacementSlot::MiddleRight)?.available);
938        // the centre anchor sits on the occupied stripe
939        assert!(!slot(&slots, PlacementSlot::Center)?.available);
940        // confined to its third, the free left block is the 2-cell left band -> 20 px
941        assert_eq!(slot(&slots, PlacementSlot::MiddleLeft)?.free_size.0, 20);
942        assert_eq!(slot(&slots, PlacementSlot::MiddleRight)?.free_size.0, 20);
943        Ok(())
944    }
945
946    #[test]
947    fn horizontal_mirror_swaps_left_and_right() -> Result<(), Box<dyn std::error::Error>> {
948        // occupy one interior cell of the top-left third (row 1, col 1 -> 9)
949        let occupied: Vec<bool> = (0..64u32).map(|i| i == 9).collect();
950        let grid = grid_from_cells(8, 8, 10, occupied);
951        let slots = grid.evaluate_slots();
952        let top_left = slot(&slots, PlacementSlot::TopLeft)?.free_size;
953        assert_ne!(top_left, (0, 0), "the top-left third still has free space");
954
955        // mirror horizontally (col -> 7 - col): the occupied cell lands in the
956        // top-right third (row 1, col 6 -> 14) and TopRight must report the
957        // mirror-image free size
958        let mirrored: Vec<bool> = (0..64u32).map(|i| i == 14).collect();
959        let mirrored_grid = grid_from_cells(8, 8, 10, mirrored);
960        let mirrored_slots = mirrored_grid.evaluate_slots();
961        let top_right = slot(&mirrored_slots, PlacementSlot::TopRight)?.free_size;
962
963        assert_eq!(top_left, top_right);
964        Ok(())
965    }
966
967    #[test]
968    fn adjacency_links_free_neighbours_across_free_band() -> Result<(), Box<dyn std::error::Error>>
969    {
970        // entirely free grid: every anchor connects to all its 3x3 neighbours
971        let grid = grid_from_cells(9, 9, 10, vec![false; 81]);
972        let slots = grid.evaluate_slots();
973        let center = slot(&slots, PlacementSlot::Center)?;
974        let mut neighbours = center.connected_neighbours.clone();
975        neighbours.sort_by_key(|a| format!("{a:?}"));
976        let mut expected = PlacementSlot::Center.neighbours();
977        expected.sort_by_key(|a| format!("{a:?}"));
978        assert_eq!(neighbours, expected);
979        Ok(())
980    }
981
982    #[test]
983    fn adjacency_broken_by_occupied_band() -> Result<(), Box<dyn std::error::Error>> {
984        // occupy the entire middle column (col 4) so left and right are separate
985        // free components
986        let occupied: Vec<bool> = (0..81u32).map(|i| i % 9 == 4).collect();
987        let grid = grid_from_cells(9, 9, 10, occupied);
988        let slots = grid.evaluate_slots();
989        // TopLeft and TopRight live in different components (split by the band),
990        // and neither connects to TopCenter (which sits on the band)
991        let top_left = slot(&slots, PlacementSlot::TopLeft)?;
992        assert!(
993            !top_left
994                .connected_neighbours
995                .contains(&PlacementSlot::TopCenter)
996        );
997        // TopLeft still connects downward to MiddleLeft (same left component)
998        assert!(
999            top_left
1000                .connected_neighbours
1001                .contains(&PlacementSlot::MiddleLeft)
1002        );
1003        Ok(())
1004    }
1005
1006    #[test]
1007    fn slots_form_rectangle_accepts_solid_blocks_only() {
1008        use PlacementSlot::{
1009            BottomCenter, BottomLeft, BottomRight, Center, MiddleLeft, TopCenter, TopLeft, TopRight,
1010        };
1011        // trivial cases
1012        assert!(PlacementSlot::slots_form_rectangle(&[]));
1013        assert!(PlacementSlot::slots_form_rectangle(&[TopLeft]));
1014        // a full top row, an adjacent column pair and a 2x2 block are rectangles
1015        assert!(PlacementSlot::slots_form_rectangle(&[
1016            TopLeft, TopCenter, TopRight
1017        ]));
1018        assert!(PlacementSlot::slots_form_rectangle(&[TopLeft, MiddleLeft]));
1019        assert!(PlacementSlot::slots_form_rectangle(&[
1020            TopLeft, TopCenter, MiddleLeft, Center
1021        ]));
1022        // the whole 3x3 grid, and the full left column
1023        assert!(PlacementSlot::slots_form_rectangle(&PlacementSlot::ALL));
1024        assert!(PlacementSlot::slots_form_rectangle(&[
1025            TopLeft, MiddleLeft, BottomLeft
1026        ]));
1027        // duplicates collapse to a single cell
1028        assert!(PlacementSlot::slots_form_rectangle(&[TopLeft, TopLeft]));
1029        // a diagonal pair, an L-shape and a gapped column are not rectangles
1030        assert!(!PlacementSlot::slots_form_rectangle(&[
1031            TopLeft,
1032            BottomRight
1033        ]));
1034        assert!(!PlacementSlot::slots_form_rectangle(&[
1035            TopLeft, TopCenter, MiddleLeft
1036        ]));
1037        assert!(!PlacementSlot::slots_form_rectangle(&[TopLeft, BottomLeft]));
1038        assert!(!PlacementSlot::slots_form_rectangle(&[
1039            TopLeft,
1040            BottomCenter
1041        ]));
1042    }
1043
1044    #[test]
1045    fn neighbours_are_orthogonal_only() {
1046        assert_eq!(
1047            PlacementSlot::TopLeft.neighbours(),
1048            vec![PlacementSlot::TopCenter, PlacementSlot::MiddleLeft]
1049        );
1050        let center = PlacementSlot::Center.neighbours();
1051        assert_eq!(center.len(), 4);
1052        assert!(!center.contains(&PlacementSlot::TopLeft));
1053    }
1054
1055    #[test]
1056    fn as_str_and_from_str_round_trip() {
1057        for slot in PlacementSlot::ALL {
1058            assert_eq!(slot.as_str().parse::<PlacementSlot>().ok(), Some(slot));
1059            // Display yields the same text as as_str
1060            assert_eq!(slot.to_string(), slot.as_str());
1061        }
1062        assert_eq!("nonsense".parse::<PlacementSlot>().ok(), None);
1063    }
1064
1065    #[test]
1066    fn default_alignment_matches_slot_outward() {
1067        assert_eq!(
1068            PlacementSlot::TopLeft.default_alignment(),
1069            (HAlign::Left, VAlign::Top)
1070        );
1071        assert_eq!(
1072            PlacementSlot::TopCenter.default_alignment(),
1073            (HAlign::Center, VAlign::Top)
1074        );
1075        assert_eq!(
1076            PlacementSlot::MiddleRight.default_alignment(),
1077            (HAlign::Right, VAlign::Center)
1078        );
1079        assert_eq!(
1080            PlacementSlot::Center.default_alignment(),
1081            (HAlign::Center, VAlign::Center)
1082        );
1083        assert_eq!(
1084            PlacementSlot::BottomRight.default_alignment(),
1085            (HAlign::Right, VAlign::Bottom)
1086        );
1087    }
1088
1089    #[test]
1090    fn align_offset_clamps_and_centres() {
1091        // content fits: left/top = 0, centre = half slack, right/bottom = full slack
1092        assert_eq!(HAlign::Left.offset(20, 100), 0);
1093        assert_eq!(HAlign::Center.offset(20, 100), 40);
1094        assert_eq!(HAlign::Right.offset(20, 100), 80);
1095        assert_eq!(VAlign::Bottom.offset(30, 100), 70);
1096        // content larger than span: never negative/wraps
1097        assert_eq!(HAlign::Right.offset(120, 100), 0);
1098        assert_eq!(VAlign::Center.offset(120, 100), 0);
1099    }
1100
1101    #[test]
1102    fn anchored_origin_hugs_edges_and_centres() {
1103        // 200x100 image, a 40x20 box, 8px margin
1104        assert_eq!(
1105            PlacementSlot::TopLeft.anchored_origin(40, 20, 200, 100, 8),
1106            (8, 8)
1107        );
1108        assert_eq!(
1109            PlacementSlot::TopRight.anchored_origin(40, 20, 200, 100, 8),
1110            (200 - 40 - 8, 8)
1111        );
1112        assert_eq!(
1113            PlacementSlot::BottomRight.anchored_origin(40, 20, 200, 100, 8),
1114            (200 - 40 - 8, 100 - 20 - 8)
1115        );
1116        assert_eq!(
1117            PlacementSlot::Center.anchored_origin(40, 20, 200, 100, 8),
1118            ((200 - 40) / 2, (100 - 20) / 2)
1119        );
1120        assert_eq!(
1121            PlacementSlot::TopCenter.anchored_origin(40, 20, 200, 100, 8),
1122            ((200 - 40) / 2, 8)
1123        );
1124        // box larger than image clamps to origin without underflow
1125        assert_eq!(
1126            PlacementSlot::BottomRight.anchored_origin(400, 200, 200, 100, 8),
1127            (0, 0)
1128        );
1129    }
1130
1131    #[test]
1132    fn spanned_region_covers_whole_free_bottom_edge() -> Result<(), Box<dyn std::error::Error>> {
1133        // free the entire bottom row (row 2), occupy everything above it
1134        let occupied: Vec<bool> = (0..27u32).map(|i| i / 9 != 2).collect();
1135        let grid = grid_from_cells(9, 3, 10, occupied);
1136        let (mut slots, rect) = grid
1137            .spanned_region(PlacementSlot::BottomCenter)
1138            .ok_or("bottom_center anchor is free, must span")?;
1139        slots.sort_by_key(|a| format!("{a:?}"));
1140        let mut expected = vec![
1141            PlacementSlot::BottomLeft,
1142            PlacementSlot::BottomCenter,
1143            PlacementSlot::BottomRight,
1144        ];
1145        expected.sort_by_key(|a| format!("{a:?}"));
1146        assert_eq!(slots, expected, "all three bottom slots are reserved");
1147        // the combined rectangle spans the full 9-cell width
1148        assert_eq!(rect.width, 90);
1149        assert_eq!(rect.height, 10);
1150        Ok(())
1151    }
1152
1153    #[test]
1154    fn spanned_region_none_when_anchor_covered() {
1155        // occupy the entire bottom row so the bottom_center anchor is covered
1156        let occupied: Vec<bool> = (0..27u32).map(|i| i / 9 == 2).collect();
1157        let grid = grid_from_cells(9, 3, 10, occupied);
1158        assert_eq!(grid.spanned_region(PlacementSlot::BottomCenter), None);
1159    }
1160
1161    #[test]
1162    fn spanned_region_stops_at_occupied_band() -> Result<(), Box<dyn std::error::Error>> {
1163        // free only the left two columns of the bottom row; occupy the rest.
1164        // bottom_left's component must exclude bottom_right.
1165        let occupied: Vec<bool> = (0..27u32)
1166            .map(|i| {
1167                let (row, col) = (i / 9, i % 9);
1168                !(row == 2 && col < 2)
1169            })
1170            .collect();
1171        let grid = grid_from_cells(9, 3, 10, occupied);
1172        let (slots, _rect) = grid
1173            .spanned_region(PlacementSlot::BottomLeft)
1174            .ok_or("bottom_left anchor is free")?;
1175        assert!(slots.contains(&PlacementSlot::BottomLeft));
1176        assert!(!slots.contains(&PlacementSlot::BottomRight));
1177        Ok(())
1178    }
1179
1180    #[test]
1181    fn centre_third_absorbs_remainder_keeping_edges_equal() -> Result<(), Box<dyn std::error::Error>>
1182    {
1183        // 11 cells per side is not divisible by 3: 11 / 3 = 3 edge cells, so the
1184        // bands are 3 | 5 | 3. The two edge thirds must stay equal and the centre
1185        // third must be the wider one (it absorbs the remainder).
1186        let grid = grid_from_cells(11, 11, 10, vec![false; 121]);
1187        let slots = grid.evaluate_slots();
1188        let size = |a| slot(&slots, a).map(|s| s.free_size);
1189        assert_eq!(size(PlacementSlot::TopLeft)?, (30, 30));
1190        assert_eq!(size(PlacementSlot::TopRight)?, (30, 30));
1191        assert_eq!(size(PlacementSlot::BottomLeft)?, (30, 30));
1192        assert_eq!(size(PlacementSlot::BottomRight)?, (30, 30));
1193        assert_eq!(size(PlacementSlot::Center)?, (50, 50));
1194        Ok(())
1195    }
1196
1197    #[test]
1198    fn spanned_region_uses_minimum_perpendicular_extent() -> Result<(), Box<dyn std::error::Error>>
1199    {
1200        // left third (cols 0..3) is free 4 rows deep; the rest of the top is free
1201        // only 2 rows deep. A span from top-left can either stay narrow-and-tall
1202        // (3 cols x 4 rows) or grow full-width-and-short (9 cols x 2 rows). The
1203        // wider rectangle wins on area, and its height is the *minimum* depth
1204        // across the thirds it crosses (2 rows), not the left third's 4.
1205        let occupied: Vec<bool> = (0..54u32)
1206            .map(|i| {
1207                let (row, col) = (i / 9, i % 9);
1208                let free = if col < 3 { row < 4 } else { row < 2 };
1209                !free
1210            })
1211            .collect();
1212        let grid = grid_from_cells(9, 6, 10, occupied);
1213        let (_slots, rect) = grid
1214            .spanned_region(PlacementSlot::TopLeft)
1215            .ok_or("top_left anchor is free")?;
1216        assert_eq!(rect.width, 90, "the span grows across the full width");
1217        assert_eq!(
1218            rect.height, 20,
1219            "the span is clipped to the shallower thirds"
1220        );
1221        Ok(())
1222    }
1223
1224    #[test]
1225    fn subset_rect_spans_two_adjacent_slots() -> Result<(), Box<dyn std::error::Error>> {
1226        // 9x9 grid, 10 px cells -> thirds are 3 | 3 | 3 cells (30 px each).
1227        let grid = grid_from_cells(9, 9, 10, vec![false; 81]);
1228        // top_left + top_center -> the top-left two thirds: 6 cells wide, 3 tall.
1229        let rect = grid
1230            .subset_rect(&[PlacementSlot::TopLeft, PlacementSlot::TopCenter])
1231            .ok_or("the two free top slots have a combined rect")?;
1232        assert_eq!(
1233            (rect.x, rect.y, rect.width, rect.height),
1234            (0, 0, 60, 30),
1235            "the combined rect fills both thirds"
1236        );
1237        Ok(())
1238    }
1239
1240    #[test]
1241    fn subset_rect_confined_to_chosen_slots_only() -> Result<(), Box<dyn std::error::Error>> {
1242        // An L-shape (top_left + top_center + middle_left) must not bleed into the
1243        // unselected centre third: the largest rectangle that fits the L is the
1244        // 6x3-cell top strip (anchored top-left), not a 6x6 block.
1245        let grid = grid_from_cells(9, 9, 10, vec![false; 81]);
1246        let rect = grid
1247            .subset_rect(&[
1248                PlacementSlot::TopLeft,
1249                PlacementSlot::TopCenter,
1250                PlacementSlot::MiddleLeft,
1251            ])
1252            .ok_or("the L-shaped union has a free rect")?;
1253        // top strip: cols 0..6, rows 0..3 -> 60x30 px, never crossing into the
1254        // unselected centre third (which would make it 60x60).
1255        assert_eq!((rect.x, rect.y, rect.width, rect.height), (0, 0, 60, 30));
1256        Ok(())
1257    }
1258
1259    #[test]
1260    fn subset_rect_none_when_union_fully_occupied() -> Result<(), Box<dyn std::error::Error>> {
1261        // Occupy the whole top-left + top-center thirds; their union has no room.
1262        let occupied: Vec<bool> = (0..81u32)
1263            .map(|i| {
1264                let (row, col) = (i / 9, i % 9);
1265                row < 3 && col < 6
1266            })
1267            .collect();
1268        let grid = grid_from_cells(9, 9, 10, occupied);
1269        assert!(
1270            grid.subset_rect(&[PlacementSlot::TopLeft, PlacementSlot::TopCenter])
1271                .is_none(),
1272            "a fully covered union has no combined rect"
1273        );
1274        Ok(())
1275    }
1276
1277    #[test]
1278    fn subset_rect_hugs_the_edge_the_group_touches() -> Result<(), Box<dyn std::error::Error>> {
1279        // bottom_center + bottom_right occupies cols 3..9, rows 6..9. Occupy the
1280        // left column of that union (col 3): the largest free rect must hug the
1281        // RIGHT edge (cols 4..9), not centre itself within the band.
1282        let occupied: Vec<bool> = (0..81u32)
1283            .map(|i| {
1284                let (row, col) = (i / 9, i % 9);
1285                col == 3 && row >= 6
1286            })
1287            .collect();
1288        let grid = grid_from_cells(9, 9, 10, occupied);
1289        let rect = grid
1290            .subset_rect(&[PlacementSlot::BottomCenter, PlacementSlot::BottomRight])
1291            .ok_or("the bottom centre+right union has a free rect")?;
1292        assert_eq!(
1293            (rect.x, rect.y, rect.width, rect.height),
1294            (40, 60, 50, 30),
1295            "the rect hugs the right edge instead of centring"
1296        );
1297        // its right edge reaches the grid's right edge (90 px), no gap there.
1298        assert_eq!(rect.x + rect.width, 90);
1299        Ok(())
1300    }
1301
1302    mod with_map {
1303        use super::*;
1304        use crate::map_tiles::Map;
1305        use image::GenericImageView as _;
1306        use sl_types::map::{GridCoordinates, GridRectangle, ZoomLevel};
1307
1308        fn test_rectangle() -> GridRectangle {
1309            // a 4x4 region rectangle
1310            GridRectangle::new(
1311                GridCoordinates::new(1000, 1000),
1312                GridCoordinates::new(1003, 1003),
1313            )
1314        }
1315
1316        #[test]
1317        fn blank_dimensions_match_zoom_times_regions() -> Result<(), Box<dyn std::error::Error>> {
1318            let zoom = ZoomLevel::try_new(4)?;
1319            let map = Map::blank(test_rectangle(), zoom);
1320            // zoom 4 -> 32 pixels per region, 4 regions per side
1321            let expected = u32::from(zoom.pixels_per_region()) * 4;
1322            assert_eq!(map.dimensions(), (expected, expected));
1323            assert_eq!(map.dimensions(), (128, 128));
1324            Ok(())
1325        }
1326
1327        #[test]
1328        fn blank_fit_matches_real_render_sizing() -> Result<(), Box<dyn std::error::Error>> {
1329            let rect = test_rectangle();
1330            let map = Map::blank_fit(rect.clone(), 200, 200)?;
1331            let zoom = ZoomLevel::max_zoom_level_to_fit_regions_into_output_image(4, 4, 200, 200)?;
1332            let expected = u32::from(zoom.pixels_per_region()) * 4;
1333            assert_eq!(map.dimensions(), (expected, expected));
1334            Ok(())
1335        }
1336
1337        #[test]
1338        fn stamped_rectangle_blocks_its_corner() -> Result<(), Box<dyn std::error::Error>> {
1339            let mut map = Map::blank(test_rectangle(), ZoomLevel::try_new(4)?);
1340            // opaque rectangle covering the top-left 40x40 pixels
1341            imageproc::drawing::draw_filled_rect_mut(
1342                map.image_mut(),
1343                imageproc::rect::Rect::at(0, 0).of_size(40, 40),
1344                image::Rgba([255, 0, 0, 255]),
1345            );
1346            let grid = OccupancyGrid::from_map(&map, DEFAULT_COVERAGE_GRID);
1347            let slots = grid.evaluate_slots();
1348            // the covered top-left corner has no free space
1349            assert!(!slot(&slots, PlacementSlot::TopLeft)?.available);
1350            assert!(slot(&slots, PlacementSlot::TopLeft)?.occupied_fraction > 0f32);
1351            // the far corner is wide open: free across its whole third (the
1352            // 128 px image splits into thirds of ~42 px, so the bottom-right
1353            // third is unobstructed end to end)
1354            let bottom_right = slot(&slots, PlacementSlot::BottomRight)?;
1355            assert!(bottom_right.available);
1356            assert!(bottom_right.free_size.0 >= 40);
1357            assert!(bottom_right.free_size.1 >= 40);
1358            Ok(())
1359        }
1360
1361        #[test]
1362        fn diagonal_route_blocks_centre_but_not_off_diagonal_corner()
1363        -> Result<(), Box<dyn std::error::Error>> {
1364            let mut map = Map::blank(test_rectangle(), ZoomLevel::try_new(4)?);
1365            map.draw_pixel_waypoint_route(
1366                &[(10f32, 10f32), (64f32, 64f32), (118f32, 118f32)],
1367                image::Rgba([0, 0, 255, 255]),
1368            )?;
1369            let grid = OccupancyGrid::from_map(&map, DEFAULT_COVERAGE_GRID);
1370            let slots = grid.evaluate_slots();
1371            // the route runs through the middle of the map
1372            assert!(!slot(&slots, PlacementSlot::Center)?.available);
1373            // the top-right corner is far from the descending diagonal
1374            assert!(slot(&slots, PlacementSlot::TopRight)?.available);
1375            Ok(())
1376        }
1377    }
1378}