proof-engine 0.2.3

Real-time graphics from math: glyphs and particles moved by ODEs, strange attractors and force fields, drawn with HDR bloom on OpenGL.
Documentation

// ── Particle Noise Field ──────────────────────────────────────────────────────

#[derive(Clone, Debug)]
pub struct NoiseField {
    pub frequency: f32,
    pub amplitude: f32,
    pub octaves: u32,
    pub lacunarity: f32,
    pub persistence: f32,
    pub offset: Vec3,
    pub scroll_speed: Vec3,
    pub enabled: bool,
}

impl NoiseField {
    pub fn new(frequency: f32, amplitude: f32) -> Self {
        Self { frequency, amplitude, octaves: 4, lacunarity: 2.0, persistence: 0.5, offset: Vec3::ZERO, scroll_speed: Vec3::ZERO, enabled: true }
    }
    pub fn sample(&self, pos: Vec3, time: f32) -> Vec3 {
        let p = pos * self.frequency + self.offset + self.scroll_speed * time;
        // Simple pseudo-noise using sin waves
        let nx = (p.x * 1.1 + p.y * 0.7 + p.z * 0.3).sin() * self.amplitude;
        let ny = (p.x * 0.3 + p.y * 1.3 + p.z * 0.9).sin() * self.amplitude;
        let nz = (p.x * 0.7 + p.y * 0.5 + p.z * 1.1).sin() * self.amplitude;
        Vec3::new(nx, ny, nz)
    }
    pub fn scroll(&mut self, dt: f32) { self.offset += self.scroll_speed * dt; }
    pub fn set_turbulence(mut self, octaves: u32) -> Self { self.octaves = octaves; self }
}

impl Default for NoiseField {
    fn default() -> Self { Self::new(0.5, 1.0) }
}

// ── Particle Spawn Burst ──────────────────────────────────────────────────────

#[derive(Clone, Debug)]
pub struct SpawnBurst {
    pub time: f32,
    pub count_min: u32,
    pub count_max: u32,
    pub probability: f32,
    pub triggered: bool,
    pub cycles: u32,
    pub cycle_interval: f32,
    pub cycles_done: u32,
}

impl SpawnBurst {
    pub fn new(time: f32, count: u32) -> Self {
        Self { time, count_min: count, count_max: count, probability: 1.0, triggered: false, cycles: 1, cycle_interval: 0.0, cycles_done: 0 }
    }
    pub fn range(mut self, min: u32, max: u32) -> Self { self.count_min = min; self.count_max = max; self }
    pub fn repeating(mut self, cycles: u32, interval: f32) -> Self { self.cycles = cycles; self.cycle_interval = interval; self }
    pub fn should_trigger(&self, current_time: f32) -> bool {
        !self.triggered && self.cycles_done < self.cycles &&
        current_time >= self.time + self.cycles_done as f32 * self.cycle_interval
    }
    pub fn trigger(&mut self) {
        self.triggered = self.cycles_done + 1 >= self.cycles;
        self.cycles_done += 1;
    }
    pub fn is_done(&self) -> bool { self.cycles_done >= self.cycles }
}

// ── Particle Sub-Emitter ──────────────────────────────────────────────────────

#[derive(Clone, Debug, PartialEq)]
pub enum SubEmitterEvent { Birth, Death, Collision, Manual }

#[derive(Clone, Debug)]
pub struct SubEmitter {
    pub id: u32,
    pub trigger_event: SubEmitterEvent,
    pub emitter_asset_id: u32,
    pub inherit_velocity: bool,
    pub inherit_color: bool,
    pub inherit_size: f32,
    pub probability: f32,
    pub cooldown: f32,
    pub last_triggered: f32,
}

impl SubEmitter {
    pub fn new(id: u32, event: SubEmitterEvent, asset_id: u32) -> Self {
        Self { id, trigger_event: event, emitter_asset_id: asset_id, inherit_velocity: true, inherit_color: false, inherit_size: 1.0, probability: 1.0, cooldown: 0.0, last_triggered: -999.0 }
    }
    pub fn can_trigger(&self, time: f32, roll: f32) -> bool {
        time - self.last_triggered >= self.cooldown && roll <= self.probability
    }
    pub fn record_trigger(&mut self, time: f32) { self.last_triggered = time; }
}

// ── Particle Texture Animation ────────────────────────────────────────────────

#[derive(Clone, Debug)]
pub struct TextureSheetAnimation {
    pub columns: u32,
    pub rows: u32,
    pub frame_count: u32,
    pub animation_speed: f32,
    pub loop_animation: bool,
    pub start_frame: u32,
    pub end_frame: u32,
    pub random_start_frame: bool,
}

impl TextureSheetAnimation {
    pub fn new(columns: u32, rows: u32) -> Self {
        let total = columns * rows;
        Self { columns, rows, frame_count: total, animation_speed: 30.0, loop_animation: true, start_frame: 0, end_frame: total.saturating_sub(1), random_start_frame: false }
    }
    pub fn frame_at_time(&self, time: f32, lifetime: f32) -> u32 {
        if self.frame_count == 0 { return 0; }
        let t = if lifetime > 0.0 { time / lifetime } else { time * self.animation_speed / self.frame_count as f32 };
        let t = if self.loop_animation { t.fract() } else { t.clamp(0.0, 1.0) };
        let range = self.end_frame - self.start_frame + 1;
        self.start_frame + (t * range as f32) as u32 % range
    }
    pub fn uv_for_frame(&self, frame: u32) -> (f32, f32, f32, f32) {
        let frame = frame.min(self.frame_count.saturating_sub(1));
        let col = frame % self.columns;
        let row = frame / self.columns;
        let w = 1.0 / self.columns as f32;
        let h = 1.0 / self.rows as f32;
        (col as f32 * w, row as f32 * h, w, h)
    }
    pub fn total_frames(&self) -> u32 { self.frame_count }
    pub fn duration(&self) -> f32 { self.frame_count as f32 / self.animation_speed.max(1.0) }
}

impl Default for TextureSheetAnimation {
    fn default() -> Self { Self::new(1, 1) }
}

// ── Particle Color Over Lifetime ──────────────────────────────────────────────

#[derive(Clone, Debug)]
pub struct ColorOverLifetime {
    pub gradient: Vec<(f32, Vec4)>,
    pub mode: ColorMode,
}

#[derive(Clone, Debug, PartialEq)]
pub enum ColorMode { Single, Gradient, RandomBetweenTwo, RandomColor }

impl ColorOverLifetime {
    pub fn constant(color: Vec4) -> Self { Self { gradient: vec![(0.0, color), (1.0, color)], mode: ColorMode::Single } }
    pub fn gradient(colors: Vec<(f32, Vec4)>) -> Self { Self { gradient: colors, mode: ColorMode::Gradient } }
    pub fn fade_out(color: Vec4) -> Self {
        let transparent = Vec4::new(color.x, color.y, color.z, 0.0);
        Self { gradient: vec![(0.0, color), (1.0, transparent)], mode: ColorMode::Gradient }
    }
    pub fn evaluate(&self, t: f32) -> Vec4 {
        if self.gradient.is_empty() { return Vec4::ONE; }
        if self.gradient.len() == 1 { return self.gradient[0].1; }
        let t = t.clamp(0.0, 1.0);
        let idx = self.gradient.partition_point(|(time, _)| *time <= t).saturating_sub(1);
        if idx + 1 >= self.gradient.len() { return self.gradient.last().unwrap().1; }
        let (t0, c0) = self.gradient[idx];
        let (t1, c1) = self.gradient[idx + 1];
        let alpha = if (t1 - t0).abs() < 1e-6 { 0.0 } else { (t - t0) / (t1 - t0) };
        lerp_color(c0, c1, alpha)
    }
    pub fn add_stop(&mut self, time: f32, color: Vec4) {
        self.gradient.push((time, color));
        self.gradient.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
    }
}

// ── Particle Size Over Lifetime ───────────────────────────────────────────────

#[derive(Clone, Debug)]
pub struct SizeOverLifetime {
    pub curve: FloatCurve,
    pub base_size: f32,
}

impl SizeOverLifetime {
    pub fn constant(size: f32) -> Self { Self { curve: FloatCurve::constant("size", 1.0), base_size: size } }
    pub fn shrink(start: f32, end: f32) -> Self {
        let mut curve = FloatCurve::new("size");
        curve.add_key(0.0, start / start.max(1e-5));
        curve.add_key(1.0, end / start.max(1e-5));
        Self { curve, base_size: start }
    }
    pub fn evaluate(&self, t: f32) -> f32 { self.base_size * self.curve.evaluate(t) }
}

// ── Velocity Over Lifetime ────────────────────────────────────────────────────

#[derive(Clone, Debug)]
pub struct VelocityOverLifetime {
    pub x_curve: FloatCurve,
    pub y_curve: FloatCurve,
    pub z_curve: FloatCurve,
    pub space: VelocitySpace,
    pub speed_modifier: FloatCurve,
}

#[derive(Clone, Debug, PartialEq)]
pub enum VelocitySpace { Local, World }

impl VelocityOverLifetime {
    pub fn constant(vel: Vec3) -> Self {
        Self {
            x_curve: FloatCurve::constant("vx", vel.x),
            y_curve: FloatCurve::constant("vy", vel.y),
            z_curve: FloatCurve::constant("vz", vel.z),
            space: VelocitySpace::World,
            speed_modifier: FloatCurve::constant("speed", 1.0),
        }
    }
    pub fn evaluate(&self, t: f32) -> Vec3 {
        let speed = self.speed_modifier.evaluate(t);
        Vec3::new(self.x_curve.evaluate(t), self.y_curve.evaluate(t), self.z_curve.evaluate(t)) * speed
    }
    pub fn zero() -> Self { Self::constant(Vec3::ZERO) }
}

// ── Extended Particle Constants ───────────────────────────────────────────────

pub const PARTICLE_MAX_BURST_EVENTS: usize = 8;
pub const PARTICLE_MAX_SUB_EMITTERS: usize = 4;
pub const PARTICLE_TEXTURE_SHEET_MAX_FRAMES: u32 = 256;
pub const PARTICLE_COLOR_GRADIENT_MAX_STOPS: usize = 8;
pub const PARTICLE_NOISE_OCTAVES_MAX: u32 = 8;
pub const PARTICLE_MAX_TRAIL_EMITTERS: usize = 16;
pub const PARTICLE_RENDERER_MAX_BATCHES: usize = 512;
pub const PARTICLE_POOL_OVERCOMMIT: f32 = 0.1;
pub const PARTICLE_ASSET_LIBRARY_MAX: usize = 1024;
pub const PARTICLE_SIMULATION_STEP_MAX: f32 = 0.033;

pub fn particle_feature_list() -> &'static [&'static str] {
    &[
        "emitters", "trails", "attractors", "force_fields",
        "colliders", "lod", "presets", "curves", "noise",
        "bursts", "sub_emitters", "texture_animation",
        "color_lifetime", "size_lifetime", "velocity_lifetime",
        "renderer", "pool", "assets", "sim_state", "debug",
        "statistics", "spawner_shapes",
    ]
}

pub fn particle_module_count() -> usize { particle_feature_list().len() }
pub fn particle_system_full_info() -> String {
    format!("ParticleSystemEditor v2.0 — {} modules, max {} total particles", particle_module_count(), PARTICLE_MAX_TOTAL)
}