diff --git a/Cargo.lock b/Cargo.lock index ed4aee83..4847c35b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,7 +168,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -224,7 +224,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", "syn_derive", ] @@ -323,12 +323,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.94" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f6e324229dc011159fcc089755d1e2e216a90d43a7dea6853ca740b84f35e7" +checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" dependencies = [ "jobserver", "libc", + "once_cell", ] [[package]] @@ -565,7 +566,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -576,7 +577,7 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" dependencies = [ "darling_core", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -590,6 +591,8 @@ dependencies = [ "lock_api", "once_cell", "parking_lot_core", + "rayon", + "serde", ] [[package]] @@ -623,7 +626,7 @@ dependencies = [ "proc-macro2", "quote", "semver", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -643,7 +646,7 @@ checksum = "61bb5a1014ce6dfc2a378578509abe775a5aa06bff584a547555d9efdb81b926" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -815,7 +818,7 @@ checksum = "b0fa992f1656e1707946bbba340ad244f0814009ef8c0118eb7b658395f19a2e" dependencies = [ "frunk_proc_macro_helpers", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -827,7 +830,7 @@ dependencies = [ "frunk_core", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -839,7 +842,7 @@ dependencies = [ "frunk_core", "frunk_proc_macro_helpers", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -1155,9 +1158,9 @@ dependencies = [ [[package]] name = "gix-index" -version = "0.32.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3383122cf18655ef4c097c0b935bba5eb56983947959aaf3b0ceb1949d4dd371" +checksum = "881ab3b1fa57f497601a5add8289e72a7ae09471fc0b9bbe483b628ae8e418a1" dependencies = [ "bitflags 2.5.0", "bstr", @@ -1199,7 +1202,7 @@ checksum = "1dff438f14e67e7713ab9332f5fd18c8f20eb7eb249494f6c2bf170522224032" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -1565,7 +1568,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.22.3", + "rustls 0.22.4", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1899,7 +1902,7 @@ dependencies = [ "once_cell", "pem", "percent-encoding", - "rustls 0.23.4", + "rustls 0.23.5", "rustls-pemfile", "serde", "serde_json", @@ -1923,7 +1926,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", "termcolor", "thiserror", ] @@ -2116,7 +2119,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -2231,7 +2234,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -2501,9 +2504,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e6cc1e89e689536eb5aeede61520e874df5a4707df811cd5da4aa5fbb2aae19" +checksum = "566cafdd92868e0939d3fb961bd0dc25fcfaaed179291093b3d43e6b3150ea10" dependencies = [ "base64 0.22.0", "bytes", @@ -2523,7 +2526,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls 0.22.3", + "rustls 0.22.4", "rustls-pemfile", "rustls-pki-types", "serde", @@ -2620,6 +2623,7 @@ dependencies = [ "sha2", "thiserror", "toml", + "tracy_full", "twox-hash", "url", "zip", @@ -2653,6 +2657,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.32" @@ -2668,9 +2681,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.3" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99008d7ad0bbbea527ec27bddbc0e432c5b87d8175178cee68d2eec9c4a1813c" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" dependencies = [ "log", "ring", @@ -2682,9 +2695,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.4" +version = "0.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c4d6d8ad9f2492485e13453acbb291dd08f64441b6609c491f1c2cd2c6b4fe1" +checksum = "afabcee0551bd1aa3e18e5adbf2c0544722014b899adb31bd186ec638d3da97e" dependencies = [ "log", "once_cell", @@ -2810,7 +2823,7 @@ checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -2977,9 +2990,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.59" +version = "2.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6531ffc7b071655e4ce2e04bd464c4830bb585a61cabb96cf808f05172615a" +checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" dependencies = [ "proc-macro2", "quote", @@ -2995,7 +3008,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -3048,7 +3061,7 @@ checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] @@ -3130,7 +3143,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" dependencies = [ - "rustls 0.22.3", + "rustls 0.22.4", "rustls-pki-types", "tokio", ] @@ -3144,7 +3157,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.9", + "toml_edit 0.22.12", ] [[package]] @@ -3169,9 +3182,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.9" +version = "0.22.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" +checksum = "d3328d4f68a705b2a4498da1d580585d39a6510f98318a2cec3018a7ec61ddef" dependencies = [ "indexmap", "serde", @@ -3228,6 +3241,26 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracy-client-sys" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d104d610dfa9dd154535102cc9c6164ae1fa37842bc2d9e83f9ac82b0ae0882" +dependencies = [ + "cc", +] + +[[package]] +name = "tracy_full" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f168899957a9ace9328f8f5110c13cc73f9156dcefc706407da1690a9d4aa0b" +dependencies = [ + "once_cell", + "rustc_version", + "tracy-client-sys", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -3368,7 +3401,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", "wasm-bindgen-shared", ] @@ -3402,7 +3435,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3675,7 +3708,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.59", + "syn 2.0.60", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 86c32207..99551722 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,7 @@ serde_json = { version = "1.0", optional = true } lazy_static = { version = "1.4", optional = true } once_cell = { version = "1.19", optional = true } mysql = { git = "https://github.com/ZeWaka/rust-mysql-simple.git", tag = "v25.0.0", default_features = false, optional = true } -dashmap = { version = "5.5", optional = true } +dashmap = { version = "5.5", optional = true, features = ["rayon", "serde"] } zip = { version = "0.6", optional = true } rand = { version = "0.8", optional = true } toml-dep = { version = "0.8.12", package = "toml", optional = true } @@ -63,6 +63,7 @@ dbpnoise = { version = "0.1.2", optional = true } pathfinding = { version = "4.9", optional = true } num-integer = { version = "0.1.46", optional = true } dmi = { version = "0.3.5", optional = true } +tracy_full = { version = "1.7.1", optional = true } [features] default = [ @@ -89,6 +90,7 @@ all = [ "file", "git", "http", + "iconforge", "json", "log", "noise", @@ -133,6 +135,20 @@ hash = [ "serde", "serde_json", ] +iconforge = [ + "dashmap", + "dep:dmi", + "hash", + "image", + "jobs", + "once_cell", + "png", + "rayon", + "serde", + "serde_json", + "tracy_full", + "twox-hash", +] pathfinder = ["num-integer", "pathfinding", "serde", "serde_json"] redis_pubsub = ["flume", "redis", "serde", "serde_json"] redis_reliablequeue = ["flume", "redis", "serde", "serde_json"] diff --git a/README.md b/README.md index 3490242b..49757e38 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ The default features are: Additional features are: * batchnoise: Discrete Batched Perlin-like Noise, fast and multi-threaded - sent over once instead of having to query for every tile. * hash: Faster replacement for `md5`, support for SHA-1, SHA-256, and SHA-512. Requires OpenSSL on Linux. +* iconforge: A much faster replacement for the spritesheet generation system used by [/tg/station]. * pathfinder: An a* pathfinder used for finding the shortest path in a static node map. Not to be used for a non-static map. * redis_pubsub: Library for sending and receiving messages through Redis. * redis_reliablequeue: Library for using a reliable queue pattern through Redis. diff --git a/dmsrc/iconforge.dm b/dmsrc/iconforge.dm new file mode 100644 index 00000000..5f6d9bf0 --- /dev/null +++ b/dmsrc/iconforge.dm @@ -0,0 +1,59 @@ +/// Generates a spritesheet at: [file_path][spritesheet_name]_[size_id].png +/// The resulting spritesheet arranges icons in a random order, with the position being denoted in the "sprites" return value. +/// All icons have the same y coordinate, and their x coordinate is equal to `icon_width * position`. +/// +/// hash_icons is a boolean (0 or 1), and determines if the generator will spend time creating hashes for the output field dmi_hashes. +/// These hashes can be heplful for 'smart' caching (see rustg_iconforge_cache_valid), but require extra computation. +/// +/// Spritesheet will contain all sprites listed within "sprites". +/// "sprites" format: +/// list( +/// "sprite_name" = list( // <--- this list is a [SPRITE_OBJECT] +/// icon_file = 'icons/path_to/an_icon.dmi', +/// icon_state = "some_icon_state", +/// dir = SOUTH, +/// frame = 1, +/// transform = list([TRANSFORM_OBJECT], ...) +/// ), +/// ..., +/// ) +/// TRANSFORM_OBJECT format: +/// list("type" = RUSTG_ICONFORGE_BLEND_COLOR, "color" = "#ff0000", "blend_mode" = ICON_MULTIPLY) +/// list("type" = RUSTG_ICONFORGE_BLEND_ICON, "icon" = [SPRITE_OBJECT], "blend_mode" = ICON_OVERLAY) +/// list("type" = RUSTG_ICONFORGE_SCALE, "width" = 32, "height" = 32) +/// list("type" = RUSTG_ICONFORGE_CROP, "x1" = 1, "y1" = 1, "x2" = 32, "y2" = 32) // (BYOND icons index from 1,1 to the upper bound, inclusive) +/// +/// Returns a SpritesheetResult as JSON, containing fields: +/// list( +/// "sizes" = list("32x32", "64x64", ...), +/// "sprites" = list("sprite_name" = list("size_id" = "32x32", "position" = 0), ...), +/// "dmi_hashes" = list("icons/path_to/an_icon.dmi" = "d6325c5b4304fb03", ...), +/// "sprites_hash" = "a2015e5ff403fb5c", // This is the xxh64 hash of the INPUT field "sprites". +/// "error" = "[A string, empty if there were no errors.]" +/// ) +/// In the case of an unrecoverable panic from within Rust, this function ONLY returns a string containing the error. +#define rustg_iconforge_generate(file_path, spritesheet_name, sprites, hash_icons) RUSTG_CALL(RUST_G, "iconforge_generate")(file_path, spritesheet_name, sprites, "[hash_icons]") +/// Returns a job_id for use with rustg_iconforge_check() +#define rustg_iconforge_generate_async(file_path, spritesheet_name, sprites, hash_icons) RUSTG_CALL(RUST_G, "iconforge_generate_async")(file_path, spritesheet_name, sprites, "[hash_icons]") +/// Returns the status of an async job_id, or its result if it is completed. See RUSTG_JOB DEFINEs. +#define rustg_iconforge_check(job_id) RUSTG_CALL(RUST_G, "iconforge_check")("[job_id]") +/// Clears all cached DMIs and images, freeing up memory. +/// This should be used after spritesheets are done being generated. +#define rustg_iconforge_cleanup RUSTG_CALL(RUST_G, "iconforge_cleanup") +/// Takes in a set of hashes, generate inputs, and DMI filepaths, and compares them to determine cache validity. +/// input_hash: xxh64 hash of "sprites" from the cache. +/// dmi_hashes: xxh64 hashes of the DMIs in a spritesheet, given by `rustg_iconforge_generate` with `hash_icons` enabled. From the cache. +/// sprites: The new input that will be passed to rustg_iconforge_generate(). +/// Returns a CacheResult with the following structure: list( +/// "result": "1" (if cache is valid) or "0" (if cache is invalid) +/// "fail_reason": "" (emtpy string if valid, otherwise a string containing the invalidation reason or an error with ERROR: prefixed.) +/// ) +/// In the case of an unrecoverable panic from within Rust, this function ONLY returns a string containing the error. +#define rustg_iconforge_cache_valid(input_hash, dmi_hashes, sprites) RUSTG_CALL(RUST_G, "iconforge_cache_valid")(input_hash, dmi_hashes, sprites) +/// Returns a job_id for use with rustg_iconforge_check() +#define rustg_iconforge_cache_valid_async(input_hash, dmi_hashes, sprites) RUSTG_CALL(RUST_G, "iconforge_cache_valid_async")(input_hash, dmi_hashes, sprites) + +#define RUSTG_ICONFORGE_BLEND_COLOR "BlendColor" +#define RUSTG_ICONFORGE_BLEND_ICON "BlendIcon" +#define RUSTG_ICONFORGE_CROP "Crop" +#define RUSTG_ICONFORGE_SCALE "Scale" diff --git a/src/byond.rs b/src/byond.rs index 385d5251..5c489037 100644 --- a/src/byond.rs +++ b/src/byond.rs @@ -1,11 +1,17 @@ +use crate::error::Error; use std::{ + backtrace::Backtrace, borrow::Cow, cell::RefCell, ffi::{CStr, CString}, + fs::OpenOptions, + io::Write, os::raw::{c_char, c_int}, slice, + sync::Once, }; +static SET_HOOK: Once = Once::new(); static EMPTY_STRING: c_char = 0; thread_local! { static RETURN_STRING: RefCell = RefCell::new(CString::default()); @@ -50,6 +56,7 @@ macro_rules! byond_fn { pub unsafe extern "C" fn $name( _argc: ::std::os::raw::c_int, _argv: *const *const ::std::os::raw::c_char ) -> *const ::std::os::raw::c_char { + $crate::byond::set_panic_hook(); let closure = || ($body); $crate::byond::byond_return(closure().map(From::from)) } @@ -84,3 +91,52 @@ byond_fn!( Some(env!("CARGO_PKG_VERSION")) } ); + +/// Print any panics before exiting. +pub fn set_panic_hook() { + SET_HOOK.call_once(|| { + std::panic::set_hook(Box::new(|panic_info| { + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open("rustg-panic.log") + .unwrap(); + file.write_all( + panic_info + .payload() + .downcast_ref::<&'static str>() + .map(|payload| payload.to_string()) + .or_else(|| panic_info.payload().downcast_ref::().cloned()) + .unwrap() + .as_bytes(), + ) + .expect("Failed to extract error payload"); + file.write_all(Backtrace::capture().to_string().as_bytes()) + .expect("Failed to extract error backtrace"); + })) + }); +} + +/// Utility for BYOND functions to catch panic unwinds safely and return a Result, as expected. +/// Usage: catch_panic(|| internal_safe_function(arguments)) +pub fn catch_panic(f: F) -> Result +where + F: FnOnce() -> Result + std::panic::UnwindSafe, +{ + match std::panic::catch_unwind(f) { + Ok(o) => o, + Err(e) => { + let message: Option = e + .downcast_ref::<&'static str>() + .map(|payload| payload.to_string()) + .or_else(|| e.downcast_ref::().cloned()); + Err(Error::Panic( + message + .unwrap_or(String::from( + "Failed to stringify panic! Check rustg-panic.log!", + )) + .to_owned(), + )) + } + } +} diff --git a/src/error.rs b/src/error.rs index d56cb5c3..6c3dbc5d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -61,6 +61,11 @@ pub enum Error { #[cfg(feature = "hash")] #[error("Unable to decode hex value.")] HexDecode, + #[cfg(feature = "iconforge")] + #[error("IconForge error: {0}")] + IconForge(String), + #[error("Panic during function execution: {0}")] + Panic(String), } impl From for Error { diff --git a/src/hash.rs b/src/hash.rs index bc67e97b..eba83f0f 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -71,16 +71,21 @@ fn hash_algorithm>(name: &str, bytes: B) -> Result { hasher.write(bytes.as_ref()); Ok(format!("{:x}", hasher.finish())) } + "xxh64_fixed" => { + let mut hasher = XxHash64::with_seed(17479268743136991876); // this seed is just a random number that should stay the same between builds and runs + hasher.write(bytes.as_ref()); + Ok(format!("{:x}", hasher.finish())) + } "base64" => Ok(base64::prelude::BASE64_STANDARD.encode(bytes.as_ref())), _ => Err(Error::InvalidAlgorithm), } } -fn string_hash(algorithm: &str, string: &str) -> Result { +pub fn string_hash(algorithm: &str, string: &str) -> Result { hash_algorithm(algorithm, string) } -fn file_hash(algorithm: &str, path: &str) -> Result { +pub fn file_hash(algorithm: &str, path: &str) -> Result { let mut bytes: Vec = Vec::new(); let mut file = BufReader::new(File::open(path)?); file.read_to_end(&mut bytes)?; diff --git a/src/iconforge.rs b/src/iconforge.rs new file mode 100644 index 00000000..40145dea --- /dev/null +++ b/src/iconforge.rs @@ -0,0 +1,1090 @@ +// DMI spritesheet generator +// Developed by itsmeow +use crate::{ + byond::catch_panic, + error::Error, + hash::{file_hash, string_hash}, + jobs, +}; +use dashmap::DashMap; +use dmi::{ + dirs::Dirs, + icon::{Icon, IconState}, +}; +use image::{Pixel, RgbaImage}; +use once_cell::sync::Lazy; +use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::sync::RwLock; +use std::{ + collections::HashMap, + fs::File, + hash::BuildHasherDefault, + io::BufReader, + sync::{Arc, Mutex}, +}; +use tracy_full::{frame, zone}; +use twox_hash::XxHash64; +type SpriteJsonMap = HashMap, BuildHasherDefault>; +/// This is used to save time decoding 'sprites' a second time between the cache step and the generate step. +static SPRITES_TO_JSON: Lazy>> = Lazy::new(|| { + Arc::new(Mutex::new(HashMap::with_hasher(BuildHasherDefault::< + XxHash64, + >::default()))) +}); +/// A cache of DMI filepath -> Icon objects. +static ICON_FILES: Lazy, BuildHasherDefault>> = + Lazy::new(|| DashMap::with_hasher(BuildHasherDefault::::default())); +/// A cache of icon_hash_input to RgbaImage (with transforms applied! This can only contain COMPLETED sprites). +static ICON_STATES: Lazy>> = + Lazy::new(|| DashMap::with_hasher(BuildHasherDefault::::default())); + +byond_fn!(fn iconforge_generate(file_path, spritesheet_name, sprites, hash_icons) { + let file_path = file_path.to_owned(); + let spritesheet_name = spritesheet_name.to_owned(); + let sprites = sprites.to_owned(); + let hash_icons = hash_icons.to_owned(); + let result = Some(match catch_panic(|| generate_spritesheet(&file_path, &spritesheet_name, &sprites, &hash_icons)) { + Ok(o) => o.to_string(), + Err(e) => e.to_string() + }); + frame!(); + result +}); + +byond_fn!(fn iconforge_generate_async(file_path, spritesheet_name, sprites, hash_icons) { + let file_path = file_path.to_owned(); + let spritesheet_name = spritesheet_name.to_owned(); + let sprites = sprites.to_owned(); + let hash_icons = hash_icons.to_owned(); + Some(jobs::start(move || { + let result = match catch_panic(|| generate_spritesheet(&file_path, &spritesheet_name, &sprites, &hash_icons)) { + Ok(o) => o.to_string(), + Err(e) => e.to_string() + }; + frame!(); + result + })) +}); + +byond_fn!(fn iconforge_check(id) { + Some(jobs::check(id)) +}); + +byond_fn!( + fn iconforge_cleanup() { + ICON_FILES.clear(); + ICON_STATES.clear(); + Some("Ok") + } +); + +byond_fn!(fn iconforge_cache_valid(input_hash, dmi_hashes, sprites) { + let input_hash = input_hash.to_owned(); + let dmi_hashes = dmi_hashes.to_owned(); + let sprites = sprites.to_owned(); + let result = Some(match catch_panic(|| cache_valid(&input_hash, &dmi_hashes, &sprites)) { + Ok(o) => o.to_string(), + Err(e) => e.to_string() + }); + frame!(); + result +}); + +byond_fn!(fn iconforge_cache_valid_async(input_hash, dmi_hashes, sprites) { + let input_hash = input_hash.to_owned(); + let dmi_hashes = dmi_hashes.to_owned(); + let sprites = sprites.to_owned(); + let result = Some(jobs::start(move || { + match catch_panic(|| cache_valid(&input_hash, &dmi_hashes, &sprites)) { + Ok(o) => o.to_string(), + Err(e) => e.to_string() + } + })); + frame!(); + result +}); + +#[derive(Serialize)] +struct SpritesheetResult { + sizes: Vec, + sprites: DashMap>, + dmi_hashes: DashMap, + sprites_hash: String, + error: String, +} + +#[derive(Serialize, Clone)] +struct SpritesheetEntry { + size_id: String, + position: u32, +} + +#[derive(Serialize, Clone, Eq, PartialEq, Hash)] +struct IconObject { + icon_file: String, + icon_state: String, + dir: u8, + frame: u32, + transform: Vec, + transform_hash_input: String, + icon_hash_input: String, +} + +#[derive(Serialize, Deserialize, Clone)] +struct IconObjectIO { + icon_file: String, + icon_state: String, + dir: u8, + frame: u32, + transform: Vec, +} + +impl std::fmt::Display for IconObject { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "IconObject(icon_file={}, icon_state={}, dir={}, frame={})", + self.icon_file, self.icon_state, self.dir, self.frame + ) + } +} + +impl IconObject { + fn to_base(&self) -> Result { + zone!("to_base"); + // This is a micro-op that ends up saving a lot of time. format!() is quite slow when you get down to microseconds. + let mut str_buf = String::with_capacity(self.icon_file.len() + self.icon_state.len() + 4); + str_buf.push_str(&self.icon_file); + str_buf.push_str(&self.icon_state); + str_buf.push_str(&self.dir.to_string()); + str_buf.push_str(&self.frame.to_string()); + Ok(str_buf) + } + + fn gen_icon_hash_input(&mut self) -> Result<(), Error> { + zone!("gen_icon_hash_input"); + let base = self.to_base()?; + { + zone!("transform_to_json"); + let transform_str = serde_json::to_string(&self.transform)?; + self.transform_hash_input = transform_str; + } + let mut str_buf = String::with_capacity(base.len() + self.transform_hash_input.len()); + str_buf.push_str(&base); + str_buf.push_str(&self.transform_hash_input); + self.icon_hash_input = str_buf; + Ok(()) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "type")] +enum TransformIO { + BlendColor { color: String, blend_mode: u8 }, + BlendIcon { icon: IconObjectIO, blend_mode: u8 }, + Scale { width: u32, height: u32 }, + Crop { x1: i32, y1: i32, x2: i32, y2: i32 }, +} + +#[derive(Serialize, Clone, Eq, PartialEq, Hash)] +enum Transform { + BlendColor { color: String, blend_mode: u8 }, + BlendIcon { icon: IconObject, blend_mode: u8 }, + Scale { width: u32, height: u32 }, + Crop { x1: i32, y1: i32, x2: i32, y2: i32 }, +} + +#[derive(Serialize)] +struct CacheResult { + result: String, + fail_reason: String, +} + +fn cache_valid(input_hash: &str, dmi_hashes_in: &str, sprites_in: &str) -> Result { + zone!("cache_valid"); + let sprites_hash = string_hash("xxh64_fixed", sprites_in)?; + if sprites_hash != input_hash { + return Ok(serde_json::to_string::(&CacheResult { + result: String::from("0"), + fail_reason: String::from("Input hash did not match."), + })?); + } + let dmi_hashes: DashMap; + { + zone!("from_json_hashes"); + dmi_hashes = serde_json::from_str::>(dmi_hashes_in)?; + } + let mut sprites_json = SPRITES_TO_JSON.lock().unwrap(); + let sprites = match sprites_json.get(&sprites_hash) { + Some(sprites) => sprites, + None => { + zone!("from_json_sprites"); + { + sprites_json.insert( + sprites_hash.clone(), + serde_json::from_str::>(sprites_in)?, + ); + } + sprites_json.get(&sprites_hash).unwrap() + } + }; + + let dmis: HashSet; + + { + zone!("collect_dmis"); + dmis = sprites + .par_iter() + .flat_map(|(_, icon)| { + icon_to_icons_io(icon) + .into_iter() + .map(|icon| icon.icon_file.clone()) + .collect::>() + }) + .collect(); + } + + drop(sprites_json); + + if dmis.len() > dmi_hashes.len() { + return Ok(serde_json::to_string::(&CacheResult { + result: String::from("0"), + fail_reason: format!("Input hash matched, but more DMIs exist than DMI hashes provided ({} DMIs, {} DMI hashes).", dmis.len(), dmi_hashes.len()), + })?); + } + + let fail_reason: Arc>> = Arc::new(RwLock::new(None)); + { + zone!("check_dmis"); + dmis.into_par_iter().for_each(|dmi_path| { + zone!("check_dmi"); + if fail_reason.read().unwrap().is_some() { + return; + } + match dmi_hashes.get(&dmi_path) { + Some(hash) => { + zone!("hash_dmi"); + match file_hash("xxh64_fixed", &dmi_path) { + Ok(new_hash) => { + zone!("check_match"); + if new_hash != *hash { + if fail_reason.read().unwrap().is_some() { + return; + } + *fail_reason.write().unwrap() = Some(format!("Input hash matched, but dmi_hash was invalid DMI: '{}' (stored hash: {}, new hash: {})", dmi_path, hash.clone(), new_hash)); + } + }, + Err(err) => { + if fail_reason.read().unwrap().is_some() { + return; + } + *fail_reason.write().unwrap() = Some(format!("ERROR: Error while hashing dmi_path '{}': {}", dmi_path, err)); + } + } + } + None => { + if fail_reason.read().unwrap().is_some() { + return; + } + *fail_reason.write().unwrap() = Some(format!("Input hash matched, but no dmi_hash existed for DMI: '{}'", dmi_path)); + } + } + }); + } + if let Some(err) = fail_reason.read().unwrap().clone() { + return Ok(serde_json::to_string::(&CacheResult { + result: String::from("0"), + fail_reason: err, + })?); + } + Ok(serde_json::to_string::(&CacheResult { + result: String::from("1"), + fail_reason: String::from(""), + })?) +} + +fn generate_spritesheet( + file_path: &str, + spritesheet_name: &str, + sprites: &str, + hash_icons: &str, +) -> std::result::Result { + zone!("generate_spritesheet"); + let hash_icons: bool = hash_icons == "1"; + let error = Arc::new(Mutex::new(Vec::::new())); + let dmi_hashes = DashMap::::new(); + + let size_to_icon_objects = Arc::new(Mutex::new(HashMap::>::new())); + let sprites_objects = + DashMap::>::with_hasher( + BuildHasherDefault::::default(), + ); + + let tree_bases = Arc::new(Mutex::new(HashMap::< + String, + Vec<(&String, &IconObject)>, + BuildHasherDefault, + >::with_hasher( + BuildHasherDefault::::default() + ))); + let sprites_hash; + { + zone!("compute_sprites_hash"); + sprites_hash = string_hash("xxh64_fixed", sprites)?; + } + let input = match SPRITES_TO_JSON.lock().unwrap().get(&sprites_hash) { + Some(sprites) => sprites.clone(), + None => { + zone!("from_json_sprites"); // byondapi, save us + serde_json::from_str::>(sprites)? + } + }; + let mut sprites_map = HashMap::::new(); + { + zone!("io_to_mem"); + sprites_map.extend( + input + .into_par_iter() + .map(|(sprite_name, icon)| (sprite_name, icon_from_io(icon))) + .collect::>(), + ); + } + + // Pre-load all the DMIs now. + // This is much faster than doing it as we go (tested!), because sometimes multiple parallel iterators need the DMI. + sprites_map.par_iter().for_each(|(sprite_name, icon)| { + zone!("sprite_to_icons"); + + icon_to_icons(icon) + .into_par_iter() + .for_each(|icon| match icon_to_dmi(icon) { + Ok(_) => { + if hash_icons && !dmi_hashes.contains_key(&icon.icon_file) { + zone!("hash_dmi"); + match file_hash("xxh64_fixed", &icon.icon_file) { + Ok(hash) => { + zone!("insert_dmi_hash"); + dmi_hashes.insert(icon.icon_file.clone(), hash); + } + Err(err) => { + error.lock().unwrap().push(err.to_string()); + } + }; + } + } + Err(err) => error.lock().unwrap().push(err), + }); + + { + zone!("map_to_base"); + let base = match icon.to_base() { + Ok(base) => base, + Err(err) => { + error.lock().unwrap().push(err.to_string()); + return; + } + }; + tree_bases + .lock() + .unwrap() + .entry(base) + .or_default() + .push((sprite_name, icon)); + } + }); + + // cache this here so we don't generate the same string 5000 times + let sprite_name = String::from("N/A, in tree generation stage"); + + // Map duplicate transform sets into a tree. + // This is beneficial in the case where we have the same base image, and the same set of transforms, but change 1 or 2 things at the end. + // We can greatly reduce the amount of RgbaImages created by first finding these. + tree_bases + .lock() + .unwrap() + .par_iter() + .for_each(|(_, icons)| { + zone!("transform_trees"); + let first_icon = match icons.first() { + Some((_, icon)) => icon, + None => { + error + .lock() + .unwrap() + .push(String::from("Somehow found no icon for a tree.")); + return; + } + }; + let (base_image, _) = match icon_to_image(first_icon, &sprite_name, false, false) { + Ok(image) => image, + Err(err) => { + error.lock().unwrap().push(err); + return; + } + }; + let mut no_transforms = Option::<&IconObject>::None; + let unique_icons = DashMap::::new(); + { + zone!("map_unique"); + icons.iter().for_each(|(_, icon)| { + // This will ensure we only map unique transform sets. This also means each IconObject is guaranteed a unique icon_hash + // Since all icons share the same 'base'. + // Also check to see if the icon is already cached. If so, we can ignore this transform chain. + if !ICON_STATES.contains_key(&icon.icon_hash_input) { + unique_icons.insert(icon.icon_hash_input.clone(), icon); + } + if icon.transform.is_empty() { + no_transforms = Some(icon); + } + }); + } + if let Some(entry) = no_transforms { + if let Err(err) = return_image(base_image.clone(), entry) { + error.lock().unwrap().push(err.to_string()); + } + } + { + zone!("transform_all_leaves"); + if let Err(err) = transform_leaves( + &unique_icons.into_iter().map(|(_, v)| v).collect(), + base_image, + 0, + ) { + error.lock().unwrap().push(err); + } + } + }); + + // Pick the specific icon states out of the DMI, also generating their transforms, build the spritesheet metadata. + sprites_map.par_iter().for_each(|sprite_entry| { + zone!("map_sprite"); + let (sprite_name, icon) = sprite_entry; + + // get RgbaImage, it should already be transformed, so it must be cached. + let (image, _) = match icon_to_image(icon, sprite_name, true, true) { + Ok(image) => image, + Err(err) => { + error.lock().unwrap().push(err); + return; + } + }; + + { + zone!("create_game_metadata"); + // Generate the metadata used by the game + let size_id = format!("{}x{}", image.width(), image.height()); + if let Err(err) = return_image(image, icon) { + error.lock().unwrap().push(err.to_string()); + } + let icon_position; + { + zone!("insert_into_size_map"); + // This scope releases the lock on size_to_icon_objects + let mut size_map = size_to_icon_objects.lock().unwrap(); + let vec = (*size_map).entry(size_id.to_owned()).or_default(); + icon_position = vec.len() as u32; + vec.push(icon); + } + + { + zone!("insert_into_sprite_objects"); + sprites_objects.insert( + sprite_name.to_owned(), + SpritesheetEntry { + size_id: size_id.to_owned(), + position: icon_position, + }, + ); + } + } + }); + + // all images have been returned now, so continue... + + // cache this here so we don't generate the same string 5000 times + let sprite_name = String::from("N/A, in final generation stage"); + + // Get all the sprites and spew them onto a spritesheet. + size_to_icon_objects + .lock() + .unwrap() + .par_iter() + .for_each(|(size_id, icon_objects)| { + zone!("join_sprites"); + let file_path = format!("{}{}_{}.png", file_path, spritesheet_name, size_id); + let size_data: Vec<&str> = size_id.split('x').collect(); + let base_width = size_data + .first() + .unwrap() + .to_string() + .parse::() + .unwrap(); + let base_height = size_data + .last() + .unwrap() + .to_string() + .parse::() + .unwrap(); + + let mut final_image = + RgbaImage::new(base_width * icon_objects.len() as u32, base_height); + + for (idx, icon) in icon_objects.iter().enumerate() { + zone!("join_sprite"); + let image = match icon_to_image(icon, &sprite_name, true, true) { + Ok((image, _)) => image, + Err(err) => { + error.lock().unwrap().push(err); + return; + } + }; + let base_x: u32 = base_width * idx as u32; + for x in 0..image.width() { + for y in 0..image.height() { + final_image.put_pixel(base_x + x, y, *image.get_pixel(x, y)) + } + } + if let Err(err) = return_image(image, icon) { + error.lock().unwrap().push(err.to_string()); + } + } + { + zone!("write_spritesheet"); + final_image.save(file_path).err(); + } + }); + + let sizes: Vec = size_to_icon_objects + .lock() + .unwrap() + .iter() + .map(|(k, _v)| k) + .cloned() + .collect(); + + // Collect the game metadata and any errors. + let returned = SpritesheetResult { + sizes, + sprites: sprites_objects, + dmi_hashes, + sprites_hash, + error: error.lock().unwrap().join("\n"), + }; + Ok(serde_json::to_string::(&returned)?) +} + +/// Given an array of 'transform arrays' onto from a shared IconObject base, +/// recursively applies transforms in a tree structure. Maximum transform depth is 128. +fn transform_leaves(icons: &Vec<&IconObject>, image: RgbaImage, depth: u8) -> Result<(), String> { + zone!("transform_leaf"); + if depth > 128 { + return Err(String::from( + "Transform depth exceeded 128. https://www.youtube.com/watch?v=CUjrySBwi5Q", + )); + } + let next_transforms = DashMap::>::new(); + let errors = Mutex::new(Vec::::new()); + + { + zone!("get_next_transforms"); + icons.par_iter().for_each(|icon| { + zone!("collect_icon_transforms"); + if let Some(transform) = icon.transform.get(depth as usize) { + next_transforms + .entry(transform.clone()) + .or_default() + .push(icon); + } + }); + } + + { + zone!("do_next_transforms"); + next_transforms + .into_par_iter() + .for_each(|(transform, mut associated_icons)| { + let mut altered_image; + { + zone!("clone_image"); + altered_image = image.clone(); + } + if let Err(err) = transform_image(&mut altered_image, &transform) { + errors.lock().unwrap().push(err); + } + { + zone!("filter_associated_icons"); + associated_icons + .clone() + .into_iter() + .enumerate() + .for_each(|(idx, icon)| { + if icon.transform.len() as u8 == depth + 1 + && *icon.transform.last().unwrap() == transform + { + associated_icons.swap_remove(idx); + if let Err(err) = return_image(altered_image.clone(), icon) { + errors.lock().unwrap().push(err.to_string()); + } + } + }); + } + if let Err(err) = transform_leaves(&associated_icons, altered_image, depth + 1) { + errors.lock().unwrap().push(err); + } + }); + } + + if !errors.lock().unwrap().is_empty() { + return Err(errors.lock().unwrap().join("\n")); + } + Ok(()) +} + +/// Converts an IO icon to one with icon_hash_input +fn icon_from_io(icon_in: IconObjectIO) -> IconObject { + zone!("icon_from_io"); + // TODO: can probably convert this function to just lazily attaching icostring to a RefCell<> or something + // This alternative type system is too verbose and wasteful of processing time. + // https://doc.rust-lang.org/reference/interior-mutability.html + let mut result = IconObject { + icon_file: icon_in.icon_file, + icon_state: icon_in.icon_state, + dir: icon_in.dir, + frame: icon_in.frame, + transform: icon_in + .transform + .into_iter() + .map(|transform_in| match transform_in { + TransformIO::BlendColor { color, blend_mode } => { + Transform::BlendColor { color, blend_mode } + } + TransformIO::BlendIcon { icon, blend_mode } => Transform::BlendIcon { + icon: icon_from_io(icon), + blend_mode, + }, + TransformIO::Crop { x1, y1, x2, y2 } => Transform::Crop { x1, y1, x2, y2 }, + TransformIO::Scale { width, height } => Transform::Scale { width, height }, + }) + .collect(), + transform_hash_input: String::new(), + icon_hash_input: String::new(), + }; + // This line can panic, but I consider that acceptable considering how annoying "proper" error handling would be + // especially when this failing basically breaks the entire program. The panic will be caught and written to logs anyway. + result.gen_icon_hash_input().unwrap(); + result +} + +/// Takes in an icon and gives a list of nested icons. Also returns a reference to the provided icon in the list. +fn icon_to_icons(icon_in: &IconObject) -> Vec<&IconObject> { + zone!("icon_to_icons"); + let mut icons: Vec<&IconObject> = Vec::new(); + icons.push(icon_in); + for transform in &icon_in.transform { + if let Transform::BlendIcon { icon, .. } = transform { + let nested = icon_to_icons(icon); + for icon in nested { + icons.push(icon) + } + } + } + icons +} + +/// icon_to_icons but for IO icons. +fn icon_to_icons_io(icon_in: &IconObjectIO) -> Vec<&IconObjectIO> { + zone!("icon_to_icons_io"); + let mut icons: Vec<&IconObjectIO> = Vec::new(); + icons.push(icon_in); + for transform in &icon_in.transform { + if let TransformIO::BlendIcon { icon, .. } = transform { + let nested = icon_to_icons_io(icon); + for icon in nested { + icons.push(icon) + } + } + } + icons +} + +/// Given an IconObject, returns a DMI Icon structure and caches it. +fn icon_to_dmi(icon: &IconObject) -> Result, String> { + zone!("icon_to_dmi"); + let icon_path = &icon.icon_file; + { + zone!("check_dmi_exists"); + if let Some(found) = ICON_FILES.get(icon_path) { + return Ok(found.clone()); + } + } + let icon_file = match File::open(icon_path) { + Ok(icon_file) => icon_file, + Err(err) => { + return Err(format!("Failed to open DMI '{}' - {}", icon_path, err)); + } + }; + let reader = BufReader::new(icon_file); + let dmi: Icon; + { + zone!("parse_dmi"); + dmi = match Icon::load(reader) { + Ok(dmi) => dmi, + Err(err) => { + return Err(format!("DMI '{}' failed to parse - {}", icon_path, err)); + } + }; + } + { + zone!("insert_dmi"); + let dmi_arc = Arc::new(dmi); + let other_arc = dmi_arc.clone(); + // Cache it for later, saving future DMI parsing operations, which are very slow. + ICON_FILES.insert(icon_path.to_owned(), dmi_arc); + Ok(other_arc) + } +} + +/// Takes an IconObject, gets its DMI, then picks out a RgbaImage for the IconState. +/// Returns with True if the RgbaImage is pre-cached (and shouldn't have new transforms applied) +/// Gives ownership over the image. Please return when you are done <3 (via return_image) +fn icon_to_image( + icon: &IconObject, + sprite_name: &String, + cached: bool, + must_be_cached: bool, +) -> Result<(RgbaImage, bool), String> { + zone!("icon_to_image"); + if cached { + zone!("check_rgba_image_exists"); + if icon.icon_hash_input.is_empty() { + return Err(format!( + "No icon_hash generated for {} {}", + icon, sprite_name + )); + } + if let Some(entry) = ICON_STATES.get(&icon.icon_hash_input) { + return Ok((entry.value().clone(), true)); + } + if must_be_cached { + return Err(String::from("Image not found in cache!")); + } + } + let dmi = icon_to_dmi(icon)?; + let mut matched_state: Option<&IconState> = None; + { + zone!("match_icon_state"); + for icon_state in &dmi.states { + if icon_state.name == icon.icon_state { + matched_state = Some(icon_state); + break; + } + } + } + let state = match matched_state { + Some(state) => state, + None => { + return Err(format!( + "Could not find associated icon state {} for {}", + icon.icon_state, sprite_name + )); + } + }; + + let dir = match Dirs::from_bits(icon.dir) { + Some(dir) => dir, + None => { + return Err(format!( + "Invalid dir number {} for {}", + icon.dir, sprite_name + )); + } + }; + Ok(match state.get_image(&dir, icon.frame) { + Ok(image) => (image.to_rgba8(), false), + Err(err) => { + return Err(format!("Error getting image for {}: {}", sprite_name, err)); + } + }) +} + +/// Gives an image back to the cache, after it is done being used. +fn return_image(image: RgbaImage, icon: &IconObject) -> Result<(), Error> { + zone!("insert_rgba_image"); + if icon.icon_hash_input.is_empty() { + return Err(Error::IconForge(format!( + "No icon_hash_input generated for {}", + icon + ))); + } + ICON_STATES.insert(icon.icon_hash_input.to_owned(), image); + Ok(()) +} + +fn apply_all_transforms(image: &mut RgbaImage, transforms: &Vec) -> Result<(), String> { + let mut errors = Vec::::new(); + for transform in transforms { + if let Err(error) = transform_image(image, transform) { + errors.push(error); + } + } + if !errors.is_empty() { + return Err(errors.join("\n")); + } + Ok(()) +} + +/// Applies transforms to a RgbaImage. +fn transform_image(image: &mut RgbaImage, transform: &Transform) -> Result<(), String> { + zone!("transform_image"); + match transform { + Transform::BlendColor { color, blend_mode } => { + zone!("blend_color"); + let mut color2: [u8; 4] = [0, 0, 0, 255]; + { + zone!("from_hex"); + let mut hex: String = color.to_owned(); + if hex.starts_with('#') { + hex = hex[1..].to_string(); + } + if hex.len() == 6 { + hex += "ff"; + } + + if let Err(err) = hex::decode_to_slice(hex, &mut color2) { + return Err(format!("Decoding hex color {} failed: {}", color, err)); + } + } + for x in 0..image.width() { + for y in 0..image.height() { + let px = image.get_pixel_mut(x, y); + let pixel = px.channels(); + let blended = Rgba::blend_u8(pixel, &color2, *blend_mode); + + *px = image::Rgba::(blended); + } + } + } + Transform::BlendIcon { icon, blend_mode } => { + zone!("blend_icon"); + let (mut other_image, cached) = + icon_to_image(icon, &format!("Transform blend_icon {}", icon), true, false)?; + + if !cached { + apply_all_transforms(&mut other_image, &icon.transform)?; + }; + for x in 0..std::cmp::min(image.width(), other_image.width()) { + for y in 0..std::cmp::min(image.width(), other_image.width()) { + let px1 = image.get_pixel_mut(x, y); + let px2 = other_image.get_pixel(x, y); + let pixel_1 = px1.channels(); + let pixel_2 = px2.channels(); + + let blended = Rgba::blend_u8(pixel_1, pixel_2, *blend_mode); + + *px1 = image::Rgba::(blended); + } + } + if let Err(err) = return_image(other_image, icon) { + return Err(err.to_string()); + } + } + Transform::Scale { width, height } => { + zone!("scale"); + let old_width = image.width() as usize; + let old_height = image.height() as usize; + let x_ratio = old_width as f32 / *width as f32; + let y_ratio = old_height as f32 / *height as f32; + let mut new_image = RgbaImage::new(*width, *height); + for x in 0..(*width) { + for y in 0..(*height) { + let old_x = (x as f32 * x_ratio).floor() as u32; + let old_y = (y as f32 * y_ratio).floor() as u32; + new_image.put_pixel(x, y, *image.get_pixel(old_x, old_y)); + } + } + *image = new_image; + } + Transform::Crop { x1, y1, x2, y2 } => { + zone!("crop"); + + let i_width = image.width(); + let i_height = image.height(); + let mut x1 = *x1; + let mut y1 = *y1; + let mut x2 = *x2; + let mut y2 = *y2; + // BYOND indexes from 1,1! how silly of them. We'll just fix this here. + // Crop(1,1,1,1) is a valid statement. Save us. + y1 -= 1; + x1 -= 1; + if x2 <= x1 || y2 <= y1 { + return Err(format!( + "Invalid bounds {} {} to {} {} in crop transform", + x1, y1, x2, y2 + )); + } + + // Convert from BYOND (0,0 is bottom left) to Rust (0,0 is top left) + // BYOND also includes the upper bound + let y2_old = y2; + y2 = i_height as i32 - y1; + y1 = i_height as i32 - y2_old; + + // Check for silly expansion crops and add transparency in the gaps. + if x1 < 0 || x2 > i_width as i32 || y1 < 0 || y2 > i_height as i32 { + // The amount the blank icon's size should increase by. + let mut width_inc: u32 = (x2 - i_width as i32).max(0) as u32; + let mut height_inc: u32 = (y2 - i_height as i32).max(0) as u32; + // Where to position the icon within our blank space. + let mut x_offset: u32 = 0; + let mut y_offset: u32 = 0; + // Make room to place the image further in, and change our bounds to match. + if x1 < 0 { + x2 += x1.abs(); + x_offset += x1.unsigned_abs(); + width_inc += x1.unsigned_abs(); + x1 = 0; + } + if y1 < 0 { + y2 += y1.abs(); + y_offset += y1.unsigned_abs(); + height_inc += y1.unsigned_abs(); + y1 = 0; + } + let mut blank_img: image::ImageBuffer, Vec> = + RgbaImage::from_fn(i_width + width_inc, i_height + height_inc, |_x, _y| { + image::Rgba([0, 0, 0, 0]) + }); + + image::imageops::replace(&mut blank_img, image, x_offset as i64, y_offset as i64); + *image = image::imageops::crop_imm( + &blank_img, + x1 as u32, + y1 as u32, + (x2 - x1) as u32, + (y2 - y1) as u32, + ) + .to_image(); + } else { + // Normal bounds crop. Hooray! + *image = image::imageops::crop_imm( + image, + x1 as u32, + y1 as u32, + (x2 - x1) as u32, + (y2 - y1) as u32, + ) + .to_image(); + } + } + } + Ok(()) +} + +#[derive(Clone)] +struct Rgba { + r: f32, + g: f32, + b: f32, + a: f32, +} + +impl Rgba { + fn into_array(self) -> [u8; 4] { + [ + self.r.round() as u8, + self.g.round() as u8, + self.b.round() as u8, + self.a.round() as u8, + ] + } + + fn from_array(rgba: &[u8]) -> Rgba { + Self { + r: rgba[0] as f32, + g: rgba[1] as f32, + b: rgba[2] as f32, + a: rgba[3] as f32, + } + } + + fn map_each(color: &Rgba, color2: &Rgba, rgb_fn: F, a_fn: T) -> Rgba + where + F: Fn(f32, f32) -> f32, + T: Fn(f32, f32) -> f32, + { + Rgba { + r: rgb_fn(color.r, color2.r), + g: rgb_fn(color.g, color2.g), + b: rgb_fn(color.b, color2.b), + a: a_fn(color.a, color2.a), + } + } + + fn map_each_a(color: &Rgba, color2: &Rgba, rgb_fn: F, a_fn: T) -> Rgba + where + F: Fn(f32, f32, f32, f32) -> f32, + T: Fn(f32, f32) -> f32, + { + Rgba { + r: rgb_fn(color.r, color2.r, color.a, color2.a), + g: rgb_fn(color.g, color2.g, color.a, color2.a), + b: rgb_fn(color.b, color2.b, color.a, color2.a), + a: a_fn(color.a, color2.a), + } + } + + /// Takes two [u8; 4]s, converts them to Rgba structs, then blends them according to blend_mode by calling blend(). + fn blend_u8(color: &[u8], other_color: &[u8], blend_mode: u8) -> [u8; 4] { + Rgba::from_array(color) + .blend(&Rgba::from_array(other_color), blend_mode) + .into_array() + } + + /// Blends two colors according to blend_mode. The numbers correspond to BYOND blend modes. + fn blend(&self, other_color: &Rgba, blend_mode: u8) -> Rgba { + match blend_mode { + 0 => Rgba::map_each(self, other_color, |c1, c2| c1 + c2, f32::min), + 1 => Rgba::map_each(self, other_color, |c1, c2| c1 - c2, f32::min), + 2 => Rgba::map_each( + self, + other_color, + |c1, c2| c1 * c2 / 255.0, + |a1: f32, a2: f32| a1 * a2 / 255.0, + ), + 3 => Rgba::map_each_a( + self, + other_color, + |c1, c2, c1_a, c2_a| { + if c1_a == 0.0 { + return c2; + } + c1 + (c2 - c1) * c2_a / 255.0 + }, + |a1, a2| { + let high = f32::max(a1, a2); + let low = f32::min(a1, a2); + high + (high * low / 255.0) + }, + ), + 6 => Rgba::map_each_a( + other_color, + self, + |c1, c2, c1_a, c2_a| { + if c1_a == 0.0 { + return c2; + } + c1 + (c2 - c1) * c2_a / 255.0 + }, + |a1, a2| { + let high = f32::max(a1, a2); + let low = f32::min(a1, a2); + high + (high * low / 255.0) + }, + ), + _ => self.clone(), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index cc7d5bcb..51b46708 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,8 @@ pub mod git; pub mod hash; #[cfg(feature = "http")] pub mod http; +#[cfg(feature = "iconforge")] +pub mod iconforge; #[cfg(feature = "json")] pub mod json; #[cfg(feature = "log")]