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