core_animation/
particles.rs

1//! Particle emitter builders.
2//!
3//! Particles are small images spawned continuously from an emitter. Each particle
4//! has properties like velocity, lifetime, and color. The GPU handles rendering,
5//! so thousands of particles run smoothly.
6//!
7//! ```ignore
8//! use std::f64::consts::PI;
9//! use core_animation::prelude::*;
10//!
11//! let emitter = CAEmitterLayerBuilder::new()
12//!     .position(320.0, 320.0)
13//!     .shape(EmitterShape::Point)
14//!     .particle(|p| p
15//!         .birth_rate(100.0)
16//!         .lifetime(5.0)
17//!         .velocity(80.0)
18//!         .emission_range(PI * 2.0)  // all directions
19//!         .color(Color::CYAN)
20//!         .image(ParticleImage::soft_glow(64))
21//!     )
22//!     .build();
23//! ```
24//!
25//! For simple point bursts, use [`PointBurstBuilder`] instead.
26
27use crate::color::Color;
28use objc2::rc::Retained;
29use objc2::runtime::AnyObject;
30use objc2_core_foundation::{CFRetained, CGPoint, CGRect, CGSize};
31use objc2_core_graphics::{
32    CGBitmapContextCreate, CGBitmapContextCreateImage, CGColor, CGColorSpace, CGContext, CGImage,
33    CGImageAlphaInfo,
34};
35use objc2_foundation::NSArray;
36use objc2_quartz_core::{
37    kCAEmitterLayerAdditive, kCAEmitterLayerBackToFront, kCAEmitterLayerCircle,
38    kCAEmitterLayerCuboid, kCAEmitterLayerLine, kCAEmitterLayerOldestFirst,
39    kCAEmitterLayerOldestLast, kCAEmitterLayerOutline, kCAEmitterLayerPoint, kCAEmitterLayerPoints,
40    kCAEmitterLayerRectangle, kCAEmitterLayerSphere, kCAEmitterLayerSurface,
41    kCAEmitterLayerUnordered, kCAEmitterLayerVolume, CAEmitterCell, CAEmitterLayer,
42};
43
44// ============================================================================
45// Enums
46// ============================================================================
47
48/// Shape of the emitter - where particles spawn.
49#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
50pub enum EmitterShape {
51    /// Particles spawn from a single point.
52    #[default]
53    Point,
54    /// Particles spawn along a line.
55    Line,
56    /// Particles spawn within a rectangle.
57    Rectangle,
58    /// Particles spawn on/in a circle (2D sphere).
59    Circle,
60    /// Particles spawn on/in a cuboid (3D box).
61    Cuboid,
62    /// Particles spawn on/in a sphere.
63    Sphere,
64}
65
66/// Mode determining where on the shape particles spawn.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum EmitterMode {
69    /// Particles spawn at discrete points on the shape.
70    #[default]
71    Points,
72    /// Particles spawn on the outline/edge of the shape.
73    Outline,
74    /// Particles spawn on the surface of the shape.
75    Surface,
76    /// Particles spawn throughout the volume of the shape.
77    Volume,
78}
79
80/// Render mode determining how particles are composited.
81#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
82pub enum RenderMode {
83    /// Particles rendered in no particular order.
84    #[default]
85    Unordered,
86    /// Oldest particles rendered first (behind newer ones).
87    OldestFirst,
88    /// Oldest particles rendered last (in front of newer ones).
89    OldestLast,
90    /// Particles sorted back-to-front by depth.
91    BackToFront,
92    /// Particles use additive blending (colors add together).
93    Additive,
94}
95
96/// Pre-built particle images.
97#[derive(Clone, Debug)]
98pub enum ParticleImage {
99    /// Radial gradient - white center fading to transparent.
100    SoftGlow(u32),
101    /// Solid filled circle.
102    Circle(u32),
103    /// Star shape with specified number of points.
104    Star { size: u32, points: u32 },
105    /// Elongated spark/streak shape.
106    Spark(u32),
107}
108
109impl ParticleImage {
110    /// Create a soft glow particle image (radial gradient).
111    pub fn soft_glow(size: u32) -> Self {
112        Self::SoftGlow(size)
113    }
114
115    /// Create a solid circle particle image.
116    pub fn circle(size: u32) -> Self {
117        Self::Circle(size)
118    }
119
120    /// Create a star particle image with the specified number of points.
121    ///
122    /// Common values: 4, 5, 6, or 8 points.
123    pub fn star(size: u32, points: u32) -> Self {
124        Self::Star { size, points }
125    }
126
127    /// Create an elongated spark/streak particle image.
128    ///
129    /// Good for motion trails, fire sparks, or shooting stars.
130    pub fn spark(size: u32) -> Self {
131        Self::Spark(size)
132    }
133
134    /// Generate the CGImage for this particle.
135    pub fn to_cgimage(&self) -> CFRetained<CGImage> {
136        match self {
137            Self::SoftGlow(size) => create_soft_glow_image(*size as usize),
138            Self::Circle(size) => create_circle_image(*size as usize),
139            Self::Star { size, points } => create_star_image(*size as usize, *points as usize),
140            Self::Spark(size) => create_spark_image(*size as usize),
141        }
142    }
143}
144
145// ============================================================================
146// CAEmitterCellBuilder
147// ============================================================================
148
149/// Builder for a particle type.
150///
151/// Used via `CAEmitterLayerBuilder::particle(|p| p.birth_rate(...))`.
152pub struct CAEmitterCellBuilder {
153    birth_rate: f32,
154    lifetime: f32,
155    lifetime_range: f32,
156    velocity: f64,
157    velocity_range: f64,
158    emission_longitude: f64,
159    emission_range: f64,
160    scale: f64,
161    scale_range: f64,
162    scale_speed: f64,
163    alpha_speed: f32,
164    spin: f64,
165    spin_range: f64,
166    acceleration: (f64, f64),
167    color: Option<Color>,
168    image: Option<ParticleImage>,
169}
170
171impl CAEmitterCellBuilder {
172    /// Create a new cell builder with default values.
173    pub fn new() -> Self {
174        Self {
175            birth_rate: 1.0,
176            lifetime: 1.0,
177            lifetime_range: 0.0,
178            velocity: 0.0,
179            velocity_range: 0.0,
180            emission_longitude: 0.0,
181            emission_range: 0.0,
182            scale: 1.0,
183            scale_range: 0.0,
184            scale_speed: 0.0,
185            alpha_speed: 0.0,
186            spin: 0.0,
187            spin_range: 0.0,
188            acceleration: (0.0, 0.0),
189            color: None,
190            image: None,
191        }
192    }
193
194    /// Set the number of particles spawned per second.
195    pub fn birth_rate(mut self, rate: f32) -> Self {
196        self.birth_rate = rate;
197        self
198    }
199
200    /// Set how long each particle lives (in seconds).
201    pub fn lifetime(mut self, seconds: f32) -> Self {
202        self.lifetime = seconds;
203        self
204    }
205
206    /// Set random variation in lifetime (+/- seconds).
207    pub fn lifetime_range(mut self, range: f32) -> Self {
208        self.lifetime_range = range;
209        self
210    }
211
212    /// Set initial velocity (points per second).
213    pub fn velocity(mut self, v: f64) -> Self {
214        self.velocity = v;
215        self
216    }
217
218    /// Set random variation in velocity.
219    pub fn velocity_range(mut self, range: f64) -> Self {
220        self.velocity_range = range;
221        self
222    }
223
224    /// Set the direction of emission (radians, 0 = right, PI/2 = up).
225    pub fn emission_longitude(mut self, radians: f64) -> Self {
226        self.emission_longitude = radians;
227        self
228    }
229
230    /// Set emission direction to point toward a target position.
231    ///
232    /// The `from` parameter is the emitter position.
233    pub fn emission_toward(mut self, from: (f64, f64), target: (f64, f64)) -> Self {
234        let dx = target.0 - from.0;
235        let dy = target.1 - from.1;
236        self.emission_longitude = dy.atan2(dx);
237        self
238    }
239
240    /// Set the spread of emission (radians).
241    ///
242    /// Use `PI * 2.0` for emission in all directions (360°).
243    /// Use `PI / 6.0` for a 30° spread.
244    pub fn emission_range(mut self, radians: f64) -> Self {
245        self.emission_range = radians;
246        self
247    }
248
249    /// Set the scale of particles (1.0 = original size).
250    pub fn scale(mut self, s: f64) -> Self {
251        self.scale = s;
252        self
253    }
254
255    /// Set random variation in scale.
256    pub fn scale_range(mut self, range: f64) -> Self {
257        self.scale_range = range;
258        self
259    }
260
261    /// Set rate of scale change per second.
262    pub fn scale_speed(mut self, speed: f64) -> Self {
263        self.scale_speed = speed;
264        self
265    }
266
267    /// Set rate of alpha change per second (negative = fade out).
268    pub fn alpha_speed(mut self, speed: f32) -> Self {
269        self.alpha_speed = speed;
270        self
271    }
272
273    /// Set rotation speed (radians per second).
274    pub fn spin(mut self, radians_per_sec: f64) -> Self {
275        self.spin = radians_per_sec;
276        self
277    }
278
279    /// Set random variation in spin.
280    pub fn spin_range(mut self, range: f64) -> Self {
281        self.spin_range = range;
282        self
283    }
284
285    /// Set acceleration (points per second squared).
286    ///
287    /// Use negative y for gravity effect.
288    pub fn acceleration(mut self, x: f64, y: f64) -> Self {
289        self.acceleration = (x, y);
290        self
291    }
292
293    /// Set particle color using a `Color` value.
294    ///
295    /// # Example
296    ///
297    /// ```ignore
298    /// .color(Color::RED)
299    /// .color(Color::rgb(0.3, 0.8, 1.0))
300    /// .color(Color::WHITE.with_alpha(0.5))
301    /// ```
302    pub fn color(mut self, color: impl Into<Color>) -> Self {
303        self.color = Some(color.into());
304        self
305    }
306
307    /// Set particle color (RGB, 0.0-1.0).
308    pub fn color_rgb(mut self, r: f64, g: f64, b: f64) -> Self {
309        self.color = Some(Color::rgb(r, g, b));
310        self
311    }
312
313    /// Set particle color (RGBA, 0.0-1.0).
314    pub fn color_rgba(mut self, r: f64, g: f64, b: f64, a: f64) -> Self {
315        self.color = Some(Color::rgba(r, g, b, a));
316        self
317    }
318
319    /// Set the particle image.
320    pub fn image(mut self, img: ParticleImage) -> Self {
321        self.image = Some(img);
322        self
323    }
324
325    /// Build the CAEmitterCell.
326    pub fn build(self) -> Retained<CAEmitterCell> {
327        let cell = CAEmitterCell::new();
328
329        cell.setBirthRate(self.birth_rate);
330        cell.setLifetime(self.lifetime);
331        cell.setLifetimeRange(self.lifetime_range);
332        cell.setVelocity(self.velocity);
333        cell.setVelocityRange(self.velocity_range);
334        cell.setEmissionLongitude(self.emission_longitude);
335        cell.setEmissionRange(self.emission_range);
336        cell.setScale(self.scale);
337        cell.setScaleRange(self.scale_range);
338        cell.setScaleSpeed(self.scale_speed);
339        cell.setAlphaSpeed(self.alpha_speed);
340        cell.setSpin(self.spin);
341        cell.setSpinRange(self.spin_range);
342        cell.setXAcceleration(self.acceleration.0);
343        cell.setYAcceleration(self.acceleration.1);
344
345        if let Some(color) = self.color {
346            let cgcolor: CFRetained<CGColor> = color.into();
347            cell.setColor(Some(&cgcolor));
348        }
349
350        if let Some(img) = self.image {
351            let cgimage = img.to_cgimage();
352            unsafe {
353                let image_obj: &AnyObject = std::mem::transmute(&*cgimage);
354                cell.setContents(Some(image_obj));
355            }
356        }
357
358        cell
359    }
360}
361
362impl Default for CAEmitterCellBuilder {
363    fn default() -> Self {
364        Self::new()
365    }
366}
367
368// ============================================================================
369// CAEmitterLayerBuilder
370// ============================================================================
371
372/// Builder for a particle emitter layer.
373///
374/// ```ignore
375/// let emitter = CAEmitterLayerBuilder::new()
376///     .position(320.0, 320.0)
377///     .shape(EmitterShape::Point)
378///     .particle(|p| p
379///         .birth_rate(100.0)
380///         .lifetime(5.0)
381///         .velocity(80.0)
382///         .color(Color::CYAN)
383///     )
384///     .build();
385/// ```
386pub struct CAEmitterLayerBuilder {
387    position: (f64, f64),
388    size: (f64, f64),
389    shape: EmitterShape,
390    mode: EmitterMode,
391    render_mode: RenderMode,
392    birth_rate: f32,
393    cells: Vec<Retained<CAEmitterCell>>,
394}
395
396impl CAEmitterLayerBuilder {
397    /// Create a new emitter layer builder.
398    pub fn new() -> Self {
399        Self {
400            position: (0.0, 0.0),
401            size: (0.0, 0.0),
402            shape: EmitterShape::Point,
403            mode: EmitterMode::Points,
404            render_mode: RenderMode::Unordered,
405            birth_rate: 1.0,
406            cells: Vec::new(),
407        }
408    }
409
410    /// Set the emitter position.
411    pub fn position(mut self, x: f64, y: f64) -> Self {
412        self.position = (x, y);
413        self
414    }
415
416    /// Set the emitter size (for Line, Rectangle, etc.).
417    pub fn size(mut self, width: f64, height: f64) -> Self {
418        self.size = (width, height);
419        self
420    }
421
422    /// Set the emitter shape.
423    pub fn shape(mut self, shape: EmitterShape) -> Self {
424        self.shape = shape;
425        self
426    }
427
428    /// Set the emitter mode.
429    pub fn mode(mut self, mode: EmitterMode) -> Self {
430        self.mode = mode;
431        self
432    }
433
434    /// Set the render mode.
435    pub fn render_mode(mut self, mode: RenderMode) -> Self {
436        self.render_mode = mode;
437        self
438    }
439
440    /// Set the overall birth rate multiplier.
441    ///
442    /// This multiplies the birth rate of all cells.
443    /// Use 0.0 to pause emission, 1.0 for normal rate.
444    pub fn birth_rate(mut self, rate: f32) -> Self {
445        self.birth_rate = rate;
446        self
447    }
448
449    /// Add a particle type using a closure to configure it.
450    ///
451    /// # Example
452    ///
453    /// ```ignore
454    /// .particle(|p| p
455    ///     .birth_rate(100.0)
456    ///     .lifetime(10.0)
457    ///     .velocity(100.0)
458    /// )
459    /// ```
460    pub fn particle<F>(mut self, configure: F) -> Self
461    where
462        F: FnOnce(CAEmitterCellBuilder) -> CAEmitterCellBuilder,
463    {
464        let builder = CAEmitterCellBuilder::new();
465        let configured = configure(builder);
466        self.cells.push(configured.build());
467        self
468    }
469
470    /// Add a pre-built cell directly.
471    ///
472    /// Prefer using `.particle()` for most cases.
473    pub fn cell(mut self, cell: Retained<CAEmitterCell>) -> Self {
474        self.cells.push(cell);
475        self
476    }
477
478    /// Build the CAEmitterLayer.
479    pub fn build(self) -> Retained<CAEmitterLayer> {
480        let emitter = CAEmitterLayer::new();
481
482        emitter.setEmitterPosition(CGPoint::new(self.position.0, self.position.1));
483        emitter.setEmitterSize(CGSize::new(self.size.0, self.size.1));
484        emitter.setBirthRate(self.birth_rate);
485
486        // Set shape
487        unsafe {
488            let shape = match self.shape {
489                EmitterShape::Point => kCAEmitterLayerPoint,
490                EmitterShape::Line => kCAEmitterLayerLine,
491                EmitterShape::Rectangle => kCAEmitterLayerRectangle,
492                EmitterShape::Circle => kCAEmitterLayerCircle,
493                EmitterShape::Cuboid => kCAEmitterLayerCuboid,
494                EmitterShape::Sphere => kCAEmitterLayerSphere,
495            };
496            emitter.setEmitterShape(shape);
497        }
498
499        // Set mode
500        unsafe {
501            let mode = match self.mode {
502                EmitterMode::Points => kCAEmitterLayerPoints,
503                EmitterMode::Outline => kCAEmitterLayerOutline,
504                EmitterMode::Surface => kCAEmitterLayerSurface,
505                EmitterMode::Volume => kCAEmitterLayerVolume,
506            };
507            emitter.setEmitterMode(mode);
508        }
509
510        // Set render mode
511        unsafe {
512            let render = match self.render_mode {
513                RenderMode::Unordered => kCAEmitterLayerUnordered,
514                RenderMode::OldestFirst => kCAEmitterLayerOldestFirst,
515                RenderMode::OldestLast => kCAEmitterLayerOldestLast,
516                RenderMode::BackToFront => kCAEmitterLayerBackToFront,
517                RenderMode::Additive => kCAEmitterLayerAdditive,
518            };
519            emitter.setRenderMode(render);
520        }
521
522        // Set cells
523        if !self.cells.is_empty() {
524            let cells = NSArray::from_retained_slice(&self.cells);
525            emitter.setEmitterCells(Some(&cells));
526        }
527
528        emitter
529    }
530}
531
532impl Default for CAEmitterLayerBuilder {
533    fn default() -> Self {
534        Self::new()
535    }
536}
537
538// ============================================================================
539// PointBurstBuilder (convenience builder)
540// ============================================================================
541
542/// Simpler builder for particles bursting outward from a point.
543///
544/// ```ignore
545/// let burst = PointBurstBuilder::new(320.0, 320.0)
546///     .velocity(100.0)
547///     .color(Color::ORANGE)
548///     .build();
549/// ```
550pub struct PointBurstBuilder {
551    position: (f64, f64),
552    birth_rate: f32,
553    lifetime: f32,
554    lifetime_range: f32,
555    velocity: f64,
556    velocity_range: f64,
557    scale: f64,
558    scale_range: f64,
559    scale_speed: f64,
560    alpha_speed: f32,
561    color: Option<Color>,
562    image: Option<ParticleImage>,
563    render_mode: RenderMode,
564}
565
566impl PointBurstBuilder {
567    /// Create a new point burst builder at the specified position.
568    pub fn new(x: f64, y: f64) -> Self {
569        Self {
570            position: (x, y),
571            birth_rate: 100.0,
572            lifetime: 5.0,
573            lifetime_range: 1.0,
574            velocity: 100.0,
575            velocity_range: 20.0,
576            scale: 0.1,
577            scale_range: 0.02,
578            scale_speed: 0.0,
579            alpha_speed: 0.0,
580            color: None,
581            image: None,
582            render_mode: RenderMode::Additive,
583        }
584    }
585
586    /// Set the number of particles spawned per second.
587    pub fn birth_rate(mut self, rate: f32) -> Self {
588        self.birth_rate = rate;
589        self
590    }
591
592    /// Set how long each particle lives (in seconds).
593    pub fn lifetime(mut self, seconds: f32) -> Self {
594        self.lifetime = seconds;
595        self
596    }
597
598    /// Set random variation in lifetime.
599    pub fn lifetime_range(mut self, range: f32) -> Self {
600        self.lifetime_range = range;
601        self
602    }
603
604    /// Set initial velocity (points per second).
605    pub fn velocity(mut self, v: f64) -> Self {
606        self.velocity = v;
607        self
608    }
609
610    /// Set random variation in velocity.
611    pub fn velocity_range(mut self, range: f64) -> Self {
612        self.velocity_range = range;
613        self
614    }
615
616    /// Set the scale of particles.
617    pub fn scale(mut self, s: f64) -> Self {
618        self.scale = s;
619        self
620    }
621
622    /// Set random variation in scale.
623    pub fn scale_range(mut self, range: f64) -> Self {
624        self.scale_range = range;
625        self
626    }
627
628    /// Set rate of scale change per second.
629    pub fn scale_speed(mut self, speed: f64) -> Self {
630        self.scale_speed = speed;
631        self
632    }
633
634    /// Set rate of alpha change per second (negative = fade out).
635    pub fn alpha_speed(mut self, speed: f32) -> Self {
636        self.alpha_speed = speed;
637        self
638    }
639
640    /// Set particle color using a `Color` value.
641    ///
642    /// # Example
643    ///
644    /// ```ignore
645    /// .color(Color::CYAN)
646    /// .color(Color::rgb(1.0, 0.8, 0.2))
647    /// ```
648    pub fn color(mut self, color: impl Into<Color>) -> Self {
649        self.color = Some(color.into());
650        self
651    }
652
653    /// Set particle color (RGB, 0.0-1.0).
654    pub fn color_rgb(mut self, r: f64, g: f64, b: f64) -> Self {
655        self.color = Some(Color::rgb(r, g, b));
656        self
657    }
658
659    /// Set particle color (RGBA, 0.0-1.0).
660    pub fn color_rgba(mut self, r: f64, g: f64, b: f64, a: f64) -> Self {
661        self.color = Some(Color::rgba(r, g, b, a));
662        self
663    }
664
665    /// Set the particle image. Defaults to soft_glow(64) if not set.
666    pub fn image(mut self, img: ParticleImage) -> Self {
667        self.image = Some(img);
668        self
669    }
670
671    /// Set the render mode. Defaults to Additive.
672    pub fn render_mode(mut self, mode: RenderMode) -> Self {
673        self.render_mode = mode;
674        self
675    }
676
677    /// Build the CAEmitterLayer configured for point burst.
678    pub fn build(self) -> Retained<CAEmitterLayer> {
679        use std::f64::consts::PI;
680
681        let image = self.image.unwrap_or_else(|| ParticleImage::soft_glow(64));
682
683        CAEmitterLayerBuilder::new()
684            .position(self.position.0, self.position.1)
685            .shape(EmitterShape::Point)
686            .render_mode(self.render_mode)
687            .particle(|p| {
688                let mut builder = p
689                    .birth_rate(self.birth_rate)
690                    .lifetime(self.lifetime)
691                    .lifetime_range(self.lifetime_range)
692                    .velocity(self.velocity)
693                    .velocity_range(self.velocity_range)
694                    .emission_range(PI * 2.0) // All directions
695                    .scale(self.scale)
696                    .scale_range(self.scale_range)
697                    .scale_speed(self.scale_speed)
698                    .alpha_speed(self.alpha_speed)
699                    .image(image);
700
701                if let Some(color) = self.color {
702                    builder = builder.color(color);
703                }
704
705                builder
706            })
707            .build()
708    }
709}
710
711// ============================================================================
712// Image creation helpers
713// ============================================================================
714
715/// Creates a soft glow particle image (radial gradient, white center fading to transparent).
716fn create_soft_glow_image(size: usize) -> CFRetained<CGImage> {
717    let color_space = CGColorSpace::new_device_rgb().expect("Failed to create color space");
718
719    let context = unsafe {
720        CGBitmapContextCreate(
721            std::ptr::null_mut(),
722            size,
723            size,
724            8,
725            size * 4,
726            Some(&color_space),
727            CGImageAlphaInfo::PremultipliedLast.0,
728        )
729    }
730    .expect("Failed to create bitmap context");
731
732    // Draw radial gradient (white center fading to transparent)
733    let center = (size / 2) as f64;
734    let radius = center;
735
736    for r in (1..=size / 2).rev() {
737        let alpha = 1.0 - (r as f64 / radius);
738        CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, alpha);
739        CGContext::fill_ellipse_in_rect(
740            Some(&context),
741            CGRect::new(
742                CGPoint::new(center - r as f64, center - r as f64),
743                CGSize::new(r as f64 * 2.0, r as f64 * 2.0),
744            ),
745        );
746    }
747
748    CGBitmapContextCreateImage(Some(&context)).expect("Failed to create image")
749}
750
751/// Creates a solid circle particle image.
752fn create_circle_image(size: usize) -> CFRetained<CGImage> {
753    let color_space = CGColorSpace::new_device_rgb().expect("Failed to create color space");
754
755    let context = unsafe {
756        CGBitmapContextCreate(
757            std::ptr::null_mut(),
758            size,
759            size,
760            8,
761            size * 4,
762            Some(&color_space),
763            CGImageAlphaInfo::PremultipliedLast.0,
764        )
765    }
766    .expect("Failed to create bitmap context");
767
768    // Draw solid white circle
769    CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, 1.0);
770    CGContext::fill_ellipse_in_rect(
771        Some(&context),
772        CGRect::new(
773            CGPoint::new(0.0, 0.0),
774            CGSize::new(size as f64, size as f64),
775        ),
776    );
777
778    CGBitmapContextCreateImage(Some(&context)).expect("Failed to create image")
779}
780
781/// Creates a star particle image with the specified number of points.
782fn create_star_image(size: usize, points: usize) -> CFRetained<CGImage> {
783    use std::f64::consts::PI;
784
785    let color_space = CGColorSpace::new_device_rgb().expect("Failed to create color space");
786
787    let context = unsafe {
788        CGBitmapContextCreate(
789            std::ptr::null_mut(),
790            size,
791            size,
792            8,
793            size * 4,
794            Some(&color_space),
795            CGImageAlphaInfo::PremultipliedLast.0,
796        )
797    }
798    .expect("Failed to create bitmap context");
799
800    let center = size as f64 / 2.0;
801    let outer_radius = center * 0.95;
802    let inner_radius = center * 0.4;
803    let points = points.max(3); // At least 3 points
804
805    // Draw star by filling triangular segments with gradient
806    // Start from top (-PI/2) and go clockwise
807    let angle_step = PI / points as f64;
808
809    for i in 0..(points * 2) {
810        let is_outer = i % 2 == 0;
811        let radius = if is_outer { outer_radius } else { inner_radius };
812        let angle = -PI / 2.0 + (i as f64) * angle_step;
813
814        let x = center + radius * angle.cos();
815        let y = center + radius * angle.sin();
816
817        // Draw radial line from center with gradient
818        let steps = 20;
819        for s in 0..steps {
820            let t = s as f64 / steps as f64;
821            let px = center + (x - center) * t;
822            let py = center + (y - center) * t;
823            let alpha = 1.0 - t * 0.5; // Fade slightly toward tips
824
825            CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, alpha);
826            let dot_size = 2.0 + (1.0 - t) * 2.0;
827            CGContext::fill_ellipse_in_rect(
828                Some(&context),
829                CGRect::new(
830                    CGPoint::new(px - dot_size / 2.0, py - dot_size / 2.0),
831                    CGSize::new(dot_size, dot_size),
832                ),
833            );
834        }
835    }
836
837    // Draw bright center
838    for r in (1..=(size / 6)).rev() {
839        let alpha = 1.0 - (r as f64 / (size as f64 / 6.0)) * 0.3;
840        CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, alpha);
841        CGContext::fill_ellipse_in_rect(
842            Some(&context),
843            CGRect::new(
844                CGPoint::new(center - r as f64, center - r as f64),
845                CGSize::new(r as f64 * 2.0, r as f64 * 2.0),
846            ),
847        );
848    }
849
850    CGBitmapContextCreateImage(Some(&context)).expect("Failed to create image")
851}
852
853/// Creates an elongated spark/streak particle image.
854fn create_spark_image(size: usize) -> CFRetained<CGImage> {
855    let color_space = CGColorSpace::new_device_rgb().expect("Failed to create color space");
856
857    // Spark is wider than tall (elongated horizontally)
858    let width = size;
859    let height = size / 3;
860
861    let context = unsafe {
862        CGBitmapContextCreate(
863            std::ptr::null_mut(),
864            width,
865            height,
866            8,
867            width * 4,
868            Some(&color_space),
869            CGImageAlphaInfo::PremultipliedLast.0,
870        )
871    }
872    .expect("Failed to create bitmap context");
873
874    let center_x = width as f64 / 2.0;
875    let center_y = height as f64 / 2.0;
876
877    // Draw elongated gradient streak
878    for x in 0..width {
879        let dx = (x as f64 - center_x) / center_x;
880        let x_alpha = 1.0 - dx.abs().powf(1.5); // Fade toward ends
881
882        for y in 0..height {
883            let dy = (y as f64 - center_y) / center_y;
884            let y_alpha = 1.0 - dy.abs().powf(2.0); // Sharp vertical falloff
885
886            let alpha = (x_alpha * y_alpha).max(0.0);
887            if alpha > 0.01 {
888                CGContext::set_rgb_fill_color(Some(&context), 1.0, 1.0, 1.0, alpha);
889                CGContext::fill_rect(
890                    Some(&context),
891                    CGRect::new(CGPoint::new(x as f64, y as f64), CGSize::new(1.0, 1.0)),
892                );
893            }
894        }
895    }
896
897    CGBitmapContextCreateImage(Some(&context)).expect("Failed to create image")
898}