doom-eternal 0.1.0

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

use image::{Pixel, Rgba, RgbaImage, imageops::FilterType};
use imageproc::geometric_transformations::{Interpolation, rotate_about_center};
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 placements: Vec<Placement>,
}

#[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)
    }
}

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);
}

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 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);
    }
}