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