Skip to main content

euv_engine/sprite/
impl.rs

1use super::*;
2
3/// Implements frame extraction, rendering, and animation creation for `SpriteSheet`.
4impl SpriteSheet {
5    /// Creates a new sprite sheet from an image element and uniform frame dimensions.
6    ///
7    /// Computes the grid columns and rows from the image dimensions and frame size.
8    ///
9    /// # Arguments
10    ///
11    /// - `HtmlImageElement` - The sprite sheet image.
12    /// - `f64` - The width of each frame.
13    /// - `f64` - The height of each frame.
14    ///
15    /// # Returns
16    ///
17    /// - `SpriteSheet` - The new sprite sheet.
18    pub fn from_image(image: HtmlImageElement, frame_width: f64, frame_height: f64) -> SpriteSheet {
19        let total_width: f64 = image.width() as f64;
20        let total_height: f64 = image.height() as f64;
21        let columns: u32 = (total_width / frame_width).max(1.0) as u32;
22        let rows: u32 = (total_height / frame_height).max(1.0) as u32;
23        SpriteSheet::new(image, frame_width, frame_height, columns, rows)
24    }
25
26    /// Returns the source rectangle for the frame at the given grid index.
27    ///
28    /// # Arguments
29    ///
30    /// - `u32` - The frame index (left-to-right, top-to-bottom).
31    ///
32    /// # Returns
33    ///
34    /// - `Rect` - The source rectangle.
35    pub fn frame_source(&self, index: u32) -> Rect {
36        let column: u32 = index % self.get_columns();
37        let row: u32 = index / self.get_columns();
38        Rect::new(
39            column as f64 * self.get_frame_width(),
40            row as f64 * self.get_frame_height(),
41            self.get_frame_width(),
42            self.get_frame_height(),
43        )
44    }
45
46    /// Creates a frame at the given grid index with a default duration.
47    ///
48    /// # Arguments
49    ///
50    /// - `u32` - The frame index.
51    ///
52    /// # Returns
53    ///
54    /// - `SpriteFrame` - The new frame.
55    pub fn frame(&self, index: u32) -> SpriteFrame {
56        SpriteFrame::new(self.frame_source(index), SPRITE_DEFAULT_FRAME_DURATION)
57    }
58
59    /// Creates an animation from a range of frame indices.
60    ///
61    /// # Arguments
62    ///
63    /// - `&str` - The animation name.
64    /// - `u32` - The starting frame index (inclusive).
65    /// - `u32` - The ending frame index (exclusive).
66    /// - `AnimationMode` - The playback mode.
67    ///
68    /// # Returns
69    ///
70    /// - `SpriteAnimation` - The new animation.
71    pub fn animation(
72        &self,
73        name: &str,
74        start: u32,
75        end: u32,
76        mode: AnimationMode,
77    ) -> SpriteAnimation {
78        let frames: Vec<SpriteFrame> = (start..end).map(|index: u32| self.frame(index)).collect();
79        SpriteAnimation::new(name.to_string(), frames, mode)
80    }
81
82    /// Draws a static (non-animated) sprite frame onto the canvas.
83    ///
84    /// # Arguments
85    ///
86    /// - `&CanvasRenderingContext2d` - The canvas context.
87    /// - `u32` - The frame index to draw.
88    /// - `&Transform2D` - The world-space transform to apply.
89    pub fn draw_frame(
90        &self,
91        context: &CanvasRenderingContext2d,
92        frame_index: u32,
93        transform: &Transform2D,
94    ) {
95        let source: Rect = self.frame_source(frame_index);
96        // Compose the TRS matrix in Rust and apply it with a single `set_transform`
97        // instead of save/translate/rotate/scale/restore (5 FFI calls + a canvas
98        // state-stack push/pop per sprite). Scale signs handle flipping.
99        let rotation: f64 = transform.get_rotation();
100        let cos: f64 = rotation.cos();
101        let sin: f64 = rotation.sin();
102        let scale_x: f64 = transform.get_scale().get_x();
103        let scale_y: f64 = transform.get_scale().get_y();
104        let _: Result<(), JsValue> = context.set_transform(
105            cos * scale_x,
106            sin * scale_x,
107            -sin * scale_y,
108            cos * scale_y,
109            transform.get_position().get_x(),
110            transform.get_position().get_y(),
111        );
112        let _: Result<(), JsValue> = context
113            .draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
114                self.get_image(),
115                source.get_x(),
116                source.get_y(),
117                source.get_width(),
118                source.get_height(),
119                -source.get_width() * 0.5,
120                -source.get_height() * 0.5,
121                source.get_width(),
122                source.get_height(),
123            );
124        let _: Result<(), JsValue> = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
125    }
126}
127
128/// Implements playback control for `Animator`.
129impl Animator {
130    /// Creates a new idle animator with no animation.
131    ///
132    /// # Returns
133    ///
134    /// - `Animator` - The new animator.
135    pub fn create() -> Animator {
136        Animator::new(AnimationState::Paused, 1)
137    }
138
139    /// Plays the given animation from the beginning.
140    ///
141    /// # Arguments
142    ///
143    /// - `SpriteAnimation` - The animation to play.
144    pub fn play(&mut self, animation: SpriteAnimation) {
145        self.set_current_animation(Some(animation));
146        self.set_current_frame_index(0);
147        self.set_elapsed_time(0.0);
148        self.set_state(AnimationState::Playing);
149        self.set_direction(1);
150    }
151
152    /// Pauses the current animation.
153    pub fn pause(&mut self) {
154        if self.get_state() == AnimationState::Playing {
155            self.set_state(AnimationState::Paused);
156        }
157    }
158
159    /// Resumes the current animation from where it was paused.
160    pub fn resume(&mut self) {
161        if self.get_state() == AnimationState::Paused {
162            self.set_state(AnimationState::Playing);
163        }
164    }
165
166    /// Stops the current animation and resets to the first frame.
167    pub fn stop(&mut self) {
168        self.set_current_frame_index(0);
169        self.set_elapsed_time(0.0);
170        self.set_state(AnimationState::Paused);
171        self.set_direction(1);
172    }
173
174    /// Advances the animation by the given delta time.
175    ///
176    /// # Arguments
177    ///
178    /// - `f64` - The time elapsed since the last update, in seconds.
179    pub fn update(&mut self, delta_time: f64) {
180        if self.get_state() != AnimationState::Playing {
181            return;
182        }
183        let current_frame_index: usize = self.get_current_frame_index();
184        let (frame_count, current_duration, mode) = {
185            let Some(animation) = self.get_mut_current_animation().as_ref() else {
186                return;
187            };
188            if animation.get_frames().is_empty() {
189                return;
190            }
191            (
192                animation.get_frames().len(),
193                animation.get_frames()[current_frame_index].get_duration(),
194                animation.get_mode(),
195            )
196        };
197        *self.get_mut_elapsed_time() += delta_time;
198        if self.get_elapsed_time() < current_duration {
199            return;
200        }
201        self.set_elapsed_time(0.0);
202        self.advance_frame(frame_count, mode);
203    }
204
205    /// Returns the source rectangle of the current frame, or `None` if no animation is playing.
206    ///
207    /// # Returns
208    ///
209    /// - `Option<Rect>` - The current frame's source rectangle.
210    pub fn current_frame_source(&self) -> Option<Rect> {
211        let animation: Option<SpriteAnimation> = self.get_current_animation();
212        let animation: &SpriteAnimation = animation.as_ref()?;
213        let frame: &SpriteFrame = animation.get_frames().get(self.get_current_frame_index())?;
214        Some(frame.get_source())
215    }
216
217    /// Advances to the next frame according to the animation mode.
218    ///
219    /// # Arguments
220    ///
221    /// - `&SpriteAnimation` - The current animation.
222    fn advance_frame(&mut self, frame_count: usize, mode: AnimationMode) {
223        match mode {
224            AnimationMode::Loop => {
225                self.set_current_frame_index((self.get_current_frame_index() + 1) % frame_count);
226            }
227            AnimationMode::Once => {
228                if self.get_current_frame_index() + 1 < frame_count {
229                    *self.get_mut_current_frame_index() += 1;
230                } else {
231                    self.set_state(AnimationState::Finished);
232                }
233            }
234            AnimationMode::PingPong => {
235                if self.get_direction() > 0 {
236                    if self.get_current_frame_index() + 1 < frame_count {
237                        *self.get_mut_current_frame_index() += 1;
238                    } else {
239                        self.set_direction(-1);
240                        if self.get_current_frame_index() > 0 {
241                            *self.get_mut_current_frame_index() -= 1;
242                        }
243                    }
244                } else if self.get_current_frame_index() > 0 {
245                    *self.get_mut_current_frame_index() -= 1;
246                } else {
247                    self.set_direction(1);
248                    if self.get_current_frame_index() + 1 < frame_count {
249                        *self.get_mut_current_frame_index() += 1;
250                    }
251                }
252            }
253        }
254    }
255}
256
257/// Forwards `Animator::update` through the [`Updatable`] trait so that
258/// collections of heterogeneous updateable objects (entities, animators,
259/// physics worlds, scene managers) can be driven by a single scheduler loop.
260///
261/// The inherent [`Animator::update`] method is the canonical implementation;
262/// this impl exists purely for trait dispatch. The inherent call resolves
263/// first when both are in scope, so there is no recursion.
264impl Updatable for Animator {
265    /// Advances the simulation by `delta_time` seconds.
266    ///
267    /// # Arguments
268    ///
269    /// - `f64` - Seconds elapsed since the previous update.
270    fn update(&mut self, delta_time: f64) {
271        Animator::update(self, delta_time);
272    }
273}
274
275/// Implements animated sprite rendering for `Animator`.
276impl Animator {
277    /// Draws the current frame of this animator onto the canvas.
278    ///
279    /// # Arguments
280    ///
281    /// - `&CanvasRenderingContext2d` - The canvas context.
282    /// - `&SpriteSheet` - The sprite sheet containing the image.
283    /// - `&Transform2D` - The world-space transform to apply.
284    pub fn draw(
285        &self,
286        context: &CanvasRenderingContext2d,
287        sheet: &SpriteSheet,
288        transform: &Transform2D,
289    ) {
290        let Some(source) = self.current_frame_source() else {
291            return;
292        };
293        // Compose the TRS matrix in Rust (flip signs folded into scale) and apply
294        // it with a single `set_transform` instead of save/translate/rotate/scale/restore.
295        let rotation: f64 = transform.get_rotation();
296        let cos: f64 = rotation.cos();
297        let sin: f64 = rotation.sin();
298        let scale_x: f64 = if self.get_flip_x() {
299            -transform.get_scale().get_x()
300        } else {
301            transform.get_scale().get_x()
302        };
303        let scale_y: f64 = if self.get_flip_y() {
304            -transform.get_scale().get_y()
305        } else {
306            transform.get_scale().get_y()
307        };
308        let _: Result<(), JsValue> = context.set_transform(
309            cos * scale_x,
310            sin * scale_x,
311            -sin * scale_y,
312            cos * scale_y,
313            transform.get_position().get_x(),
314            transform.get_position().get_y(),
315        );
316        let _: Result<(), JsValue> = context
317            .draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
318                sheet.get_image(),
319                source.get_x(),
320                source.get_y(),
321                source.get_width(),
322                source.get_height(),
323                -source.get_width() * 0.5,
324                -source.get_height() * 0.5,
325                source.get_width(),
326                source.get_height(),
327            );
328        let _: Result<(), JsValue> = context.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0);
329    }
330}
331
332/// Implements `Default` for `Animator` as a new idle animator.
333impl Default for Animator {
334    /// Constructs a default [`Animator`] value.
335    ///
336    /// # Returns
337    ///
338    /// - `Animator` - A default-constructed instance with the documented initial state.
339    fn default() -> Animator {
340        Animator::create()
341    }
342}
343
344/// Implements named access over the three-by-three nine-slice grid.
345///
346/// The index constants live in this module's `const.rs`; each accessor
347/// maps a grid position to a self-documenting name so call sites and
348/// assert messages read as geometry rather than as `(row, column)` pairs.
349impl NineSliceRects {
350    /// Returns the top-left corner sub-rectangle.
351    ///
352    /// # Returns
353    ///
354    /// - `Rect` - The top-left corner patch.
355    pub fn get_top_left(&self) -> Rect {
356        self.get_grid()[NINE_SLICE_ROW_TOP][NINE_SLICE_COL_LEFT]
357    }
358
359    /// Returns the top edge sub-rectangle.
360    ///
361    /// # Returns
362    ///
363    /// - `Rect` - The top edge patch.
364    pub fn get_top(&self) -> Rect {
365        self.get_grid()[NINE_SLICE_ROW_TOP][NINE_SLICE_COL_CENTER]
366    }
367
368    /// Returns the top-right corner sub-rectangle.
369    ///
370    /// # Returns
371    ///
372    /// - `Rect` - The top-right corner patch.
373    pub fn get_top_right(&self) -> Rect {
374        self.get_grid()[NINE_SLICE_ROW_TOP][NINE_SLICE_COL_RIGHT]
375    }
376
377    /// Returns the left edge sub-rectangle.
378    ///
379    /// # Returns
380    ///
381    /// - `Rect` - The left edge patch.
382    pub fn get_left(&self) -> Rect {
383        self.get_grid()[NINE_SLICE_ROW_MIDDLE][NINE_SLICE_COL_LEFT]
384    }
385
386    /// Returns the stretchable center sub-rectangle.
387    ///
388    /// # Returns
389    ///
390    /// - `Rect` - The center patch.
391    pub fn get_center(&self) -> Rect {
392        self.get_grid()[NINE_SLICE_ROW_MIDDLE][NINE_SLICE_COL_CENTER]
393    }
394
395    /// Returns the right edge sub-rectangle.
396    ///
397    /// # Returns
398    ///
399    /// - `Rect` - The right edge patch.
400    pub fn get_right(&self) -> Rect {
401        self.get_grid()[NINE_SLICE_ROW_MIDDLE][NINE_SLICE_COL_RIGHT]
402    }
403
404    /// Returns the bottom-left corner sub-rectangle.
405    ///
406    /// # Returns
407    ///
408    /// - `Rect` - The bottom-left corner patch.
409    pub fn get_bottom_left(&self) -> Rect {
410        self.get_grid()[NINE_SLICE_ROW_BOTTOM][NINE_SLICE_COL_LEFT]
411    }
412
413    /// Returns the bottom edge sub-rectangle.
414    ///
415    /// # Returns
416    ///
417    /// - `Rect` - The bottom edge patch.
418    pub fn get_bottom(&self) -> Rect {
419        self.get_grid()[NINE_SLICE_ROW_BOTTOM][NINE_SLICE_COL_CENTER]
420    }
421
422    /// Returns the bottom-right corner sub-rectangle.
423    ///
424    /// # Returns
425    ///
426    /// - `Rect` - The bottom-right corner patch.
427    pub fn get_bottom_right(&self) -> Rect {
428        self.get_grid()[NINE_SLICE_ROW_BOTTOM][NINE_SLICE_COL_RIGHT]
429    }
430
431    /// Returns the nine patches as a flat slice in reading order.
432    ///
433    /// # Returns
434    ///
435    /// - `Vec<Rect>` - The nine patches, top row first then middle then bottom.
436    pub fn to_vec(&self) -> Vec<Rect> {
437        let grid: [[Rect; 3]; 3] = self.get_grid();
438        vec![
439            grid[NINE_SLICE_ROW_TOP][NINE_SLICE_COL_LEFT],
440            grid[NINE_SLICE_ROW_TOP][NINE_SLICE_COL_CENTER],
441            grid[NINE_SLICE_ROW_TOP][NINE_SLICE_COL_RIGHT],
442            grid[NINE_SLICE_ROW_MIDDLE][NINE_SLICE_COL_LEFT],
443            grid[NINE_SLICE_ROW_MIDDLE][NINE_SLICE_COL_CENTER],
444            grid[NINE_SLICE_ROW_MIDDLE][NINE_SLICE_COL_RIGHT],
445            grid[NINE_SLICE_ROW_BOTTOM][NINE_SLICE_COL_LEFT],
446            grid[NINE_SLICE_ROW_BOTTOM][NINE_SLICE_COL_CENTER],
447            grid[NINE_SLICE_ROW_BOTTOM][NINE_SLICE_COL_RIGHT],
448        ]
449    }
450}
451
452/// Implements the nine-slice geometry math for `NineSliceInsets`.
453///
454/// All methods here are pure functions of the insets and the rectangle
455/// being split: no canvas context, no image handle, no DOM. That is what
456/// makes the split verifiable on the host, and it is the same math
457/// [`NineSlice::draw_into`] and [`NineSlice::record`] reuse.
458impl NineSliceInsets {
459    /// Splits a source rectangle into its nine sub-rectangles.
460    ///
461    /// The insets are clamped against the source size so the nine results
462    /// always tile the source exactly: the two horizontal insets share the
463    /// available width and the two vertical insets share the available
464    /// height, with the center taking whatever remains.
465    ///
466    /// # Arguments
467    ///
468    /// - `Rect` - The full source rectangle to split.
469    ///
470    /// # Returns
471    ///
472    /// - `NineSliceRects` - The nine source sub-rectangles in reading order.
473    pub fn source_rects(&self, source: Rect) -> NineSliceRects {
474        let width: f64 = Numeric::clamp(source.get_width(), 0.0, f64::MAX);
475        let height: f64 = Numeric::clamp(source.get_height(), 0.0, f64::MAX);
476        let left: f64 = Numeric::clamp(self.get_left(), 0.0, width);
477        let right: f64 = Numeric::clamp(self.get_right(), 0.0, width - left);
478        let top: f64 = Numeric::clamp(self.get_top(), 0.0, height);
479        let bottom: f64 = Numeric::clamp(self.get_bottom(), 0.0, height - top);
480        let center_x: f64 = source.get_x() + left;
481        let center_y: f64 = source.get_y() + top;
482        let center_width: f64 = width - left - right;
483        let center_height: f64 = height - top - bottom;
484        let right_x: f64 = center_x + center_width;
485        let bottom_y: f64 = center_y + center_height;
486        NineSliceRects::new([
487            [
488                Rect::new(source.get_x(), source.get_y(), left, top),
489                Rect::new(center_x, source.get_y(), center_width, top),
490                Rect::new(right_x, source.get_y(), right, top),
491            ],
492            [
493                Rect::new(source.get_x(), center_y, left, center_height),
494                Rect::new(center_x, center_y, center_width, center_height),
495                Rect::new(right_x, center_y, right, center_height),
496            ],
497            [
498                Rect::new(source.get_x(), bottom_y, left, bottom),
499                Rect::new(center_x, bottom_y, center_width, bottom),
500                Rect::new(right_x, bottom_y, right, bottom),
501            ],
502        ])
503    }
504
505    /// Splits a destination rectangle into its nine sub-rectangles.
506    ///
507    /// Corners and edges keep their natural pixel size taken from the
508    /// insets, and the center absorbs all remaining destination space, so
509    /// the nine results tile the destination exactly. When the
510    /// destination is smaller than the combined borders the center
511    /// collapses to zero width and/or height instead of going negative.
512    ///
513    /// # Arguments
514    ///
515    /// - `Rect` - The full destination rectangle to split.
516    ///
517    /// # Returns
518    ///
519    /// - `NineSliceRects` - The nine destination sub-rectangles in reading order.
520    pub fn dest_rects(&self, dest: Rect) -> NineSliceRects {
521        let left: f64 = Numeric::clamp(self.get_left(), 0.0, f64::MAX);
522        let right: f64 = Numeric::clamp(self.get_right(), 0.0, f64::MAX);
523        let top: f64 = Numeric::clamp(self.get_top(), 0.0, f64::MAX);
524        let bottom: f64 = Numeric::clamp(self.get_bottom(), 0.0, f64::MAX);
525        let left_edge: f64 = Numeric::clamp(left, 0.0, dest.get_width());
526        let right_edge: f64 = Numeric::clamp(right, 0.0, dest.get_width() - left_edge);
527        let top_edge: f64 = Numeric::clamp(top, 0.0, dest.get_height());
528        let bottom_edge: f64 = Numeric::clamp(bottom, 0.0, dest.get_height() - top_edge);
529        let center_x: f64 = dest.get_x() + left_edge;
530        let center_y: f64 = dest.get_y() + top_edge;
531        let center_width: f64 = dest.get_width() - left_edge - right_edge;
532        let center_height: f64 = dest.get_height() - top_edge - bottom_edge;
533        let right_x: f64 = center_x + center_width;
534        let bottom_y: f64 = center_y + center_height;
535        NineSliceRects::new([
536            [
537                Rect::new(dest.get_x(), dest.get_y(), left_edge, top_edge),
538                Rect::new(center_x, dest.get_y(), center_width, top_edge),
539                Rect::new(right_x, dest.get_y(), right_edge, top_edge),
540            ],
541            [
542                Rect::new(dest.get_x(), center_y, left_edge, center_height),
543                Rect::new(center_x, center_y, center_width, center_height),
544                Rect::new(right_x, center_y, right_edge, center_height),
545            ],
546            [
547                Rect::new(dest.get_x(), bottom_y, left_edge, bottom_edge),
548                Rect::new(center_x, bottom_y, center_width, bottom_edge),
549                Rect::new(right_x, bottom_y, right_edge, bottom_edge),
550            ],
551        ])
552    }
553}
554
555/// Implements image-backed nine-slice drawing and deferred recording.
556impl NineSlice {
557    /// Creates a nine-slice from an image and uniform border insets.
558    ///
559    /// # Arguments
560    ///
561    /// - `HtmlImageElement` - The source image containing the nine patches.
562    /// - `f64` - The border inset applied to all four edges, in source pixels.
563    ///
564    /// # Returns
565    ///
566    /// - `NineSlice` - The new nine-slice.
567    pub fn from_image(image: HtmlImageElement, border: f64) -> NineSlice {
568        NineSlice::new(image, NineSliceInsets::new(border, border, border, border))
569    }
570
571    /// Returns the nine source sub-rectangles for the whole source image.
572    ///
573    /// # Returns
574    ///
575    /// - `NineSliceRects` - The nine source sub-rectangles in reading order.
576    pub fn source_rects(&self) -> NineSliceRects {
577        let image: &HtmlImageElement = &self.get_image();
578        let source: Rect = Rect::new(0.0, 0.0, image.width() as f64, image.height() as f64);
579        self.get_insets().source_rects(source)
580    }
581
582    /// Returns the nine destination sub-rectangles for a destination rect.
583    ///
584    /// # Arguments
585    ///
586    /// - `Rect` - The destination rectangle to split.
587    ///
588    /// # Returns
589    ///
590    /// - `NineSliceRects` - The nine destination sub-rectangles in reading order.
591    pub fn dest_rects(&self, dest: Rect) -> NineSliceRects {
592        self.get_insets().dest_rects(dest)
593    }
594
595    /// Draws the nine-slice into a destination rectangle immediately.
596    ///
597    /// Issues nine `drawImage` calls against the canvas context, pairing
598    /// each source patch with its destination sub-rectangle. Uses the
599    /// canvas transform already in effect, so the caller controls world
600    /// positioning, rotation and scale.
601    ///
602    /// # Arguments
603    ///
604    /// - `&CanvasRenderingContext2d` - The canvas context.
605    /// - `Rect` - The destination rectangle in current canvas space.
606    pub fn draw_into(&self, context: &CanvasRenderingContext2d, dest: Rect) {
607        let sources: Vec<Rect> = self.source_rects().to_vec();
608        let dests: Vec<Rect> = self.dest_rects(dest).to_vec();
609        let image: &HtmlImageElement = &self.get_image();
610        for index in 0..sources.len() {
611            let source: Rect = sources[index];
612            let target: Rect = dests[index];
613            let _: Result<(), JsValue> = context
614                .draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
615                    image,
616                    source.get_x(),
617                    source.get_y(),
618                    source.get_width(),
619                    source.get_height(),
620                    target.get_x(),
621                    target.get_y(),
622                    target.get_width(),
623                    target.get_height(),
624                );
625        }
626    }
627
628    /// Records the nine-slice into a deferred draw list.
629    ///
630    /// The command carries the image plus the insets rather than the nine
631    /// resolved sub-rectangles, so the split is recomputed at replay time
632    /// against whatever destination size the command was recorded with.
633    ///
634    /// # Arguments
635    ///
636    /// - `&mut DrawList` - The draw list to record into.
637    /// - `Vector2D` - The destination top-left position in world space.
638    /// - `f64` - The destination width in pixels.
639    /// - `f64` - The destination height in pixels.
640    pub fn record(
641        &self,
642        list: &mut DrawList,
643        dest_position: Vector2D,
644        dest_width: f64,
645        dest_height: f64,
646    ) {
647        list.get_mut_commands().push(DrawCommand::DrawNineSlice {
648            image: self.get_image(),
649            insets: self.get_insets(),
650            dest_position,
651            dest_width,
652            dest_height,
653        });
654    }
655}
656
657/// Implements the pure name-to-rectangle index for `AtlasRegions`.
658impl AtlasRegions {
659    /// Inserts or replaces the source rectangle stored under a name.
660    ///
661    /// # Arguments
662    ///
663    /// - `&str` - The sprite name.
664    /// - `Rect` - The source rectangle in atlas pixels.
665    pub fn insert(&mut self, name: &str, region: Rect) {
666        self.get_mut_regions().insert(name.to_string(), region);
667    }
668
669    /// Returns the source rectangle stored under a name.
670    ///
671    /// # Arguments
672    ///
673    /// - `&str` - The sprite name.
674    ///
675    /// # Returns
676    ///
677    /// - `Option<Rect>` - The source rectangle, or `None` if the name is unknown.
678    pub fn get(&self, name: &str) -> Option<Rect> {
679        self.get_regions().get(name).copied()
680    }
681
682    /// Returns whether a name is present in the index.
683    ///
684    /// # Arguments
685    ///
686    /// - `&str` - The sprite name.
687    ///
688    /// # Returns
689    ///
690    /// - `bool` - `true` when the name has a stored rectangle.
691    pub fn contains(&self, name: &str) -> bool {
692        self.get_regions().contains_key(name)
693    }
694
695    /// Returns the number of named regions in the index.
696    ///
697    /// # Returns
698    ///
699    /// - `usize` - The region count.
700    pub fn len(&self) -> usize {
701        self.get_regions().len()
702    }
703
704    /// Returns whether the index holds no regions.
705    ///
706    /// # Returns
707    ///
708    /// - `bool` - `true` when no region has been inserted.
709    pub fn is_empty(&self) -> bool {
710        self.get_regions().is_empty()
711    }
712
713    /// Returns the stored names in unspecified order.
714    ///
715    /// # Returns
716    ///
717    /// - `Vec<String>` - Every stored sprite name.
718    pub fn names(&self) -> Vec<String> {
719        self.get_regions().keys().cloned().collect()
720    }
721}
722
723/// Implements image-backed drawing, UV conversion, and recording for `SpriteAtlas`.
724impl SpriteAtlas {
725    /// Creates an empty atlas backed by the given image.
726    ///
727    /// # Arguments
728    ///
729    /// - `HtmlImageElement` - The shared image holding every packed sprite.
730    ///
731    /// # Returns
732    ///
733    /// - `SpriteAtlas` - The new empty atlas.
734    pub fn create(image: HtmlImageElement) -> SpriteAtlas {
735        SpriteAtlas::new(image, AtlasRegions::default())
736    }
737
738    /// Inserts or replaces the source rectangle stored under a name.
739    ///
740    /// # Arguments
741    ///
742    /// - `&str` - The sprite name.
743    /// - `Rect` - The source rectangle in atlas pixels.
744    pub fn insert(&mut self, name: &str, region: Rect) {
745        self.get_mut_regions().insert(name, region);
746    }
747
748    /// Returns the source rectangle stored under a name.
749    ///
750    /// # Arguments
751    ///
752    /// - `&str` - The sprite name.
753    ///
754    /// # Returns
755    ///
756    /// - `Option<Rect>` - The source rectangle, or `None` if the name is unknown.
757    pub fn get(&self, name: &str) -> Option<Rect> {
758        self.get_regions().get(name)
759    }
760
761    /// Returns whether a name is present in the atlas.
762    ///
763    /// # Arguments
764    ///
765    /// - `&str` - The sprite name.
766    ///
767    /// # Returns
768    ///
769    /// - `bool` - `true` when the name has a stored rectangle.
770    pub fn contains(&self, name: &str) -> bool {
771        self.get_regions().contains(name)
772    }
773
774    /// Returns the number of named regions in the atlas.
775    ///
776    /// # Returns
777    ///
778    /// - `usize` - The region count.
779    pub fn len(&self) -> usize {
780        self.get_regions().len()
781    }
782
783    /// Returns whether the atlas holds no regions.
784    ///
785    /// # Returns
786    ///
787    /// - `bool` - `true` when no region has been inserted.
788    pub fn is_empty(&self) -> bool {
789        self.get_regions().is_empty()
790    }
791
792    /// Returns the normalized texture coordinates for one atlas region.
793    ///
794    /// Normalizes against the image's intrinsic (`naturalWidth` /
795    /// `naturalHeight`) size, which is the atlas's true texture size
796    /// regardless of any CSS size applied to the element. A zero-sized
797    /// atlas — not yet loaded, or decoded with no intrinsic size — yields
798    /// an all-zero `UvRect` rather than dividing by zero, so the result is
799    /// never `NaN` or infinite.
800    ///
801    /// # Arguments
802    ///
803    /// - `Rect` - The source rectangle in atlas pixels.
804    ///
805    /// # Returns
806    ///
807    /// - `UvRect` - The normalized `(u0, v0, u1, v1)` coordinates.
808    pub fn uv(&self, region: Rect) -> UvRect {
809        let image: &HtmlImageElement = &self.get_image();
810        let width: f64 = image.natural_width() as f64;
811        let height: f64 = image.natural_height() as f64;
812        Self::normalize_uv(region, width, height)
813    }
814
815    /// Normalizes a pixel rectangle against an explicit atlas size.
816    ///
817    /// The pure half of [`SpriteAtlas::uv`], separated so the arithmetic
818    /// can be exercised without an image element. A non-positive width
819    /// or height yields an all-zero `UvRect` instead of dividing by zero.
820    ///
821    /// # Arguments
822    ///
823    /// - `Rect` - The source rectangle in atlas pixels.
824    /// - `f64` - The atlas width in pixels.
825    /// - `f64` - The atlas height in pixels.
826    ///
827    /// # Returns
828    ///
829    /// - `UvRect` - The normalized `(u0, v0, u1, v1)` coordinates.
830    pub fn normalize_uv(region: Rect, width: f64, height: f64) -> UvRect {
831        if width <= 0.0 || height <= 0.0 {
832            return UvRect::new(0.0, 0.0, 0.0, 0.0);
833        }
834        UvRect::new(
835            region.get_x() / width,
836            region.get_y() / height,
837            (region.get_x() + region.get_width()) / width,
838            (region.get_y() + region.get_height()) / height,
839        )
840    }
841
842    /// Blits one named atlas region into a destination rectangle.
843    ///
844    /// Unknown names are a no-op. Uses the canvas transform already in
845    /// effect, so the caller controls world positioning.
846    ///
847    /// # Arguments
848    ///
849    /// - `&CanvasRenderingContext2d` - The canvas context.
850    /// - `&str` - The sprite name to blit.
851    /// - `Rect` - The destination rectangle in current canvas space.
852    pub fn draw(&self, context: &CanvasRenderingContext2d, name: &str, dest: Rect) {
853        let Some(source) = self.get(name) else {
854            return;
855        };
856        let _: Result<(), JsValue> = context
857            .draw_image_with_html_image_element_and_sw_and_sh_and_dx_and_dy_and_dw_and_dh(
858                &self.get_image(),
859                source.get_x(),
860                source.get_y(),
861                source.get_width(),
862                source.get_height(),
863                dest.get_x(),
864                dest.get_y(),
865                dest.get_width(),
866                dest.get_height(),
867            );
868    }
869
870    /// Records one named atlas region into a deferred draw list.
871    ///
872    /// Unknown names are a no-op, so callers do not need to probe with
873    /// [`SpriteAtlas::get`] first.
874    ///
875    /// # Arguments
876    ///
877    /// - `&mut DrawList` - The draw list to record into.
878    /// - `&str` - The sprite name to blit.
879    /// - `Vector2D` - The destination top-left position in world space.
880    /// - `f64` - The destination width in pixels.
881    /// - `f64` - The destination height in pixels.
882    pub fn record(
883        &self,
884        list: &mut DrawList,
885        name: &str,
886        dest_position: Vector2D,
887        dest_width: f64,
888        dest_height: f64,
889    ) {
890        let Some(source) = self.get(name) else {
891            return;
892        };
893        list.get_mut_commands().push(DrawCommand::DrawAtlasRegion {
894            image: self.get_image(),
895            source,
896            dest_position,
897            dest_width,
898            dest_height,
899        });
900    }
901}