Skip to main content

vello_common/
multi_atlas.rs

1// Copyright 2025 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Multi-atlas management for texture atlases.
5//!
6//! This module provides support for managing multiple texture atlases, allowing for handling of
7//! large numbers of images.
8//!
9//! The allocator backend is [guillotiere](https://github.com/nical/guillotiere)'s tree-based
10//! guillotine algorithm, providing O(1) neighbor lookup during deallocation and automatic
11//! free-rect coalescing.
12
13use alloc::vec::Vec;
14pub use guillotiere::AllocId;
15use guillotiere::AtlasAllocator;
16use thiserror::Error;
17
18/// The result of a successful rectangle allocation within a single atlas.
19#[derive(Debug)]
20pub struct Allocation {
21    /// Opaque handle used for deallocation.
22    pub id: AllocId,
23    /// X coordinate of the top-left corner of the allocated rectangle.
24    pub x: u32,
25    /// Y coordinate of the top-left corner of the allocated rectangle.
26    pub y: u32,
27}
28
29// ---------------------------------------------------------------------------
30// Unified Atlas type
31// ---------------------------------------------------------------------------
32
33/// Represents a single atlas in the multi-atlas system.
34pub struct Atlas {
35    /// Unique identifier for this atlas.
36    pub id: AtlasId,
37    /// Rectangle allocator backend.
38    allocator: AtlasAllocator,
39    /// Current usage statistics.
40    stats: AtlasUsageStats,
41    /// Allocation counter.
42    allocation_counter: u32,
43}
44
45impl Atlas {
46    /// Create a new atlas with the given ID and size.
47    pub fn new(id: AtlasId, width: u32, height: u32) -> Self {
48        Self {
49            id,
50            allocator: AtlasAllocator::new(guillotiere::size2(width as i32, height as i32)),
51            stats: AtlasUsageStats {
52                allocated_area: 0,
53                total_area: width * height,
54                allocated_count: 0,
55            },
56            allocation_counter: 0,
57        }
58    }
59
60    /// Try to allocate an image in this atlas.
61    #[expect(
62        clippy::cast_sign_loss,
63        reason = "coordinates are always non-negative for valid allocations"
64    )]
65    pub fn allocate(&mut self, width: u32, height: u32) -> Option<Allocation> {
66        let alloc = self
67            .allocator
68            .allocate(guillotiere::size2(width as i32, height as i32))?;
69        self.stats.allocated_area += width * height;
70        self.stats.allocated_count += 1;
71        self.allocation_counter += 1;
72        Some(Allocation {
73            id: alloc.id,
74            x: alloc.rectangle.min.x as u32,
75            y: alloc.rectangle.min.y as u32,
76        })
77    }
78
79    /// Deallocate an image from this atlas.
80    pub fn deallocate(&mut self, alloc_id: AllocId, width: u32, height: u32) {
81        self.allocator.deallocate(alloc_id);
82        self.stats.allocated_area = self.stats.allocated_area.saturating_sub(width * height);
83        self.stats.allocated_count = self.stats.allocated_count.saturating_sub(1);
84    }
85
86    /// Get current usage statistics.
87    pub fn stats(&self) -> &AtlasUsageStats {
88        &self.stats
89    }
90}
91
92impl core::fmt::Debug for Atlas {
93    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
94        f.debug_struct("Atlas")
95            .field("id", &self.id)
96            .field("stats", &self.stats)
97            .field("allocation_counter", &self.allocation_counter)
98            .finish_non_exhaustive()
99    }
100}
101
102// ---------------------------------------------------------------------------
103// MultiAtlasManager
104// ---------------------------------------------------------------------------
105
106/// Manages multiple texture atlases.
107pub struct MultiAtlasManager {
108    /// All atlases managed by this instance.
109    atlases: Vec<Atlas>,
110    /// Configuration for atlas management.
111    config: AtlasConfig,
112    /// Round-robin counter for allocation strategy.
113    round_robin_counter: usize,
114}
115
116impl MultiAtlasManager {
117    /// Create a new multi-atlas manager with the given configuration.
118    pub fn new(config: AtlasConfig) -> Self {
119        let mut manager = Self {
120            atlases: Vec::new(),
121            config,
122            round_robin_counter: 0,
123        };
124
125        for _ in 0..config.initial_atlas_count {
126            manager
127                .create_atlas()
128                .expect("Failed to create initial atlas");
129        }
130
131        manager
132    }
133
134    /// Get the current configuration.
135    pub fn config(&self) -> &AtlasConfig {
136        &self.config
137    }
138
139    /// Create a new atlas and return its ID.
140    pub fn create_atlas(&mut self) -> Result<AtlasId, AtlasError> {
141        if self.atlases.len() >= self.config.max_atlases {
142            return Err(AtlasError::AtlasLimitReached {
143                max_atlases: self.config.max_atlases,
144                diagnostics: AtlasSpaceDiagnostics::Unavailable,
145            });
146        }
147
148        let atlas_id = AtlasId::new(self.next_atlas_id());
149
150        let atlas = Atlas::new(atlas_id, self.config.atlas_size.0, self.config.atlas_size.1);
151        self.atlases.push(atlas);
152
153        Ok(atlas_id)
154    }
155
156    /// Get the next available atlas ID.
157    pub fn next_atlas_id(&self) -> u32 {
158        u32::try_from(self.atlases.len()).unwrap()
159    }
160
161    /// Try to allocate space for an image with the given dimensions.
162    pub fn try_allocate(&mut self, width: u32, height: u32) -> Result<AtlasAllocation, AtlasError> {
163        self.try_allocate_excluding(width, height, None)
164    }
165
166    /// Try to allocate space for an image with the given dimensions,
167    /// optionally excluding a specific atlas.
168    pub fn try_allocate_excluding(
169        &mut self,
170        width: u32,
171        height: u32,
172        exclude_atlas_id: Option<AtlasId>,
173    ) -> Result<AtlasAllocation, AtlasError> {
174        // Check if the image is too large for any atlas
175        if width > self.config.atlas_size.0 || height > self.config.atlas_size.1 {
176            return Err(AtlasError::TextureTooLarge {
177                width,
178                height,
179                max_width: self.config.atlas_size.0,
180                max_height: self.config.atlas_size.1,
181            });
182        }
183
184        // Try allocation based on strategy
185        match self.config.allocation_strategy {
186            AllocationStrategy::FirstFit => {
187                self.allocate_first_fit(width, height, exclude_atlas_id)
188            }
189            AllocationStrategy::BestFit => self.allocate_best_fit(width, height, exclude_atlas_id),
190            AllocationStrategy::LeastUsed => {
191                self.allocate_least_used(width, height, exclude_atlas_id)
192            }
193            AllocationStrategy::RoundRobin => {
194                self.allocate_round_robin(width, height, exclude_atlas_id)
195            }
196        }
197    }
198
199    fn space_diagnostics(&self, width: u32, height: u32) -> AtlasSpaceDiagnostics {
200        let mut atlases = Vec::new();
201
202        for atlas in &self.atlases {
203            let mut free_area = 0_u64;
204            let mut free_rectangle_count = 0;
205            let mut largest_free_width = 0;
206            let mut largest_free_height = 0;
207            let mut largest_free_area = 0_u64;
208            atlas.allocator.for_each_free_rectangle(|rect| {
209                let rect_width = rect.width() as u32;
210                let rect_height = rect.height() as u32;
211                let rect_area = u64::from(rect_width) * u64::from(rect_height);
212                free_area += rect_area;
213                free_rectangle_count += 1;
214
215                if rect_area > largest_free_area {
216                    largest_free_area = rect_area;
217                    largest_free_width = rect_width;
218                    largest_free_height = rect_height;
219                }
220            });
221
222            atlases.push(AtlasLayerDiagnostics {
223                atlas_id: atlas.id,
224                total_area: u64::from(atlas.stats.total_area),
225                free_area,
226                free_rectangle_count,
227                largest_free_width,
228                largest_free_height,
229            });
230        }
231
232        AtlasSpaceDiagnostics::Allocation {
233            width,
234            height,
235            atlas_width: self.config.atlas_size.0,
236            atlas_height: self.config.atlas_size.1,
237            max_atlases: self.config.max_atlases,
238            atlases,
239        }
240    }
241
242    fn no_space_available(&self, width: u32, height: u32) -> AtlasError {
243        AtlasError::NoSpaceAvailable(self.space_diagnostics(width, height))
244    }
245
246    fn atlas_limit_reached(&self, width: u32, height: u32) -> AtlasError {
247        AtlasError::AtlasLimitReached {
248            max_atlases: self.config.max_atlases,
249            diagnostics: self.space_diagnostics(width, height),
250        }
251    }
252
253    /// Allocate using first-fit strategy: try atlases in order until one has space.
254    fn allocate_first_fit(
255        &mut self,
256        width: u32,
257        height: u32,
258        exclude_atlas_id: Option<AtlasId>,
259    ) -> Result<AtlasAllocation, AtlasError> {
260        for atlas in &mut self.atlases {
261            if Some(atlas.id) == exclude_atlas_id {
262                continue;
263            }
264
265            if let Some(allocation) = atlas.allocate(width, height) {
266                return Ok(AtlasAllocation {
267                    atlas_id: atlas.id,
268                    allocation,
269                });
270            }
271        }
272
273        // Try creating a new atlas if auto-grow is enabled
274        if self.config.auto_grow {
275            let atlas_id = self
276                .create_atlas()
277                .map_err(|_| self.atlas_limit_reached(width, height))?;
278            let atlas = self.atlases.last_mut().unwrap();
279            if let Some(allocation) = atlas.allocate(width, height) {
280                return Ok(AtlasAllocation {
281                    atlas_id,
282                    allocation,
283                });
284            }
285        }
286
287        Err(self.no_space_available(width, height))
288    }
289
290    /// Allocate using best-fit strategy: choose the atlas with the smallest remaining space that
291    /// can fit the image.
292    fn allocate_best_fit(
293        &mut self,
294        width: u32,
295        height: u32,
296        exclude_atlas_id: Option<AtlasId>,
297    ) -> Result<AtlasAllocation, AtlasError> {
298        let mut best_atlas_idx = None;
299        let mut best_remaining_space = u32::MAX;
300
301        // Find the atlas with the least remaining space that can fit the image
302        for (idx, atlas) in self.atlases.iter().enumerate() {
303            if Some(atlas.id) == exclude_atlas_id {
304                continue;
305            }
306
307            let stats = atlas.stats();
308            let remaining_space = stats.total_area - stats.allocated_area;
309
310            if remaining_space >= width * height && remaining_space < best_remaining_space {
311                best_remaining_space = remaining_space;
312                best_atlas_idx = Some(idx);
313            }
314        }
315
316        if let Some(idx) = best_atlas_idx {
317            let atlas = &mut self.atlases[idx];
318            if let Some(allocation) = atlas.allocate(width, height) {
319                return Ok(AtlasAllocation {
320                    atlas_id: atlas.id,
321                    allocation,
322                });
323            }
324        }
325
326        // Fallback to first-fit if best-fit didn't work
327        self.allocate_first_fit(width, height, exclude_atlas_id)
328    }
329
330    /// Allocate using least-used strategy: prefer the atlas with the lowest usage percentage.
331    fn allocate_least_used(
332        &mut self,
333        width: u32,
334        height: u32,
335        exclude_atlas_id: Option<AtlasId>,
336    ) -> Result<AtlasAllocation, AtlasError> {
337        let mut best_atlas_idx = None;
338        let mut lowest_usage = f32::MAX;
339
340        // Find the atlas with the lowest usage percentage
341        for (idx, atlas) in self.atlases.iter().enumerate() {
342            if Some(atlas.id) == exclude_atlas_id {
343                continue;
344            }
345
346            let usage = atlas.stats().usage_percentage();
347            if usage < lowest_usage {
348                lowest_usage = usage;
349                best_atlas_idx = Some(idx);
350            }
351        }
352
353        if let Some(idx) = best_atlas_idx
354            && let Some(allocation) = self.atlases[idx].allocate(width, height)
355        {
356            let atlas_id = self.atlases[idx].id;
357            return Ok(AtlasAllocation {
358                atlas_id,
359                allocation,
360            });
361        }
362
363        // Fallback to first-fit if least-used didn't work
364        self.allocate_first_fit(width, height, exclude_atlas_id)
365    }
366
367    /// Allocate using round-robin strategy: cycle through atlases using a round-robin counter.
368    fn allocate_round_robin(
369        &mut self,
370        width: u32,
371        height: u32,
372        exclude_atlas_id: Option<AtlasId>,
373    ) -> Result<AtlasAllocation, AtlasError> {
374        if self.atlases.is_empty() {
375            return self.allocate_first_fit(width, height, exclude_atlas_id);
376        }
377
378        let start_idx = self.round_robin_counter % self.atlases.len();
379
380        // Try starting from the round-robin position
381        for i in 0..self.atlases.len() {
382            let idx = (start_idx + i) % self.atlases.len();
383
384            if Some(self.atlases[idx].id) == exclude_atlas_id {
385                continue;
386            }
387
388            if let Some(allocation) = self.atlases[idx].allocate(width, height) {
389                let atlas_id = self.atlases[idx].id;
390                self.round_robin_counter = (idx + 1) % self.atlases.len();
391                return Ok(AtlasAllocation {
392                    atlas_id,
393                    allocation,
394                });
395            }
396        }
397
398        // Try creating a new atlas if auto-grow is enabled
399        if self.config.auto_grow {
400            let atlas_id = self
401                .create_atlas()
402                .map_err(|_| self.atlas_limit_reached(width, height))?;
403            let atlas = self.atlases.last_mut().unwrap();
404            if let Some(allocation) = atlas.allocate(width, height) {
405                self.round_robin_counter = self.atlases.len() - 1;
406                return Ok(AtlasAllocation {
407                    atlas_id,
408                    allocation,
409                });
410            }
411        }
412
413        Err(self.no_space_available(width, height))
414    }
415
416    /// Deallocate space in the specified atlas.
417    pub fn deallocate(
418        &mut self,
419        atlas_id: AtlasId,
420        alloc_id: AllocId,
421        width: u32,
422        height: u32,
423    ) -> Result<(), AtlasError> {
424        // Since atlases only grow (never deallocate) and id is the index into the atlases vec,
425        // we can do a lookup instead of a linear search
426        let atlas = self
427            .atlases
428            .get_mut(atlas_id.0 as usize)
429            .ok_or(AtlasError::AtlasNotFound(atlas_id))?;
430        atlas.deallocate(alloc_id, width, height);
431        Ok(())
432    }
433
434    /// Get statistics for all atlases.
435    pub fn atlas_stats(&self) -> Vec<(AtlasId, &AtlasUsageStats)> {
436        self.atlases
437            .iter()
438            .map(|atlas| (atlas.id, atlas.stats()))
439            .collect()
440    }
441
442    /// Get the number of atlases.
443    pub fn atlas_count(&self) -> usize {
444        self.atlases.len()
445    }
446}
447
448impl core::fmt::Debug for MultiAtlasManager {
449    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
450        f.debug_struct("MultiAtlasManager")
451            .field("atlas_count", &self.atlases.len())
452            .field("config", &self.config)
453            .field("next_atlas_id", &self.next_atlas_id())
454            .field("round_robin_counter", &self.round_robin_counter)
455            .field("atlases", &self.atlases)
456            .finish()
457    }
458}
459
460/// Errors that can occur during atlas operations.
461#[derive(Debug, Clone, Error)]
462pub enum AtlasError {
463    /// No space available in any atlas.
464    #[error("No space available in any atlas{0}")]
465    NoSpaceAvailable(AtlasSpaceDiagnostics),
466    /// Maximum number of atlases reached.
467    #[error("Maximum atlas count reached ({max_atlases}){diagnostics}")]
468    AtlasLimitReached {
469        /// The configured maximum number of atlases.
470        max_atlases: usize,
471        /// Details about the failed allocation, when available.
472        diagnostics: AtlasSpaceDiagnostics,
473    },
474    /// The requested texture size is too large for any atlas.
475    #[error("Texture too large ({width}x{height}) for atlas (maximum {max_width}x{max_height})")]
476    TextureTooLarge {
477        /// The width of the requested texture.
478        width: u32,
479        /// The height of the requested texture.
480        height: u32,
481        /// The maximum texture width supported by the atlas.
482        max_width: u32,
483        /// The maximum texture height supported by the atlas.
484        max_height: u32,
485    },
486    /// The specified atlas was not found.
487    #[error("Atlas with Id {0:?} not found")]
488    AtlasNotFound(AtlasId),
489}
490
491/// Free-space details collected after an atlas allocation fails.
492#[derive(Clone)]
493pub enum AtlasSpaceDiagnostics {
494    /// No allocation context is available.
495    Unavailable,
496    /// Details about the requested allocation and available atlas space.
497    Allocation {
498        /// The requested allocation width.
499        width: u32,
500        /// The requested allocation height.
501        height: u32,
502        /// The width shared by all atlas layers.
503        atlas_width: u32,
504        /// The height shared by all atlas layers.
505        atlas_height: u32,
506        /// The configured maximum number of atlas layers.
507        max_atlases: usize,
508        /// Per-layer details for each atlas considered for the allocation.
509        atlases: Vec<AtlasLayerDiagnostics>,
510    },
511}
512
513impl core::fmt::Debug for AtlasSpaceDiagnostics {
514    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
515        let Self::Allocation {
516            width,
517            height,
518            atlas_width,
519            atlas_height,
520            max_atlases,
521            atlases,
522        } = self
523        else {
524            return f.write_str("Unavailable");
525        };
526
527        f.debug_struct("Allocation")
528            .field("requested", &Dimensions(*width, *height))
529            .field("layer_size", &Dimensions(*atlas_width, *atlas_height))
530            .field("max_atlases", max_atlases)
531            .field("atlas_layers", atlases)
532            .finish()
533    }
534}
535
536impl core::fmt::Display for AtlasSpaceDiagnostics {
537    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
538        let Self::Allocation {
539            width,
540            height,
541            atlas_width: _,
542            atlas_height: _,
543            max_atlases,
544            atlases,
545        } = self
546        else {
547            return Ok(());
548        };
549
550        let total_area = atlases.iter().map(|atlas| atlas.total_area).sum::<u64>();
551        let free_area = atlases.iter().map(|atlas| atlas.free_area).sum::<u64>();
552        let used_percentage = if total_area == 0 {
553            0.0
554        } else {
555            (1.0 - free_area as f64 / total_area as f64) * 100.0
556        };
557        write!(
558            f,
559            ": failed to allocate {width}x{height} across {} atlas layers \
560             (maximum {max_atlases}; {used_percentage:.1}% used)",
561            atlases.len(),
562        )
563    }
564}
565
566/// Free-space details for one atlas texture-array layer.
567#[derive(Clone)]
568pub struct AtlasLayerDiagnostics {
569    /// The atlas represented by this layer.
570    pub atlas_id: AtlasId,
571    /// The total layer area, in texels.
572    pub total_area: u64,
573    /// The total free layer area, in texels.
574    pub free_area: u64,
575    /// The number of disjoint free rectangles in the layer.
576    pub free_rectangle_count: usize,
577    /// The width of the largest free rectangle by area.
578    pub largest_free_width: u32,
579    /// The height of the largest free rectangle by area.
580    pub largest_free_height: u32,
581}
582
583impl AtlasLayerDiagnostics {
584    /// Calculate layer utilization as a percentage from 0 to 100.
585    pub fn utilization_percentage(&self) -> f64 {
586        if self.total_area == 0 {
587            0.0
588        } else {
589            (1.0 - self.free_area as f64 / self.total_area as f64) * 100.0
590        }
591    }
592
593    /// Calculate layer fragmentation as a percentage from 0 to 100.
594    pub fn fragmentation_percentage(&self) -> f64 {
595        if self.free_area == 0 {
596            0.0
597        } else {
598            let largest_free_area =
599                u64::from(self.largest_free_width) * u64::from(self.largest_free_height);
600            (1.0 - largest_free_area as f64 / self.free_area as f64) * 100.0
601        }
602    }
603}
604
605impl core::fmt::Debug for AtlasLayerDiagnostics {
606    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
607        f.debug_struct("Layer")
608            .field("atlas_id", &self.atlas_id)
609            .field("utilization", &Percentage(self.utilization_percentage()))
610            .field("capacity", &self.total_area)
611            .field("free", &self.free_area)
612            .field(
613                "largest_free_rectangle",
614                &Dimensions(self.largest_free_width, self.largest_free_height),
615            )
616            .field("free_rectangles", &self.free_rectangle_count)
617            .field(
618                "fragmentation",
619                &Percentage(self.fragmentation_percentage()),
620            )
621            .finish()
622    }
623}
624
625struct Dimensions(u32, u32);
626
627impl core::fmt::Debug for Dimensions {
628    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
629        write!(f, "{}x{}", self.0, self.1)
630    }
631}
632
633struct Percentage(f64);
634
635impl core::fmt::Debug for Percentage {
636    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
637        write!(f, "{:.1}%", self.0)
638    }
639}
640
641/// Unique identifier for an atlas.
642#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
643pub struct AtlasId(pub u32);
644
645impl AtlasId {
646    /// Create a new atlas ID.
647    pub fn new(id: u32) -> Self {
648        Self(id)
649    }
650
651    /// Get the raw ID value.
652    pub fn as_u32(self) -> u32 {
653        self.0
654    }
655}
656
657/// Usage statistics for an atlas.
658#[derive(Debug, Clone)]
659pub struct AtlasUsageStats {
660    /// Total allocated area in pixels.
661    pub allocated_area: u32,
662    /// Total available area in pixels.
663    pub total_area: u32,
664    /// Number of allocated images.
665    pub allocated_count: u32,
666}
667
668impl AtlasUsageStats {
669    /// Calculate usage percentage (0.0 to 1.0).
670    pub fn usage_percentage(&self) -> f32 {
671        if self.total_area == 0 {
672            0.0
673        } else {
674            self.allocated_area as f32 / self.total_area as f32
675        }
676    }
677}
678
679/// Result of an atlas allocation attempt.
680#[derive(Debug)]
681pub struct AtlasAllocation {
682    /// The atlas where the allocation was made.
683    pub atlas_id: AtlasId,
684    /// The allocation details.
685    pub allocation: Allocation,
686}
687
688/// Configuration for multiple atlas support.
689///
690/// Note that any values provided here are recommendations and might not be fully
691/// honored depending on the capabilities of the backend. For example, if you define
692/// the atlas size to be 8192x8192 but the device only supports texture sizes up to 4096x4096,
693/// the backend will likely decide to instead use the value that is compatible with the device.
694#[derive(Debug, Clone, Copy)]
695pub struct AtlasConfig {
696    /// Initial number of atlases to create.
697    ///
698    /// Set this to zero to allocate the first atlas lazily.
699    pub initial_atlas_count: usize,
700    /// Maximum number of atlases to create.
701    pub max_atlases: usize,
702    // TODO: Make those u16 instead?
703    /// Size of each atlas texture.
704    pub atlas_size: (u32, u32),
705    /// Whether to automatically create new atlases when needed.
706    pub auto_grow: bool,
707    /// Strategy for allocating images across atlases.
708    pub allocation_strategy: AllocationStrategy,
709}
710
711impl Default for AtlasConfig {
712    fn default() -> Self {
713        Self {
714            initial_atlas_count: 0,
715            max_atlases: 8,
716            atlas_size: (4096, 4096),
717            auto_grow: true,
718            allocation_strategy: AllocationStrategy::FirstFit,
719        }
720    }
721}
722
723/// Strategy for allocating images across multiple atlases.
724#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
725pub enum AllocationStrategy {
726    /// Try atlases in order until one has space.
727    #[default]
728    FirstFit,
729    /// Choose the atlas with the smallest remaining space that can fit the image.
730    BestFit,
731    /// Prefer the atlas with the lowest usage percentage.
732    LeastUsed,
733    /// Cycle through atlases in round-robin fashion.
734    RoundRobin,
735}
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740
741    #[test]
742    fn test_atlas_creation() {
743        let mut manager = MultiAtlasManager::new(AtlasConfig {
744            initial_atlas_count: 0,
745            ..Default::default()
746        });
747
748        let atlas_id = manager.create_atlas().unwrap();
749        assert_eq!(atlas_id.as_u32(), 0);
750        assert_eq!(manager.atlas_count(), 1);
751    }
752
753    #[test]
754    fn test_default_lazily_creates_first_atlas() {
755        let mut manager = MultiAtlasManager::new(AtlasConfig::default());
756        assert_eq!(manager.atlas_count(), 0);
757
758        let allocation = manager.try_allocate(100, 100).unwrap();
759        assert_eq!(allocation.atlas_id.as_u32(), 0);
760        assert_eq!(manager.atlas_count(), 1);
761    }
762
763    #[test]
764    fn test_allocation_strategies() {
765        let mut manager = MultiAtlasManager::new(AtlasConfig {
766            initial_atlas_count: 1,
767            max_atlases: 3,
768            atlas_size: (256, 256),
769            allocation_strategy: AllocationStrategy::FirstFit,
770            auto_grow: true,
771        });
772
773        // Should create atlas automatically
774        let allocation = manager.try_allocate(100, 100).unwrap();
775        assert_eq!(allocation.atlas_id.as_u32(), 0);
776    }
777
778    #[test]
779    fn test_atlas_limit() {
780        let mut manager = MultiAtlasManager::new(AtlasConfig {
781            initial_atlas_count: 1,
782            max_atlases: 1,
783            atlas_size: (256, 256),
784            allocation_strategy: AllocationStrategy::FirstFit,
785            auto_grow: false,
786        });
787
788        assert!(matches!(
789            manager.create_atlas(),
790            Err(AtlasError::AtlasLimitReached {
791                max_atlases: 1,
792                diagnostics: AtlasSpaceDiagnostics::Unavailable,
793            })
794        ));
795    }
796
797    #[test]
798    fn test_no_space_diagnostics() {
799        let mut manager = MultiAtlasManager::new(AtlasConfig {
800            initial_atlas_count: 1,
801            max_atlases: 1,
802            atlas_size: (256, 256),
803            auto_grow: false,
804            ..Default::default()
805        });
806        manager.try_allocate(128, 256).unwrap();
807
808        let Err(AtlasError::NoSpaceAvailable(AtlasSpaceDiagnostics::Allocation {
809            width: 129,
810            height: 256,
811            atlas_width: 256,
812            atlas_height: 256,
813            max_atlases: 1,
814            atlases,
815        })) = manager.try_allocate(129, 256)
816        else {
817            panic!("expected no-space diagnostics");
818        };
819        assert_eq!(atlases.len(), 1);
820        let atlas = &atlases[0];
821        assert_eq!(atlas.atlas_id, AtlasId::new(0));
822        assert_eq!(atlas.total_area, 65_536);
823        assert_eq!(atlas.free_area, 32_768);
824        assert_eq!(atlas.free_rectangle_count, 1);
825        assert_eq!(
826            (atlas.largest_free_width, atlas.largest_free_height),
827            (128, 256)
828        );
829        assert_eq!(atlas.fragmentation_percentage(), 0.0);
830    }
831
832    #[test]
833    fn test_atlas_limit_diagnostics() {
834        let mut manager = MultiAtlasManager::new(AtlasConfig {
835            initial_atlas_count: 1,
836            max_atlases: 1,
837            atlas_size: (256, 256),
838            auto_grow: true,
839            ..Default::default()
840        });
841        manager.try_allocate(128, 256).unwrap();
842
843        let Err(AtlasError::AtlasLimitReached {
844            max_atlases: 1,
845            diagnostics:
846                AtlasSpaceDiagnostics::Allocation {
847                    width: 129,
848                    height: 256,
849                    atlas_width: 256,
850                    atlas_height: 256,
851                    max_atlases: 1,
852                    atlases,
853                },
854        }) = manager.try_allocate(129, 256)
855        else {
856            panic!("expected atlas-limit diagnostics");
857        };
858        assert_eq!(atlases.len(), 1);
859    }
860
861    #[test]
862    fn test_fragmentation_per_atlas_layer() {
863        let atlases = [
864            AtlasLayerDiagnostics {
865                atlas_id: AtlasId::new(0),
866                total_area: 100,
867                free_area: 100,
868                free_rectangle_count: 1,
869                largest_free_width: 10,
870                largest_free_height: 10,
871            },
872            AtlasLayerDiagnostics {
873                atlas_id: AtlasId::new(1),
874                total_area: 100,
875                free_area: 100,
876                free_rectangle_count: 2,
877                largest_free_width: 5,
878                largest_free_height: 10,
879            },
880        ];
881
882        assert_eq!(atlases[0].fragmentation_percentage(), 0.0);
883        assert_eq!(atlases[1].fragmentation_percentage(), 50.0);
884    }
885
886    #[test]
887    fn test_texture_too_large() {
888        let mut manager = MultiAtlasManager::new(AtlasConfig {
889            atlas_size: (256, 256),
890            ..Default::default()
891        });
892
893        let result = manager.try_allocate(300, 300);
894        assert!(matches!(
895            result,
896            Err(AtlasError::TextureTooLarge {
897                width: 300,
898                height: 300,
899                max_width: 256,
900                max_height: 256,
901            })
902        ));
903    }
904
905    #[test]
906    fn test_first_fit_allocation_strategy() {
907        let mut manager = MultiAtlasManager::new(AtlasConfig {
908            initial_atlas_count: 3,
909            max_atlases: 3,
910            atlas_size: (256, 256),
911            allocation_strategy: AllocationStrategy::FirstFit,
912            auto_grow: false,
913        });
914
915        // First allocation should go to atlas 0
916        let allocation0 = manager.try_allocate(100, 100).unwrap();
917        assert_eq!(allocation0.atlas_id.as_u32(), 0);
918
919        // Second allocation should also go to atlas 0 (first fit)
920        let allocation1 = manager.try_allocate(50, 50).unwrap();
921        assert_eq!(allocation1.atlas_id.as_u32(), 0);
922
923        // Third allocation should still go to atlas 0 (first fit continues to use same atlas)
924        let allocation2 = manager.try_allocate(80, 80).unwrap();
925        assert_eq!(allocation2.atlas_id.as_u32(), 0);
926
927        // Try to allocate something very large that definitely won't fit in atlas 0's remaining space
928        // This should force it to go to atlas 1
929        let allocation3 = manager.try_allocate(200, 200).unwrap();
930        assert_eq!(allocation3.atlas_id.as_u32(), 1);
931
932        // Next small allocation should go back to atlas 0 (first fit tries atlas 0 first)
933        let allocation4 = manager.try_allocate(20, 20).unwrap();
934        assert_eq!(allocation4.atlas_id.as_u32(), 0);
935    }
936
937    #[test]
938    fn test_best_fit_allocation_strategy() {
939        let mut manager = MultiAtlasManager::new(AtlasConfig {
940            initial_atlas_count: 3,
941            max_atlases: 3,
942            atlas_size: (256, 256),
943            allocation_strategy: AllocationStrategy::BestFit,
944            auto_grow: false,
945        });
946
947        // All atlases start empty, so first allocation goes to atlas 0 (first available)
948        let allocation0 = manager.try_allocate(150, 150).unwrap();
949        assert_eq!(allocation0.atlas_id.as_u32(), 0);
950
951        // Second allocation should also go to atlas 0 since it still has the least remaining space
952        // that can fit the image (all atlases have same remaining space, so it picks the first)
953        let allocation1 = manager.try_allocate(100, 100).unwrap();
954        assert_eq!(allocation1.atlas_id.as_u32(), 0);
955
956        // Now atlas 0 has less remaining space than atlases 1 and 2
957        // For a small allocation, it should still go to atlas 0 (best fit - least remaining space)
958        let allocation2 = manager.try_allocate(100, 100).unwrap();
959        assert_eq!(allocation2.atlas_id.as_u32(), 0);
960
961        // Now try to allocate something very large that won't fit in atlas 0's remaining space
962        // This should force it to go to atlas 1 (which has the most remaining space)
963        let allocation3 = manager.try_allocate(200, 200).unwrap();
964        assert_eq!(allocation3.atlas_id.as_u32(), 1);
965
966        // Now atlas 1 has less remaining space
967        // A small allocation should go to atlas 0 as it can
968        let allocation4 = manager.try_allocate(80, 80).unwrap();
969        assert_eq!(allocation4.atlas_id.as_u32(), 0);
970
971        // Now atlas 1 has less remaining space but it can't fit the allocation
972        // It should go to atlas 2 (best fit - least remaining space)
973        let allocation5 = manager.try_allocate(80, 80).unwrap();
974        assert_eq!(allocation5.atlas_id.as_u32(), 2);
975    }
976
977    #[test]
978    fn test_least_used_allocation_strategy() {
979        let mut manager = MultiAtlasManager::new(AtlasConfig {
980            initial_atlas_count: 3,
981            max_atlases: 3,
982            atlas_size: (256, 256),
983            allocation_strategy: AllocationStrategy::LeastUsed,
984            auto_grow: false,
985        });
986
987        // First allocation goes to atlas 0 (all atlases have 0% usage, picks first)
988        let allocation0 = manager.try_allocate(100, 100).unwrap();
989        assert_eq!(allocation0.atlas_id.as_u32(), 0);
990
991        // Second allocation should go to atlas 1 (least used among remaining)
992        let allocation1 = manager.try_allocate(50, 50).unwrap();
993        assert_eq!(allocation1.atlas_id.as_u32(), 1);
994
995        // Third allocation should go to atlas 2 (least used)
996        let allocation2 = manager.try_allocate(30, 30).unwrap();
997        assert_eq!(allocation2.atlas_id.as_u32(), 2);
998
999        // Fourth allocation should go to atlas 2 again (still least used)
1000        let allocation3 = manager.try_allocate(30, 30).unwrap();
1001        assert_eq!(allocation3.atlas_id.as_u32(), 2);
1002    }
1003
1004    #[test]
1005    fn test_round_robin_allocation_strategy() {
1006        let mut manager = MultiAtlasManager::new(AtlasConfig {
1007            initial_atlas_count: 3,
1008            max_atlases: 3,
1009            atlas_size: (256, 256),
1010            allocation_strategy: AllocationStrategy::RoundRobin,
1011            auto_grow: false,
1012        });
1013
1014        // Allocations should cycle through atlases in order
1015        let allocation0 = manager.try_allocate(50, 50).unwrap();
1016        assert_eq!(allocation0.atlas_id.as_u32(), 0);
1017
1018        let allocation1 = manager.try_allocate(50, 50).unwrap();
1019        assert_eq!(allocation1.atlas_id.as_u32(), 1);
1020
1021        let allocation2 = manager.try_allocate(50, 50).unwrap();
1022        assert_eq!(allocation2.atlas_id.as_u32(), 2);
1023
1024        // Should wrap back to atlas 0
1025        let allocation3 = manager.try_allocate(50, 50).unwrap();
1026        assert_eq!(allocation3.atlas_id.as_u32(), 0);
1027
1028        // Continue the cycle
1029        let allocation4 = manager.try_allocate(50, 50).unwrap();
1030        assert_eq!(allocation4.atlas_id.as_u32(), 1);
1031    }
1032
1033    #[test]
1034    fn test_auto_grow() {
1035        let mut manager = MultiAtlasManager::new(AtlasConfig {
1036            initial_atlas_count: 1,
1037            max_atlases: 3,
1038            atlas_size: (256, 256),
1039            allocation_strategy: AllocationStrategy::FirstFit,
1040            auto_grow: true,
1041        });
1042
1043        let allocation0 = manager.try_allocate(256, 256).unwrap();
1044        assert_eq!(allocation0.atlas_id.as_u32(), 0);
1045
1046        let allocation1 = manager.try_allocate(256, 256).unwrap();
1047        assert_eq!(allocation1.atlas_id.as_u32(), 1);
1048
1049        let allocation2 = manager.try_allocate(256, 256).unwrap();
1050        assert_eq!(allocation2.atlas_id.as_u32(), 2);
1051    }
1052
1053    fn test_allocate_excluding_with_strategy(strategy: AllocationStrategy) {
1054        let mut manager = MultiAtlasManager::new(AtlasConfig {
1055            initial_atlas_count: 3,
1056            max_atlases: 3,
1057            atlas_size: (256, 256),
1058            allocation_strategy: strategy,
1059            auto_grow: false,
1060        });
1061
1062        let allocation0 = manager.try_allocate(100, 100).unwrap();
1063        let first_atlas = allocation0.atlas_id;
1064        let allocation1 = manager
1065            .try_allocate_excluding(256, 256, Some(first_atlas))
1066            .unwrap();
1067        assert_ne!(allocation1.atlas_id, first_atlas);
1068
1069        let second_atlas = allocation1.atlas_id;
1070        let allocation2 = manager
1071            .try_allocate_excluding(100, 100, Some(second_atlas))
1072            .unwrap();
1073        assert_ne!(allocation2.atlas_id, second_atlas);
1074
1075        let allocation3 = manager
1076            .try_allocate_excluding(100, 100, Some(first_atlas))
1077            .unwrap();
1078        assert_ne!(allocation3.atlas_id, first_atlas);
1079    }
1080
1081    #[test]
1082    fn test_allocate_excluding_first_fit() {
1083        test_allocate_excluding_with_strategy(AllocationStrategy::FirstFit);
1084    }
1085
1086    #[test]
1087    fn test_allocate_excluding_best_fit() {
1088        test_allocate_excluding_with_strategy(AllocationStrategy::BestFit);
1089    }
1090
1091    #[test]
1092    fn test_allocate_excluding_least_used() {
1093        test_allocate_excluding_with_strategy(AllocationStrategy::LeastUsed);
1094    }
1095
1096    #[test]
1097    fn test_allocate_excluding_round_robin() {
1098        test_allocate_excluding_with_strategy(AllocationStrategy::RoundRobin);
1099    }
1100}