doom-eternal 1.4.0

Rust CLI for the Xylex DOOM Eternal texture and install workflow
Documentation
use std::{collections::BTreeMap, path::Path};

use image::{imageops::FilterType, Pixel, Rgba, RgbaImage};
use imageproc::geometric_transformations::{rotate_about_center, Interpolation};
use serde::{Deserialize, Serialize};

use crate::{
    context::AppContext,
    error::{DoomError, Result},
};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlacementConfig {
    #[serde(default)]
    pub targets: BTreeMap<String, String>,
    #[serde(default)]
    pub adjustments: Vec<TextureAdjustment>,
    #[serde(default)]
    pub placements: Vec<Placement>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextureAdjustment {
    pub id: String,
    pub target: String,
    pub kind: TextureAdjustmentKind,
    pub strength: Option<f32>,
    pub enabled: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextureAdjustmentKind {
    JetBlack,
    JetBlackWithIndigoAccents,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Placement {
    pub id: String,
    pub target: String,
    pub logo: String,
    pub x: f32,
    pub y: f32,
    pub width: f32,
    pub height: Option<f32>,
    pub rotation_degrees: Option<f32>,
    pub opacity: Option<f32>,
    pub enabled: Option<bool>,
}

impl Placement {
    pub fn is_enabled(&self) -> bool {
        self.enabled.unwrap_or(true)
    }
}

impl TextureAdjustment {
    pub fn is_enabled(&self) -> bool {
        self.enabled.unwrap_or(true)
    }
}

pub fn load_config(ctx: &AppContext, path: impl AsRef<Path>) -> Result<PlacementConfig> {
    let path = ctx.repo().repo_path(path);
    if !path.exists() {
        return Err(DoomError::message(format!(
            "Missing placement config: {}",
            path.display()
        )));
    }
    Ok(serde_json::from_str(&std::fs::read_to_string(path)?)?)
}

pub fn composite_logo(base: &mut RgbaImage, logo: &RgbaImage, placement: &Placement) {
    let draw_width = (placement.width * base.width() as f32).round().max(1.0) as u32;
    let draw_height = placement
        .height
        .map(|value| (value * base.height() as f32).round().max(1.0) as u32)
        .unwrap_or_else(|| {
            ((draw_width as f32) * (logo.height() as f32 / logo.width() as f32))
                .round()
                .max(1.0) as u32
        });

    let mut resized = image::imageops::resize(logo, draw_width, draw_height, FilterType::Lanczos3);
    apply_opacity(&mut resized, placement.opacity.unwrap_or(1.0));

    let rotation_radians = -placement.rotation_degrees.unwrap_or(0.0).to_radians();
    let rotated = if rotation_radians.abs() < f32::EPSILON {
        resized
    } else {
        rotate_about_center(
            &resized,
            rotation_radians,
            Interpolation::Bicubic,
            Rgba([0, 0, 0, 0]),
        )
    };

    let center_x = placement.x * base.width() as f32;
    let center_y = placement.y * base.height() as f32;
    let paste_x = (center_x - rotated.width() as f32 / 2.0).round() as i64;
    let paste_y = (center_y - rotated.height() as f32 / 2.0).round() as i64;
    alpha_overlay(base, &rotated, paste_x, paste_y);
}

pub fn apply_texture_adjustment(base: &mut RgbaImage, adjustment: &TextureAdjustment) {
    match adjustment.kind {
        TextureAdjustmentKind::JetBlack => {
            apply_jet_black_finish(base, adjustment.strength.unwrap_or(1.0));
        }
        TextureAdjustmentKind::JetBlackWithIndigoAccents => {
            apply_jet_black_with_indigo_accents(base, adjustment.strength.unwrap_or(1.0));
        }
    }
}

fn apply_opacity(image: &mut RgbaImage, opacity: f32) {
    let opacity = opacity.clamp(0.0, 1.0);
    if opacity >= 1.0 {
        return;
    }

    for pixel in image.pixels_mut() {
        let alpha = ((pixel[3] as f32) * opacity).round().clamp(0.0, 255.0) as u8;
        pixel[3] = alpha;
    }
}

fn apply_jet_black_finish(image: &mut RgbaImage, strength: f32) {
    let strength = strength.clamp(0.0, 1.0);
    if strength <= f32::EPSILON {
        return;
    }

    for pixel in image.pixels_mut() {
        if pixel[3] == 0 {
            continue;
        }

        let red = pixel[0] as f32;
        let green = pixel[1] as f32;
        let blue = pixel[2] as f32;
        let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
        let remapped = ((luminance / 255.0).powf(1.65) * 28.0)
            .round()
            .clamp(0.0, 255.0);

        for channel in 0..3 {
            let original = pixel[channel] as f32;
            let blended = original * (1.0 - strength) + remapped * strength;
            pixel[channel] = blended.round().clamp(0.0, 255.0) as u8;
        }
    }
}

fn apply_jet_black_with_indigo_accents(image: &mut RgbaImage, strength: f32) {
    let strength = strength.clamp(0.0, 1.0);
    if strength <= f32::EPSILON {
        return;
    }

    for pixel in image.pixels_mut() {
        if pixel[3] == 0 {
            continue;
        }

        let red = pixel[0] as f32;
        let green = pixel[1] as f32;
        let blue = pixel[2] as f32;
        let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;

        if is_green_accent(red, green, blue) {
            let normalized_luminance = (luminance / 255.0).clamp(0.0, 1.0);
            let shade_scale = (0.42 + normalized_luminance * 0.78).clamp(0.0, 1.0);
            let indigo = [75.0 * shade_scale, 0.0, 130.0 * shade_scale];
            for channel in 0..3 {
                let original = pixel[channel] as f32;
                let blended = original * (1.0 - strength) + indigo[channel] * strength;
                pixel[channel] = blended.round().clamp(0.0, 255.0) as u8;
            }
            continue;
        }

        let remapped = ((luminance / 255.0).powf(1.65) * 28.0)
            .round()
            .clamp(0.0, 255.0);
        for channel in 0..3 {
            let original = pixel[channel] as f32;
            let blended = original * (1.0 - strength) + remapped * strength;
            pixel[channel] = blended.round().clamp(0.0, 255.0) as u8;
        }
    }
}

fn is_green_accent(red: f32, green: f32, blue: f32) -> bool {
    green >= 48.0 && green > red * 1.12 && green > blue * 1.1
}

fn alpha_overlay(base: &mut RgbaImage, overlay: &RgbaImage, offset_x: i64, offset_y: i64) {
    for (x, y, pixel) in overlay.enumerate_pixels() {
        if pixel[3] == 0 {
            continue;
        }
        let target_x = offset_x + x as i64;
        let target_y = offset_y + y as i64;
        if target_x < 0
            || target_y < 0
            || target_x >= base.width() as i64
            || target_y >= base.height() as i64
        {
            continue;
        }

        let target_pixel = base.get_pixel_mut(target_x as u32, target_y as u32);
        target_pixel.blend(pixel);
    }
}

#[cfg(test)]
mod tests {
    use image::{Rgba, RgbaImage};

    use super::{apply_texture_adjustment, TextureAdjustment, TextureAdjustmentKind};

    #[test]
    fn jet_black_adjustment_desaturates_and_darkens_pixels() {
        let mut image = RgbaImage::from_pixel(1, 1, Rgba([20, 220, 40, 255]));
        apply_texture_adjustment(
            &mut image,
            &TextureAdjustment {
                id: "helmet-jet-black".to_string(),
                target: "third-person-helmet".to_string(),
                kind: TextureAdjustmentKind::JetBlack,
                strength: Some(1.0),
                enabled: Some(true),
            },
        );

        let pixel = image.get_pixel(0, 0);
        assert_eq!(pixel[0], pixel[1]);
        assert_eq!(pixel[1], pixel[2]);
        assert!(
            pixel[0] < 24,
            "jet black finish should push the pixel close to black"
        );
    }

    #[test]
    fn indigo_adjustment_recolors_green_accents() {
        let mut image = RgbaImage::from_pixel(1, 1, Rgba([20, 220, 40, 255]));
        apply_texture_adjustment(
            &mut image,
            &TextureAdjustment {
                id: "torso-indigo".to_string(),
                target: "third-person-torso".to_string(),
                kind: TextureAdjustmentKind::JetBlackWithIndigoAccents,
                strength: Some(1.0),
                enabled: Some(true),
            },
        );

        let pixel = image.get_pixel(0, 0);
        assert!(
            pixel[2] > pixel[0],
            "indigo accent should keep blue above red"
        );
        assert!(pixel[0] > pixel[1], "indigo accent should suppress green");
        assert!(
            pixel[1] < 8,
            "indigo accent should remove the original green channel"
        );
    }
}