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