Skip to main content

proof_engine/editor/
particle_editor.rs

1
2//! Visual particle system editor — emitter modules, curves, preview, gradient editor.
3
4use glam::{Vec2, Vec3, Vec4};
5use std::collections::HashMap;
6
7// ---------------------------------------------------------------------------
8// Value sampling — constant, curve, or random
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone)]
12pub enum MinMaxCurve {
13    Constant(f32),
14    Random(f32, f32),          // min, max
15    Curve(Vec<Vec2>),          // control points (time, value)
16    TwoCurves(Vec<Vec2>, Vec<Vec2>),
17}
18
19impl MinMaxCurve {
20    pub fn evaluate(&self, t: f32, rand01: f32) -> f32 {
21        let t = t.clamp(0.0, 1.0);
22        match self {
23            MinMaxCurve::Constant(v) => *v,
24            MinMaxCurve::Random(lo, hi) => lo + (hi - lo) * rand01,
25            MinMaxCurve::Curve(pts) => {
26                if pts.is_empty() { return 0.0; }
27                if pts.len() == 1 { return pts[0].y; }
28                let i = pts.partition_point(|p| p.x <= t);
29                if i == 0 { return pts[0].y; }
30                if i >= pts.len() { return pts[pts.len()-1].y; }
31                let a = pts[i-1];
32                let b = pts[i];
33                let u = (t - a.x) / (b.x - a.x).max(1e-6);
34                a.y + (b.y - a.y) * u
35            }
36            MinMaxCurve::TwoCurves(lo_pts, hi_pts) => {
37                let lo = MinMaxCurve::Curve(lo_pts.clone()).evaluate(t, 0.0);
38                let hi = MinMaxCurve::Curve(hi_pts.clone()).evaluate(t, 0.0);
39                lo + (hi - lo) * rand01
40            }
41        }
42    }
43
44    pub fn constant(v: f32) -> Self { MinMaxCurve::Constant(v) }
45    pub fn random_range(lo: f32, hi: f32) -> Self { MinMaxCurve::Random(lo, hi) }
46}
47
48#[derive(Debug, Clone)]
49pub struct MinMaxGradient {
50    pub stops: Vec<(f32, Vec4)>, // (time 0..1, rgba)
51}
52
53impl MinMaxGradient {
54    pub fn solid(color: Vec4) -> Self {
55        Self { stops: vec![(0.0, color), (1.0, color)] }
56    }
57
58    pub fn two_color(from: Vec4, to: Vec4) -> Self {
59        Self { stops: vec![(0.0, from), (1.0, to)] }
60    }
61
62    pub fn evaluate(&self, t: f32) -> Vec4 {
63        let t = t.clamp(0.0, 1.0);
64        if self.stops.is_empty() { return Vec4::ONE; }
65        if self.stops.len() == 1 { return self.stops[0].1; }
66        let i = self.stops.partition_point(|s| s.0 <= t);
67        if i == 0 { return self.stops[0].1; }
68        if i >= self.stops.len() { return self.stops[self.stops.len()-1].1; }
69        let (ta, ca) = self.stops[i-1];
70        let (tb, cb) = self.stops[i];
71        let u = (t - ta) / (tb - ta).max(1e-6);
72        ca.lerp(cb, u)
73    }
74
75    pub fn add_stop(&mut self, t: f32, color: Vec4) {
76        let i = self.stops.partition_point(|s| s.0 <= t);
77        self.stops.insert(i, (t, color));
78    }
79}
80
81// ---------------------------------------------------------------------------
82// Emitter shapes
83// ---------------------------------------------------------------------------
84
85#[derive(Debug, Clone, PartialEq)]
86pub enum EmitterShape {
87    Point,
88    Sphere { radius: f32, thickness: f32 },
89    Hemisphere { radius: f32, thickness: f32 },
90    Cone { angle: f32, radius: f32, length: f32, emit_from_volume: bool },
91    Box { half_extents: Vec3, emit_from_surface: bool },
92    Circle { radius: f32, arc: f32, thickness: f32 },
93    Edge { length: f32 },
94    Mesh { mesh_id: u64 },
95    Trail,
96    Donut { radius: f32, donut_radius: f32 },
97    Rectangle { half_extents: Vec2, emit_from_edge: bool },
98    Line { length: f32, spread: f32 },
99}
100
101impl EmitterShape {
102    pub fn label(&self) -> &'static str {
103        match self {
104            EmitterShape::Point => "Point",
105            EmitterShape::Sphere { .. } => "Sphere",
106            EmitterShape::Hemisphere { .. } => "Hemisphere",
107            EmitterShape::Cone { .. } => "Cone",
108            EmitterShape::Box { .. } => "Box",
109            EmitterShape::Circle { .. } => "Circle",
110            EmitterShape::Edge { .. } => "Edge",
111            EmitterShape::Mesh { .. } => "Mesh",
112            EmitterShape::Trail => "Trail",
113            EmitterShape::Donut { .. } => "Donut",
114            EmitterShape::Rectangle { .. } => "Rectangle",
115            EmitterShape::Line { .. } => "Line",
116        }
117    }
118
119    /// Generate a position and direction for a newly emitted particle (pseudo-random).
120    pub fn sample_position(&self, rand: [f32; 4]) -> (Vec3, Vec3) {
121        match self {
122            EmitterShape::Point => (Vec3::ZERO, Vec3::Y),
123            EmitterShape::Sphere { radius, thickness } => {
124                let theta = rand[0] * std::f32::consts::TAU;
125                let phi = (rand[1] * 2.0 - 1.0).acos();
126                let r = radius * (1.0 - thickness * rand[2]);
127                let pos = Vec3::new(phi.sin() * theta.cos(), phi.cos(), phi.sin() * theta.sin()) * r;
128                (pos, pos.normalize())
129            }
130            EmitterShape::Cone { angle, radius, length, .. } => {
131                let t = rand[0] * length;
132                let local_radius = (angle * std::f32::consts::PI / 180.0).tan() * t + radius;
133                let theta = rand[1] * std::f32::consts::TAU;
134                let r = (rand[2]).sqrt() * local_radius;
135                let pos = Vec3::new(r * theta.cos(), t, r * theta.sin());
136                let dir = Vec3::new(0.0, 1.0, 0.0).normalize();
137                (pos, dir)
138            }
139            EmitterShape::Box { half_extents, emit_from_surface } => {
140                if *emit_from_surface {
141                    let pos = Vec3::new(
142                        (rand[0] * 2.0 - 1.0) * half_extents.x,
143                        half_extents.y,
144                        (rand[1] * 2.0 - 1.0) * half_extents.z,
145                    );
146                    (pos, Vec3::Y)
147                } else {
148                    let pos = Vec3::new(
149                        (rand[0] * 2.0 - 1.0) * half_extents.x,
150                        (rand[1] * 2.0 - 1.0) * half_extents.y,
151                        (rand[2] * 2.0 - 1.0) * half_extents.z,
152                    );
153                    (pos, Vec3::Y)
154                }
155            }
156            EmitterShape::Circle { radius, arc, thickness } => {
157                let theta = rand[0] * arc * std::f32::consts::PI / 180.0;
158                let r = radius * (1.0 - thickness * rand[1]);
159                (Vec3::new(r * theta.cos(), 0.0, r * theta.sin()), Vec3::Y)
160            }
161            _ => (Vec3::ZERO, Vec3::Y),
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Module system (Unity-style)
168// ---------------------------------------------------------------------------
169
170#[derive(Debug, Clone)]
171pub struct MainModule {
172    pub duration: f32,
173    pub looping: bool,
174    pub prewarm: bool,
175    pub start_delay: MinMaxCurve,
176    pub start_lifetime: MinMaxCurve,
177    pub start_speed: MinMaxCurve,
178    pub start_size_3d: bool,
179    pub start_size: MinMaxCurve,
180    pub start_size_x: MinMaxCurve,
181    pub start_size_y: MinMaxCurve,
182    pub start_size_z: MinMaxCurve,
183    pub start_rotation_3d: bool,
184    pub start_rotation: MinMaxCurve,
185    pub flip_rotation: f32,
186    pub start_color: MinMaxGradient,
187    pub gravity_modifier: MinMaxCurve,
188    pub simulation_space: SimulationSpace,
189    pub simulation_speed: f32,
190    pub stop_action: StopAction,
191    pub culling_mode: CullingMode,
192    pub max_particles: u32,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub enum SimulationSpace { Local, World, Custom }
197#[derive(Debug, Clone, Copy, PartialEq)]
198pub enum StopAction { None, Disable, Destroy, Callback }
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub enum CullingMode { Automatic, AlwaysSimulate, PauseAndCatchup, Pause }
201
202impl Default for MainModule {
203    fn default() -> Self {
204        Self {
205            duration: 5.0,
206            looping: true,
207            prewarm: false,
208            start_delay: MinMaxCurve::constant(0.0),
209            start_lifetime: MinMaxCurve::random_range(1.0, 3.0),
210            start_speed: MinMaxCurve::random_range(1.0, 5.0),
211            start_size_3d: false,
212            start_size: MinMaxCurve::random_range(0.05, 0.2),
213            start_size_x: MinMaxCurve::constant(1.0),
214            start_size_y: MinMaxCurve::constant(1.0),
215            start_size_z: MinMaxCurve::constant(1.0),
216            start_rotation_3d: false,
217            start_rotation: MinMaxCurve::random_range(0.0, 360.0),
218            flip_rotation: 0.0,
219            start_color: MinMaxGradient::solid(Vec4::ONE),
220            gravity_modifier: MinMaxCurve::constant(0.0),
221            simulation_space: SimulationSpace::World,
222            simulation_speed: 1.0,
223            stop_action: StopAction::None,
224            culling_mode: CullingMode::Automatic,
225            max_particles: 1000,
226        }
227    }
228}
229
230#[derive(Debug, Clone)]
231pub struct EmissionModule {
232    pub enabled: bool,
233    pub rate_over_time: MinMaxCurve,
234    pub rate_over_distance: MinMaxCurve,
235    pub bursts: Vec<EmissionBurst>,
236}
237
238#[derive(Debug, Clone)]
239pub struct EmissionBurst {
240    pub time: f32,
241    pub count: MinMaxCurve,
242    pub cycles: u32,
243    pub interval: f32,
244    pub probability: f32,
245}
246
247impl Default for EmissionModule {
248    fn default() -> Self {
249        Self {
250            enabled: true,
251            rate_over_time: MinMaxCurve::constant(10.0),
252            rate_over_distance: MinMaxCurve::constant(0.0),
253            bursts: Vec::new(),
254        }
255    }
256}
257
258#[derive(Debug, Clone)]
259pub struct VelocityOverLifetimeModule {
260    pub enabled: bool,
261    pub x: MinMaxCurve,
262    pub y: MinMaxCurve,
263    pub z: MinMaxCurve,
264    pub space: SimulationSpace,
265    pub orbital_x: MinMaxCurve,
266    pub orbital_y: MinMaxCurve,
267    pub orbital_z: MinMaxCurve,
268    pub orbital_offset_x: MinMaxCurve,
269    pub orbital_offset_y: MinMaxCurve,
270    pub orbital_offset_z: MinMaxCurve,
271    pub radial: MinMaxCurve,
272    pub speed_modifier: MinMaxCurve,
273}
274
275impl Default for VelocityOverLifetimeModule {
276    fn default() -> Self {
277        Self {
278            enabled: false,
279            x: MinMaxCurve::constant(0.0),
280            y: MinMaxCurve::constant(0.0),
281            z: MinMaxCurve::constant(0.0),
282            space: SimulationSpace::Local,
283            orbital_x: MinMaxCurve::constant(0.0),
284            orbital_y: MinMaxCurve::constant(0.0),
285            orbital_z: MinMaxCurve::constant(0.0),
286            orbital_offset_x: MinMaxCurve::constant(0.0),
287            orbital_offset_y: MinMaxCurve::constant(0.0),
288            orbital_offset_z: MinMaxCurve::constant(0.0),
289            radial: MinMaxCurve::constant(0.0),
290            speed_modifier: MinMaxCurve::constant(1.0),
291        }
292    }
293}
294
295#[derive(Debug, Clone)]
296pub struct ColorOverLifetimeModule {
297    pub enabled: bool,
298    pub color: MinMaxGradient,
299}
300
301impl Default for ColorOverLifetimeModule {
302    fn default() -> Self {
303        Self {
304            enabled: true,
305            color: MinMaxGradient::two_color(Vec4::ONE, Vec4::new(1.0, 1.0, 1.0, 0.0)),
306        }
307    }
308}
309
310#[derive(Debug, Clone)]
311pub struct SizeOverLifetimeModule {
312    pub enabled: bool,
313    pub size: MinMaxCurve,
314    pub size_3d: bool,
315    pub x: MinMaxCurve,
316    pub y: MinMaxCurve,
317    pub z: MinMaxCurve,
318}
319
320impl Default for SizeOverLifetimeModule {
321    fn default() -> Self {
322        Self {
323            enabled: true,
324            size: MinMaxCurve::Curve(vec![
325                Vec2::new(0.0, 0.0),
326                Vec2::new(0.2, 1.0),
327                Vec2::new(0.8, 1.0),
328                Vec2::new(1.0, 0.0),
329            ]),
330            size_3d: false,
331            x: MinMaxCurve::constant(1.0),
332            y: MinMaxCurve::constant(1.0),
333            z: MinMaxCurve::constant(1.0),
334        }
335    }
336}
337
338#[derive(Debug, Clone)]
339pub struct RotationOverLifetimeModule {
340    pub enabled: bool,
341    pub angular_velocity: MinMaxCurve,
342    pub rotation_3d: bool,
343    pub x: MinMaxCurve,
344    pub y: MinMaxCurve,
345    pub z: MinMaxCurve,
346}
347
348impl Default for RotationOverLifetimeModule {
349    fn default() -> Self {
350        Self {
351            enabled: false,
352            angular_velocity: MinMaxCurve::random_range(-90.0, 90.0),
353            rotation_3d: false,
354            x: MinMaxCurve::constant(0.0),
355            y: MinMaxCurve::constant(0.0),
356            z: MinMaxCurve::constant(0.0),
357        }
358    }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq)]
362pub enum ForceMode { Force, Acceleration, Impulse, VelocityChange }
363
364#[derive(Debug, Clone)]
365pub struct ForceOverLifetimeModule {
366    pub enabled: bool,
367    pub x: MinMaxCurve,
368    pub y: MinMaxCurve,
369    pub z: MinMaxCurve,
370    pub space: SimulationSpace,
371    pub randomize_per_frame: bool,
372}
373
374impl Default for ForceOverLifetimeModule {
375    fn default() -> Self {
376        Self {
377            enabled: false,
378            x: MinMaxCurve::constant(0.0),
379            y: MinMaxCurve::constant(-9.8),
380            z: MinMaxCurve::constant(0.0),
381            space: SimulationSpace::World,
382            randomize_per_frame: false,
383        }
384    }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq)]
388pub enum CollisionType { Planes, World }
389#[derive(Debug, Clone, Copy, PartialEq)]
390pub enum CollisionMode { Collision2D, Collision3D }
391
392#[derive(Debug, Clone)]
393pub struct CollisionModule {
394    pub enabled: bool,
395    pub collision_type: CollisionType,
396    pub mode: CollisionMode,
397    pub dampen: MinMaxCurve,
398    pub bounce: MinMaxCurve,
399    pub lifetime_loss: MinMaxCurve,
400    pub min_kill_speed: f32,
401    pub max_kill_speed: f32,
402    pub radius_scale: f32,
403    pub send_collision_messages: bool,
404}
405
406impl Default for CollisionModule {
407    fn default() -> Self {
408        Self {
409            enabled: false,
410            collision_type: CollisionType::World,
411            mode: CollisionMode::Collision3D,
412            dampen: MinMaxCurve::constant(0.1),
413            bounce: MinMaxCurve::constant(0.6),
414            lifetime_loss: MinMaxCurve::constant(0.0),
415            min_kill_speed: 0.0,
416            max_kill_speed: 10000.0,
417            radius_scale: 1.0,
418            send_collision_messages: false,
419        }
420    }
421}
422
423#[derive(Debug, Clone)]
424pub struct TrailModule {
425    pub enabled: bool,
426    pub mode: TrailMode,
427    pub ratio: f32,
428    pub lifetime: MinMaxCurve,
429    pub min_vertex_distance: f32,
430    pub world_space: bool,
431    pub die_with_particles: bool,
432    pub texture_mode: TrailTextureMode,
433    pub size_affects_width: bool,
434    pub size_affects_lifetime: bool,
435    pub inherit_particle_color: bool,
436    pub color_over_lifetime: MinMaxGradient,
437    pub width_over_trail: MinMaxCurve,
438    pub color_over_trail: MinMaxGradient,
439    pub generate_lighting_data: bool,
440    pub shadow_bias: f32,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq)]
444pub enum TrailMode { PerParticle, Ribbon }
445#[derive(Debug, Clone, Copy, PartialEq)]
446pub enum TrailTextureMode { Stretch, Tile, DistributedPerSegment, RepeatPerSegment }
447
448impl Default for TrailModule {
449    fn default() -> Self {
450        Self {
451            enabled: false,
452            mode: TrailMode::PerParticle,
453            ratio: 1.0,
454            lifetime: MinMaxCurve::constant(1.0),
455            min_vertex_distance: 0.1,
456            world_space: false,
457            die_with_particles: true,
458            texture_mode: TrailTextureMode::Stretch,
459            size_affects_width: true,
460            size_affects_lifetime: false,
461            inherit_particle_color: true,
462            color_over_lifetime: MinMaxGradient::solid(Vec4::ONE),
463            width_over_trail: MinMaxCurve::constant(1.0),
464            color_over_trail: MinMaxGradient::solid(Vec4::ONE),
465            generate_lighting_data: false,
466            shadow_bias: 0.5,
467        }
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Renderer module
473// ---------------------------------------------------------------------------
474
475#[derive(Debug, Clone, Copy, PartialEq)]
476pub enum RenderMode { Billboard, StretchedBillboard, HorizontalBillboard, VerticalBillboard, Mesh, None }
477#[derive(Debug, Clone, Copy, PartialEq)]
478pub enum BillboardAlignment { View, World, Local, Facing, Velocity }
479#[derive(Debug, Clone, Copy, PartialEq)]
480pub enum SortMode { None, ByDistance, OldestInFront, YoungestInFront }
481
482#[derive(Debug, Clone)]
483pub struct RendererModule {
484    pub render_mode: RenderMode,
485    pub material_id: Option<u64>,
486    pub mesh_id: Option<u64>,
487    pub sort_mode: SortMode,
488    pub sorting_fudge: f32,
489    pub min_particle_size: f32,
490    pub max_particle_size: f32,
491    pub billboard_alignment: BillboardAlignment,
492    pub flip_u: f32,
493    pub flip_v: f32,
494    pub enable_gpu_instancing: bool,
495    pub shadow_casting: bool,
496    pub receive_shadows: bool,
497    pub motion_vectors: bool,
498    pub allow_roll: bool,
499    pub pivot: Vec3,
500    pub masking_layer: u32,
501    pub apply_active_color_space: bool,
502}
503
504impl Default for RendererModule {
505    fn default() -> Self {
506        Self {
507            render_mode: RenderMode::Billboard,
508            material_id: None,
509            mesh_id: None,
510            sort_mode: SortMode::None,
511            sorting_fudge: 0.0,
512            min_particle_size: 0.0,
513            max_particle_size: 0.5,
514            billboard_alignment: BillboardAlignment::View,
515            flip_u: 0.0,
516            flip_v: 0.0,
517            enable_gpu_instancing: true,
518            shadow_casting: false,
519            receive_shadows: false,
520            motion_vectors: false,
521            allow_roll: true,
522            pivot: Vec3::ZERO,
523            masking_layer: 0,
524            apply_active_color_space: true,
525        }
526    }
527}
528
529// ---------------------------------------------------------------------------
530// Particle system definition
531// ---------------------------------------------------------------------------
532
533#[derive(Debug, Clone)]
534pub struct ParticleSystem {
535    pub id: u64,
536    pub name: String,
537    pub main: MainModule,
538    pub emission: EmissionModule,
539    pub shape: EmitterShape,
540    pub velocity_over_lifetime: VelocityOverLifetimeModule,
541    pub force_over_lifetime: ForceOverLifetimeModule,
542    pub color_over_lifetime: ColorOverLifetimeModule,
543    pub size_over_lifetime: SizeOverLifetimeModule,
544    pub rotation_over_lifetime: RotationOverLifetimeModule,
545    pub collision: CollisionModule,
546    pub trails: TrailModule,
547    pub renderer: RendererModule,
548    pub position: Vec3,
549    pub enabled: bool,
550    pub sub_systems: Vec<u64>,
551    pub tags: Vec<String>,
552}
553
554impl ParticleSystem {
555    pub fn new(id: u64, name: impl Into<String>) -> Self {
556        Self {
557            id,
558            name: name.into(),
559            main: MainModule::default(),
560            emission: EmissionModule::default(),
561            shape: EmitterShape::Cone { angle: 25.0, radius: 0.1, length: 0.0, emit_from_volume: false },
562            velocity_over_lifetime: VelocityOverLifetimeModule::default(),
563            force_over_lifetime: ForceOverLifetimeModule::default(),
564            color_over_lifetime: ColorOverLifetimeModule::default(),
565            size_over_lifetime: SizeOverLifetimeModule::default(),
566            rotation_over_lifetime: RotationOverLifetimeModule::default(),
567            collision: CollisionModule::default(),
568            trails: TrailModule::default(),
569            renderer: RendererModule::default(),
570            position: Vec3::ZERO,
571            enabled: true,
572            sub_systems: Vec::new(),
573            tags: Vec::new(),
574        }
575    }
576
577    pub fn fire() -> Self {
578        let mut ps = Self::new(1, "Fire");
579        ps.main.start_lifetime = MinMaxCurve::random_range(0.5, 1.5);
580        ps.main.start_speed = MinMaxCurve::random_range(0.5, 2.0);
581        ps.main.start_size = MinMaxCurve::random_range(0.1, 0.5);
582        ps.main.start_color = MinMaxGradient::two_color(
583            Vec4::new(1.0, 0.5, 0.0, 1.0),
584            Vec4::new(1.0, 0.8, 0.0, 1.0),
585        );
586        ps.main.gravity_modifier = MinMaxCurve::constant(-0.2);
587        ps.emission.rate_over_time = MinMaxCurve::constant(30.0);
588        ps.shape = EmitterShape::Cone { angle: 15.0, radius: 0.2, length: 0.0, emit_from_volume: false };
589        ps.color_over_lifetime.color = MinMaxGradient::two_color(
590            Vec4::new(1.0, 0.5, 0.0, 1.0),
591            Vec4::new(0.2, 0.2, 0.2, 0.0),
592        );
593        ps
594    }
595
596    pub fn smoke() -> Self {
597        let mut ps = Self::new(2, "Smoke");
598        ps.main.start_lifetime = MinMaxCurve::random_range(2.0, 5.0);
599        ps.main.start_speed = MinMaxCurve::random_range(0.2, 0.8);
600        ps.main.start_size = MinMaxCurve::random_range(0.3, 1.0);
601        ps.main.start_color = MinMaxGradient::solid(Vec4::new(0.4, 0.4, 0.4, 0.5));
602        ps.emission.rate_over_time = MinMaxCurve::constant(5.0);
603        ps.size_over_lifetime.size = MinMaxCurve::Curve(vec![
604            Vec2::new(0.0, 0.3), Vec2::new(0.5, 0.8), Vec2::new(1.0, 2.0),
605        ]);
606        ps.rotation_over_lifetime.enabled = true;
607        ps
608    }
609
610    pub fn sparks() -> Self {
611        let mut ps = Self::new(3, "Sparks");
612        ps.main.start_lifetime = MinMaxCurve::random_range(0.5, 1.5);
613        ps.main.start_speed = MinMaxCurve::random_range(3.0, 8.0);
614        ps.main.start_size = MinMaxCurve::random_range(0.02, 0.06);
615        ps.main.start_color = MinMaxGradient::two_color(
616            Vec4::new(1.0, 1.0, 0.5, 1.0),
617            Vec4::new(1.0, 0.3, 0.0, 1.0),
618        );
619        ps.main.gravity_modifier = MinMaxCurve::constant(1.0);
620        ps.emission.rate_over_time = MinMaxCurve::constant(0.0);
621        ps.emission.bursts.push(EmissionBurst {
622            time: 0.0,
623            count: MinMaxCurve::random_range(50.0, 100.0),
624            cycles: 1,
625            interval: 0.0,
626            probability: 1.0,
627        });
628        ps.shape = EmitterShape::Sphere { radius: 0.1, thickness: 0.0 };
629        ps.collision.enabled = true;
630        ps
631    }
632
633    pub fn rain() -> Self {
634        let mut ps = Self::new(4, "Rain");
635        ps.main.start_lifetime = MinMaxCurve::constant(2.0);
636        ps.main.start_speed = MinMaxCurve::random_range(5.0, 10.0);
637        ps.main.start_size = MinMaxCurve::random_range(0.01, 0.03);
638        ps.main.start_color = MinMaxGradient::solid(Vec4::new(0.6, 0.7, 1.0, 0.6));
639        ps.main.gravity_modifier = MinMaxCurve::constant(1.0);
640        ps.main.max_particles = 5000;
641        ps.emission.rate_over_time = MinMaxCurve::constant(200.0);
642        ps.shape = EmitterShape::Box { half_extents: Vec3::new(20.0, 0.1, 20.0), emit_from_surface: true };
643        ps.renderer.render_mode = RenderMode::StretchedBillboard;
644        ps.collision.enabled = true;
645        ps
646    }
647
648    pub fn estimated_particle_count(&self) -> f32 {
649        let rate = self.emission.rate_over_time.evaluate(0.0, 0.5);
650        let lifetime_avg = (self.main.start_lifetime.evaluate(0.0, 0.0) +
651                            self.main.start_lifetime.evaluate(0.0, 1.0)) * 0.5;
652        (rate * lifetime_avg).min(self.main.max_particles as f32)
653    }
654}
655
656// ---------------------------------------------------------------------------
657// Particle editor state
658// ---------------------------------------------------------------------------
659
660#[derive(Debug, Clone, Copy, PartialEq)]
661pub enum ParticleEditorTab {
662    Emitter,
663    Modules,
664    Preview,
665    Timeline,
666    Curves,
667    Gradient,
668}
669
670#[derive(Debug, Clone, Copy, PartialEq)]
671pub enum ParticlePreviewMode {
672    Solid,
673    Wireframe,
674    Velocity,
675    Age,
676    Count,
677}
678
679#[derive(Debug, Clone)]
680pub struct ParticleEditor {
681    pub systems: Vec<ParticleSystem>,
682    pub selected_system: Option<usize>,
683    pub active_tab: ParticleEditorTab,
684    pub preview_mode: ParticlePreviewMode,
685    pub preview_time: f32,
686    pub preview_playing: bool,
687    pub preview_loop: bool,
688    pub preview_speed: f32,
689    pub show_bounds: bool,
690    pub show_emitter_shape: bool,
691    pub camera_distance: f32,
692    pub search_query: String,
693    pub next_id: u64,
694}
695
696impl ParticleEditor {
697    pub fn new() -> Self {
698        let mut ed = Self {
699            systems: Vec::new(),
700            selected_system: None,
701            active_tab: ParticleEditorTab::Emitter,
702            preview_mode: ParticlePreviewMode::Solid,
703            preview_time: 0.0,
704            preview_playing: true,
705            preview_loop: true,
706            preview_speed: 1.0,
707            show_bounds: false,
708            show_emitter_shape: true,
709            camera_distance: 10.0,
710            search_query: String::new(),
711            next_id: 5,
712        };
713        ed.systems.push(ParticleSystem::fire());
714        ed.systems.push(ParticleSystem::smoke());
715        ed.systems.push(ParticleSystem::sparks());
716        ed.systems.push(ParticleSystem::rain());
717        ed.selected_system = Some(0);
718        ed
719    }
720
721    pub fn add_system(&mut self, name: impl Into<String>) -> usize {
722        let id = self.next_id;
723        self.next_id += 1;
724        let ps = ParticleSystem::new(id, name);
725        let idx = self.systems.len();
726        self.systems.push(ps);
727        idx
728    }
729
730    pub fn remove_system(&mut self, idx: usize) {
731        if idx < self.systems.len() {
732            self.systems.remove(idx);
733            if self.selected_system == Some(idx) {
734                self.selected_system = if self.systems.is_empty() { None } else { Some(0) };
735            }
736        }
737    }
738
739    pub fn duplicate_system(&mut self, idx: usize) -> Option<usize> {
740        if idx >= self.systems.len() { return None; }
741        let mut copy = self.systems[idx].clone();
742        copy.id = self.next_id;
743        self.next_id += 1;
744        copy.name = format!("{}_copy", copy.name);
745        let new_idx = self.systems.len();
746        self.systems.push(copy);
747        Some(new_idx)
748    }
749
750    pub fn update(&mut self, dt: f32) {
751        if self.preview_playing {
752            self.preview_time += dt * self.preview_speed;
753            if let Some(idx) = self.selected_system {
754                let duration = self.systems[idx].main.duration;
755                if self.preview_time >= duration {
756                    if self.preview_loop {
757                        self.preview_time = self.preview_time % duration;
758                    } else {
759                        self.preview_time = duration;
760                        self.preview_playing = false;
761                    }
762                }
763            }
764        }
765    }
766
767    pub fn selected_system(&self) -> Option<&ParticleSystem> {
768        self.selected_system.and_then(|i| self.systems.get(i))
769    }
770
771    pub fn selected_system_mut(&mut self) -> Option<&mut ParticleSystem> {
772        self.selected_system.and_then(|i| self.systems.get_mut(i))
773    }
774
775    pub fn search_systems(&self, query: &str) -> Vec<usize> {
776        let q = query.to_lowercase();
777        self.systems.iter().enumerate()
778            .filter(|(_, s)| s.name.to_lowercase().contains(&q) || s.tags.iter().any(|t| t.to_lowercase().contains(&q)))
779            .map(|(i, _)| i)
780            .collect()
781    }
782
783    pub fn total_estimated_particles(&self) -> f32 {
784        self.systems.iter().filter(|s| s.enabled).map(|s| s.estimated_particle_count()).sum()
785    }
786}
787
788// ---------------------------------------------------------------------------
789// Tests
790// ---------------------------------------------------------------------------
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    #[test]
796    fn test_min_max_curve() {
797        let c = MinMaxCurve::random_range(1.0, 3.0);
798        let v = c.evaluate(0.5, 0.5);
799        assert!((v - 2.0).abs() < 1e-5);
800    }
801
802    #[test]
803    fn test_gradient() {
804        let g = MinMaxGradient::two_color(Vec4::ZERO, Vec4::ONE);
805        let mid = g.evaluate(0.5);
806        assert!((mid.x - 0.5).abs() < 1e-5);
807    }
808
809    #[test]
810    fn test_emitter_shape_sample() {
811        let shape = EmitterShape::Sphere { radius: 1.0, thickness: 0.0 };
812        let (pos, dir) = shape.sample_position([0.3, 0.6, 0.9, 0.1]);
813        assert!((pos.length() - 1.0).abs() < 0.01);
814        assert!((dir.length() - 1.0).abs() < 0.01);
815    }
816
817    #[test]
818    fn test_particle_system_presets() {
819        let fire = ParticleSystem::fire();
820        assert!(fire.estimated_particle_count() > 0.0);
821        let sparks = ParticleSystem::sparks();
822        assert!(!sparks.emission.bursts.is_empty());
823    }
824
825    #[test]
826    fn test_editor() {
827        let mut ed = ParticleEditor::new();
828        assert_eq!(ed.systems.len(), 4);
829        ed.update(0.016);
830        let idx = ed.add_system("TestPS");
831        assert!(ed.systems[idx].name == "TestPS");
832    }
833}