Initial commit.
Signed-off-by: Andreas Widen <aw@luflow.net>
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
CHANGELOG.md
|
||||||
|
/target
|
||||||
|
flow-texpack.log
|
||||||
|
out
|
||||||
|
test_data/cli_*
|
||||||
|
test_data/atlas_*
|
||||||
9
AUTHORS
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
Maintainer: Andreas Widen <aw@luflow.net>
|
||||||
|
License: zlib
|
||||||
|
URL: https://www.luflow.net
|
||||||
|
|
||||||
|
Authors
|
||||||
|
=======
|
||||||
|
|
||||||
|
Andreas Widen <aw@luflow.net>
|
||||||
1761
Cargo.lock
generated
Normal file
26
Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "flow-texpack"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
authors = ["Andreas Widen <aw@luflow.net>"]
|
||||||
|
description = "flow-texpack is a program that will allow you to generate texture atlas from input images (BMP, HDR, JPG, PNG, TGA, TIFF, WEBP). The application generates both texture atlas and descriptions file that can be read by a game."
|
||||||
|
license = "Zlib"
|
||||||
|
repository = "https://luflow.net/git/hfsoulz/flow-texpack.git"
|
||||||
|
readme = "README.md"
|
||||||
|
documentation = "https://luflow.net/git/hfsoulz/flow-texpack.git"
|
||||||
|
keywords = ["texture atlas", "image atlas", "generator"]
|
||||||
|
exclude = [".github", "/ci/*", "*.log"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
flow-rbp = { git = "https://luflow.net/git/hfsoulz/flow-rbp.git", tag = "v0.1.0" }
|
||||||
|
tokio = { version = "1.52.3", features = ["macros", "fs", "io-util", "rt-multi-thread"] }
|
||||||
|
async-recursion = "1.1.1"
|
||||||
|
rich_rust = "0.2.1"
|
||||||
|
image = "0.25.10"
|
||||||
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
fern = "0.7.1"
|
||||||
|
log = "0.4.32"
|
||||||
|
humantime = "2.3.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2.2.2"
|
||||||
18
LICENSE
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
Copyright (C) 2026-2026 Andreas Widen <aw@luflow.net>
|
||||||
|
|
||||||
|
This software is provided 'as-is', without any express or implied
|
||||||
|
warranty. In no event will the authors be held liable for any damages
|
||||||
|
arising from the use of this software.
|
||||||
|
|
||||||
|
Permission is granted to anyone to use this software for any purpose,
|
||||||
|
including commercial applications, and to alter it and redistribute it
|
||||||
|
freely, subject to the following restrictions:
|
||||||
|
|
||||||
|
1. The origin of this software must not be misrepresented; you must not
|
||||||
|
claim that you wrote the original software. If you use this software
|
||||||
|
in a product, an acknowledgment in the product documentation would be
|
||||||
|
appreciated but is not required.
|
||||||
|
2. Altered source versions must be plainly marked as such, and must not be
|
||||||
|
misrepresented as being the original software.
|
||||||
|
3. This notice may not be removed or altered from any source distribution.
|
||||||
84
README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# flow-texpack
|
||||||
|
|
||||||
|
`flow-texpack` is a program that will allow you to generate texture atlas from
|
||||||
|
input images (BMP, HDR, JPG, PNG, TGA, TIFF, WEBP). The application generates
|
||||||
|
both texture atlas and descriptions file that can be read by a game.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install `Rust` from your package manager or by downloading from here:
|
||||||
|
[https://rust-lang.org/](https://rust-lang.org/).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Build using release mode and install locally (on GNU/Linux this is `~/.cargo/bin`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install --locked --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Show available options:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -h
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Generate from input `data/characters` and `data/tiles`, write output to
|
||||||
|
`out/atlas` and enable the options: `premultiply` pixels by their alpha
|
||||||
|
channel, `trim` excess transparency off the textures, `remove duplicate
|
||||||
|
textures` from the atlas, enable `rotation` of textures 90 degrees clockwise,
|
||||||
|
`pad` each texture by 2 pixels and finally enable `verbose` output mode.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -i data/characters data/tiles -o out/atlas -m -t -u -r -p 2 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable `load filter` so that only `TGA` images are included in the texture atlas:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -i data/tiles -o out/atlas --load-filter tga -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable rect heuristic `AreaFit`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -i data/tiles -o out/atlas --rect-heuristic area-fit -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable output `atlas size` of **2048x2048**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -i data/tiles -o out/atlas --atlas-size pot2048 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Read input files/directories from `input.txt` but exclude all in `exclude.txt`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack --input-file input.txt --exlude-file exclude.txt -o out/atlas -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`Adjust atlas size` automatically so that texture will fit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -i data/characters -o out/atlas --adjust-size -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`Adjust texture size` so that it will fit given atlas size:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flow-texpack -i data/characters -o out/atlas --adjust-fit -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## LICENSE
|
||||||
|
|
||||||
|
See the file 'LICENSE' for license information.
|
||||||
81
cliff.toml
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# git-cliff ~ configuration file
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# A Tera template to be rendered as the changelog's header.
|
||||||
|
# See https://keats.github.io/tera/docs/#introduction
|
||||||
|
header = """
|
||||||
|
# Changelog\n
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
|
||||||
|
"""
|
||||||
|
# A Tera template to be rendered for each release in the changelog.
|
||||||
|
# See https://keats.github.io/tera/docs/#introduction
|
||||||
|
body = """
|
||||||
|
{% if version -%}
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else -%}
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif -%}
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
# A Tera template to be rendered as the changelog's footer.
|
||||||
|
# See https://keats.github.io/tera/docs/#introduction
|
||||||
|
footer = """
|
||||||
|
{% for release in releases -%}
|
||||||
|
{% if release.version -%}
|
||||||
|
{% if release.previous.version -%}
|
||||||
|
[{{ release.version | trim_start_matches(pat="v") }}]: \
|
||||||
|
https://luflow.net/git/hfsoulz/flow-texpack.git\
|
||||||
|
/compare/{{ release.previous.version }}..{{ release.version }}
|
||||||
|
{% else -%}
|
||||||
|
[{{ release.version | trim_start_matches(pat="v") }}]: \
|
||||||
|
https://luflow.net/git/hfsoulz/flow-texpack.git\
|
||||||
|
/releases/tag/{{ release.version }}
|
||||||
|
{% endif -%}
|
||||||
|
{% else -%}
|
||||||
|
[unreleased]: https://luflow.net/git/hfsoulz/flow-texpack.git\
|
||||||
|
/compare/{{ release.previous.version }}..HEAD
|
||||||
|
{% endif -%}
|
||||||
|
{% endfor %}
|
||||||
|
<!-- generated by git-cliff -->
|
||||||
|
"""
|
||||||
|
# Remove leading and trailing whitespaces from the changelog's body.
|
||||||
|
trim = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse commits according to the conventional commits specification.
|
||||||
|
# See https://www.conventionalcommits.org
|
||||||
|
conventional_commits = true
|
||||||
|
# Exclude commits that do not match the conventional commits specification.
|
||||||
|
filter_unconventional = false
|
||||||
|
# An array of regex based parsers for extracting data from the commit message.
|
||||||
|
# Assigns commits to groups.
|
||||||
|
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^[a|A]dd", group = "Added" },
|
||||||
|
{ message = "^[s|S]upport", group = "Added" },
|
||||||
|
{ message = "^[r|R]emove", group = "Removed" },
|
||||||
|
{ message = "^.*: add", group = "Added" },
|
||||||
|
{ message = "^.*: support", group = "Added" },
|
||||||
|
{ message = "^.*: remove", group = "Removed" },
|
||||||
|
{ message = "^.*: delete", group = "Removed" },
|
||||||
|
{ message = "^test", group = "Fixed" },
|
||||||
|
{ message = "^fix", group = "Fixed" },
|
||||||
|
{ message = "^.*: fix", group = "Fixed" },
|
||||||
|
{ message = "^.*", group = "Changed" },
|
||||||
|
]
|
||||||
|
# Prevent commits that are breaking from being excluded by commit parsers.
|
||||||
|
filter_commits = false
|
||||||
|
# Order releases topologically instead of chronologically.
|
||||||
|
topo_order = false
|
||||||
|
# Order of commits in each group/release within the changelog.
|
||||||
|
# Allowed values: newest, oldest
|
||||||
|
sort_commits = "oldest"
|
||||||
42
src/lib.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
// zlib License (see LICENSE)
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod texpack;
|
||||||
|
|
||||||
|
// re-export types:
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::App;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::get_atlas_image_extension;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::create_dir_all;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::remove_dir_all;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::remove_file;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::exists_dir;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::exists_file;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::app::write_file_sync;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::packer::Packer;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::packer::PackerError;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::texture::Texture;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub use crate::texpack::texture::TextureError;
|
||||||
10
src/main.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
// zlib License (see LICENSE)
|
||||||
|
|
||||||
|
use flow_texpack::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.run().await;
|
||||||
|
}
|
||||||
6
src/texpack.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
// zlib License (see LICENSE)
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod packer;
|
||||||
|
pub mod texture;
|
||||||
1138
src/texpack/app.rs
Normal file
901
src/texpack/packer.rs
Normal file
|
|
@ -0,0 +1,901 @@
|
||||||
|
// flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
// zlib License (see LICENSE)
|
||||||
|
|
||||||
|
use log::info;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use flow_rbp::FreeRectHeuristic;
|
||||||
|
use flow_rbp::RectsBinPack;
|
||||||
|
|
||||||
|
use crate::Texture;
|
||||||
|
use crate::texpack::app::AtlasImage;
|
||||||
|
|
||||||
|
/// is the minimum allowed size.
|
||||||
|
pub const MIN_SIZE: u32 = 64;
|
||||||
|
/// is the maximum allowed size.
|
||||||
|
pub const MAX_SIZE: u32 = 8192;
|
||||||
|
|
||||||
|
/// Specifies the different error types that can occur.
|
||||||
|
#[derive(PartialEq, Clone, Debug)]
|
||||||
|
pub enum PackerError {
|
||||||
|
/// Invalid argument
|
||||||
|
InvalidArg,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the properties of a `Point`.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Point {
|
||||||
|
/// is the x offset.
|
||||||
|
pub x: i32,
|
||||||
|
/// is the y offset.
|
||||||
|
pub y: i32,
|
||||||
|
/// is the duplicate ID.
|
||||||
|
pub duplicate_id: usize,
|
||||||
|
/// is the flag determining whether rotated or not.
|
||||||
|
pub rotate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Point {
|
||||||
|
/// Instantiates a new `Point` instance.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
duplicate_id: 0,
|
||||||
|
rotate: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the properties of a `PackerState`.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PackerState {
|
||||||
|
/// is the width.
|
||||||
|
pub width: u32,
|
||||||
|
/// is the height.
|
||||||
|
pub height: u32,
|
||||||
|
/// is the padding.
|
||||||
|
pub padding: i32,
|
||||||
|
/// is the flag determining whether mip maps should be generated (rendering hint).
|
||||||
|
pub generate_mipmaps: bool,
|
||||||
|
/// is the vector holding the textures to pack.
|
||||||
|
pub textures: Vec<Texture>,
|
||||||
|
/// is the vector holding the points used for `unique` lookups.
|
||||||
|
pub points: Vec<Point>,
|
||||||
|
/// is the hash map holding texture hash and duplicate_id.
|
||||||
|
pub duplicates: HashMap<u64, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackerState {
|
||||||
|
/// Instantiates a new `PackerState` instance.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `width` - is the width.
|
||||||
|
/// * `height` - is the height.
|
||||||
|
/// * `padding` - is the padding.
|
||||||
|
/// * `generate_mipmaps` - is the flag determining whether to generate mip maps (rendering hint)
|
||||||
|
/// or not.
|
||||||
|
pub fn new(width: u32, height: u32, padding: i32, generate_mipmaps: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
padding,
|
||||||
|
generate_mipmaps,
|
||||||
|
textures: Vec::new(),
|
||||||
|
points: Vec::new(),
|
||||||
|
duplicates: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the properties of a `Packer`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Packer {
|
||||||
|
/// is the `PackerState` protected by a `Mutex`.
|
||||||
|
pub state: Mutex<PackerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Packer {
|
||||||
|
/// Instantiates a new `Packer` instance.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `width` - is the width.
|
||||||
|
/// * `height` - is the height.
|
||||||
|
/// * `padding` - is the padding.
|
||||||
|
/// * `generate_mipmaps` - is the flag determining whether to generate mip maps (rendering hint)
|
||||||
|
/// or not.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`InvalidArg`](crate::texpack::packer::PackerError) error is returned if:
|
||||||
|
/// `width != SIZE_IN_POWER_OF_TWO || height != SIZE_IN_POWER_OF_TWO ||
|
||||||
|
/// width < 64 || width > 8192 ||
|
||||||
|
/// height < 64 || height > 8192`.
|
||||||
|
pub fn new(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
padding: i32,
|
||||||
|
generate_mipmaps: bool,
|
||||||
|
) -> Result<Self, PackerError> {
|
||||||
|
// make sure width/height is power-of-two and 64 - 8192 in size:
|
||||||
|
if width >= MIN_SIZE
|
||||||
|
&& width <= MAX_SIZE
|
||||||
|
&& height >= MIN_SIZE
|
||||||
|
&& height <= MAX_SIZE
|
||||||
|
&& (width & (width - 1)) == 0
|
||||||
|
&& (height & (height - 1)) == 0
|
||||||
|
{
|
||||||
|
Ok(Self {
|
||||||
|
state: Mutex::new(PackerState::new(width, height, padding, generate_mipmaps)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(PackerError::InvalidArg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a shared `Arc` `Packer` instance.
|
||||||
|
pub fn shared(self) -> Arc<Self> {
|
||||||
|
Arc::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packs all textures that will fit from given `textures` and pops each packed from vector.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `textures` - is the vector holding the textures to pack.
|
||||||
|
/// * `unique` - is a flag determining whether to include only unique textures or not (a
|
||||||
|
/// unique texture is determined by its combined hash value of `width`, `height` and `buffer`).
|
||||||
|
/// * `rotate` - is a flag determining whether rotation of textures is allowed or not.
|
||||||
|
/// * `square` - is a flag determining whether packers size must be POWER OF TWO in size for both width
|
||||||
|
/// and height.
|
||||||
|
/// * `adjust_fit` - is a flag determining whether to adjust fit automatically or not.
|
||||||
|
/// * `heuristic` - is the heuristic method to use for determining where to place the texture.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If packing fails.
|
||||||
|
pub fn pack(
|
||||||
|
&mut self,
|
||||||
|
textures: &mut Vec<Texture>,
|
||||||
|
unique: bool,
|
||||||
|
rotate: bool,
|
||||||
|
square: bool,
|
||||||
|
adjust_size: bool,
|
||||||
|
heuristic: FreeRectHeuristic,
|
||||||
|
) {
|
||||||
|
assert!(textures.len() > 0);
|
||||||
|
|
||||||
|
let mut exists_larger = false;
|
||||||
|
if !square {
|
||||||
|
exists_larger = self.exists_larger_texture(textures);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure each texture fit within size:
|
||||||
|
if adjust_size {
|
||||||
|
self.adjust_size_to_fit(textures);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lock = self.state.lock().unwrap();
|
||||||
|
let mut rbp = RectsBinPack::new(
|
||||||
|
lock.width.try_into().unwrap(),
|
||||||
|
lock.height.try_into().unwrap(),
|
||||||
|
rotate,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut ww: u32 = 0;
|
||||||
|
let mut hh: u32 = 0;
|
||||||
|
while !textures.is_empty() {
|
||||||
|
if let Some(texture) = textures.last() {
|
||||||
|
if unique {
|
||||||
|
if let Some(value) = lock.duplicates.get(&texture.hash_value) {
|
||||||
|
if let Some(point) = lock.points.get(*value) {
|
||||||
|
info!(
|
||||||
|
"Texture '{}' with hash: {} is not unique (not packed but will be added in descriptor)",
|
||||||
|
texture.file_name, texture.hash_value
|
||||||
|
);
|
||||||
|
let mut p = point.clone();
|
||||||
|
p.duplicate_id = *value;
|
||||||
|
lock.points.push(p);
|
||||||
|
lock.textures.push(texture.clone());
|
||||||
|
textures.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let tw: i32 = texture.width.try_into().unwrap();
|
||||||
|
let th: i32 = texture.height.try_into().unwrap();
|
||||||
|
let width: i32 = tw + lock.padding;
|
||||||
|
let height: i32 = th + lock.padding;
|
||||||
|
if let Some(rect) = rbp.insert(width, height, heuristic.clone()) {
|
||||||
|
if unique {
|
||||||
|
let num_points = lock.points.len();
|
||||||
|
lock.duplicates.insert(texture.hash_value, num_points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if we rotated:
|
||||||
|
let mut p = Point::new();
|
||||||
|
p.x = rect.x;
|
||||||
|
p.y = rect.y;
|
||||||
|
p.duplicate_id = std::usize::MAX;
|
||||||
|
p.rotate = rotate && tw != rect.width - lock.padding;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Packed '{}' w: {} h: {} rotated: {} hash: {}",
|
||||||
|
texture.file_name,
|
||||||
|
texture.width,
|
||||||
|
texture.height,
|
||||||
|
p.rotate,
|
||||||
|
texture.hash_value
|
||||||
|
);
|
||||||
|
lock.points.push(p);
|
||||||
|
lock.textures.push(texture.clone());
|
||||||
|
textures.pop();
|
||||||
|
|
||||||
|
ww = std::cmp::max((rect.x + rect.width).try_into().unwrap(), ww);
|
||||||
|
hh = std::cmp::max((rect.y + rect.height).try_into().unwrap(), hh);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panic!("texture.last() failed!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tweak power-of-two size so that it's optimized for largest found width/height:
|
||||||
|
if !square && !exists_larger {
|
||||||
|
while lock.width / 2 >= ww {
|
||||||
|
lock.width /= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
while lock.height / 2 >= hh {
|
||||||
|
lock.height /= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the packed textures to disk.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `file_path` - is the output file path.
|
||||||
|
/// * `image_type` - is the output image type.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If save fails.
|
||||||
|
pub fn save_image(&self, file_path: &PathBuf, image_type: AtlasImage) {
|
||||||
|
let lock = self.state.lock().unwrap();
|
||||||
|
let mut texture = Texture::with_details(lock.width, lock.height).unwrap();
|
||||||
|
|
||||||
|
for i in 0..lock.textures.len() {
|
||||||
|
if let Some(src) = lock.textures.get(i)
|
||||||
|
&& let Some(point) = lock.points.get(i)
|
||||||
|
{
|
||||||
|
if point.duplicate_id == std::usize::MAX {
|
||||||
|
if point.rotate {
|
||||||
|
texture.copy_pixels_rot_90cw(
|
||||||
|
src,
|
||||||
|
point.x.try_into().unwrap(),
|
||||||
|
point.y.try_into().unwrap(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
texture.copy_pixels(
|
||||||
|
src,
|
||||||
|
point.x.try_into().unwrap(),
|
||||||
|
point.y.try_into().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
texture.save(file_path, image_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the atlas descriptor to disk in JSON format.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `file` - is a reference to already opened for write `File` to use when writing.
|
||||||
|
/// * `file_name` - is the name of the atlas image.
|
||||||
|
/// * `image_ext` - is the extension of the atlas image ("png", "tga" etc).
|
||||||
|
pub fn save_json(&self, file: &mut File, file_name: &str, image_ext: &str) {
|
||||||
|
let lock = self.state.lock().unwrap();
|
||||||
|
file.write(String::from("\t\t\t{\n").as_bytes()).unwrap();
|
||||||
|
file.write(format!("\t\t\t\t\"n\": \"{}.{}\",\n", file_name, image_ext).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\t\t\t\t\"numImages\": {},\n", lock.textures.len()).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\t\t\t\t\"width\": {},\n", lock.width).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\t\t\t\t\"height\": {},\n", lock.height).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(
|
||||||
|
format!(
|
||||||
|
"\t\t\t\t\"generateMipMaps\": {},\n",
|
||||||
|
lock.generate_mipmaps as u8
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
file.write(String::from("\t\t\t\t\"img\":\n").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(String::from("\t\t\t\t[\n").as_bytes()).unwrap();
|
||||||
|
|
||||||
|
for i in 0..lock.textures.len() {
|
||||||
|
if let Some(texture) = lock.textures.get(i)
|
||||||
|
&& let Some(point) = lock.points.get(i)
|
||||||
|
{
|
||||||
|
let mut trimmed = false;
|
||||||
|
if texture.frame_w != texture.width || texture.frame_h != texture.height {
|
||||||
|
trimmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i > 0 {
|
||||||
|
file.write(String::from(",\n").as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
file.write(String::from("\t\t\t\t\t{\n").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\t\t\t\t\t\t\"n\": \"{}\", ", texture.file_name).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"x\": {}, ", point.x).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"y\": {}, ", point.y).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"w\": {}, ", texture.width).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"h\": {}, ", texture.height).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"trimmed\": {}, ", trimmed as u8).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"rotated\": {}, ", point.rotate as u8).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"fx\": {}, ", texture.frame_x).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"fy\": {}, ", texture.frame_y).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"fw\": {}, ", texture.frame_w).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!("\"fh\": {}\n", texture.frame_h).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(String::from("\t\t\t\t\t}").as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file.write(String::from("\n\t\t\t\t]\n").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(String::from("\t\t\t}").as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the atlas descriptor to disk in plain TXT format.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `file` - is a reference to already opened for write `File` to use when writing.
|
||||||
|
/// * `file_name` - is the name of the atlas image.
|
||||||
|
/// * `image_ext` - is the extension of the atlas image ("png", "tga" etc).
|
||||||
|
pub fn save_txt(&self, file: &mut File, file_name: &str, image_ext: &str) {
|
||||||
|
let lock = self.state.lock().unwrap();
|
||||||
|
file.write(format!("{}.{}", file_name, image_ext).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", lock.textures.len()).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", lock.width).as_bytes()).unwrap();
|
||||||
|
file.write(format!(",{}", lock.height).as_bytes()).unwrap();
|
||||||
|
file.write(format!(",{}\n", lock.generate_mipmaps as u8).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for i in 0..lock.textures.len() {
|
||||||
|
if let Some(texture) = lock.textures.get(i)
|
||||||
|
&& let Some(point) = lock.points.get(i)
|
||||||
|
{
|
||||||
|
let mut trimmed = false;
|
||||||
|
if texture.frame_w != texture.width || texture.frame_h != texture.height {
|
||||||
|
trimmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.write(format!("{}", texture.file_name).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", point.x).as_bytes()).unwrap();
|
||||||
|
file.write(format!(",{}", point.y).as_bytes()).unwrap();
|
||||||
|
file.write(format!(",{}", texture.width).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", texture.height).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", trimmed as u8).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", point.rotate as u8).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", texture.frame_x).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", texture.frame_y).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}", texture.frame_w).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
file.write(format!(",{}\n", texture.frame_h).as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusts the packer width and height so that given `textures` will fit.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `textures` - is the vector holding the textures.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If new adjusted width / height is > 8192.
|
||||||
|
fn adjust_size_to_fit(&mut self, textures: &Vec<Texture>) -> bool {
|
||||||
|
let mut lock = self.state.lock().unwrap();
|
||||||
|
let mut adjusted_size = false;
|
||||||
|
let padding: u32 = lock.padding.try_into().unwrap();
|
||||||
|
|
||||||
|
for i in 0..textures.len() {
|
||||||
|
if let Some(texture) = textures.get(i) {
|
||||||
|
if texture.width + padding > lock.width {
|
||||||
|
lock.width *= 2;
|
||||||
|
lock.height = lock.width;
|
||||||
|
adjusted_size = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if texture.height + padding > lock.height {
|
||||||
|
lock.height *= 2;
|
||||||
|
lock.width = lock.height;
|
||||||
|
adjusted_size = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lock.width > MAX_SIZE || lock.height > MAX_SIZE {
|
||||||
|
panic!(
|
||||||
|
"adjust_size_to_fit failed. Maximum allowed width / height is {}",
|
||||||
|
MAX_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure width is at least minimum size:
|
||||||
|
if lock.width < MIN_SIZE {
|
||||||
|
lock.width = MIN_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure height is at least minimum size:
|
||||||
|
if lock.height < MIN_SIZE {
|
||||||
|
lock.height = MIN_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if adjusted_size {
|
||||||
|
info!(
|
||||||
|
"Packer: Adjusted size to {}x{} to fit textures.",
|
||||||
|
lock.width, lock.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjusted_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines whether there exists a texture in given `textures` which size is larger then
|
||||||
|
/// that of packer width and height.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `textures` - is the vector holding the textures.
|
||||||
|
fn exists_larger_texture(&self, textures: &Vec<Texture>) -> bool {
|
||||||
|
let lock = self.state.lock().unwrap();
|
||||||
|
let padding: u32 = lock.padding.try_into().unwrap();
|
||||||
|
|
||||||
|
for i in 0..textures.len() {
|
||||||
|
if let Some(texture) = textures.get(i) {
|
||||||
|
if texture.width + padding > lock.width || texture.height + padding > lock.height {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unit tests:
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::texpack::app::{exists_file, get_atlas_image_extension, remove_file};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_basics() {
|
||||||
|
let p = Point::new();
|
||||||
|
|
||||||
|
assert_eq!(p.x, 0);
|
||||||
|
assert_eq!(p.y, 0);
|
||||||
|
assert_eq!(p.duplicate_id, 0);
|
||||||
|
assert_eq!(p.rotate, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_error() {
|
||||||
|
assert_eq!(
|
||||||
|
Packer::new(0, 0, 1, false).unwrap_err(),
|
||||||
|
PackerError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packer::new(32, 32, 1, false).unwrap_err(),
|
||||||
|
PackerError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packer::new(8192, 8193, 1, false).unwrap_err(),
|
||||||
|
PackerError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packer::new(32, 64, 1, false).unwrap_err(),
|
||||||
|
PackerError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Packer::new(64, 32, 1, false).unwrap_err(),
|
||||||
|
PackerError::InvalidArg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_textures() -> Vec<Texture> {
|
||||||
|
let mut textures: Vec<Texture> = Vec::new();
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/white_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
textures.push(t1);
|
||||||
|
|
||||||
|
let mut t2 = Texture::new();
|
||||||
|
t2.load(
|
||||||
|
&PathBuf::from("test_data/red_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
textures.push(t2);
|
||||||
|
|
||||||
|
let mut t3 = Texture::new();
|
||||||
|
t3.load(
|
||||||
|
&PathBuf::from("test_data/green_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
textures.push(t3);
|
||||||
|
|
||||||
|
let mut t4 = Texture::new();
|
||||||
|
t4.load(
|
||||||
|
&PathBuf::from("test_data/blue_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
textures.push(t4);
|
||||||
|
|
||||||
|
return textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_basics_short_side_fit() {
|
||||||
|
let mut textures = load_textures();
|
||||||
|
assert_eq!(textures.len(), 4);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::ShortSideFit,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_short_side_fit.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_basics_long_side_fit() {
|
||||||
|
let mut textures = load_textures();
|
||||||
|
assert_eq!(textures.len(), 4);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::LongSideFit,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_long_side_fit.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_basics_area_fit() {
|
||||||
|
let mut textures = load_textures();
|
||||||
|
assert_eq!(textures.len(), 4);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::AreaFit,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_area_fit.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_basics_bottom_left() {
|
||||||
|
let mut textures = load_textures();
|
||||||
|
assert_eq!(textures.len(), 4);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::BottomLeft,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_bottom_left.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_basics_contact_point() {
|
||||||
|
let mut textures = load_textures();
|
||||||
|
assert_eq!(textures.len(), 4);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::ContactPoint,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_contact_point.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_adjust_size_to_fit() {
|
||||||
|
let mut textures: Vec<Texture> = Vec::new();
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/white_128x128.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
textures.push(t1);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::ContactPoint,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_adjust_size_to_fit.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
|
||||||
|
let mut output = Texture::new();
|
||||||
|
output.load(
|
||||||
|
&PathBuf::from("test_data/atlas_adjust_size_to_fit.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
assert_eq!(output.width, 64);
|
||||||
|
assert_eq!(output.height, 64);
|
||||||
|
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_trim() {
|
||||||
|
let mut textures: Vec<Texture> = Vec::new();
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/blue_trimmable_128x128.png"),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
128,
|
||||||
|
);
|
||||||
|
textures.push(t1);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(128, 128, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::BottomLeft,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_trimmed.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
|
||||||
|
let mut output = Texture::new();
|
||||||
|
output.load(
|
||||||
|
&PathBuf::from("test_data/atlas_trimmed.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
assert_eq!(output.width, 32);
|
||||||
|
assert_eq!(output.height, 32);
|
||||||
|
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_rotated() {
|
||||||
|
let mut textures: Vec<Texture> = Vec::new();
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/white_128x64.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
textures.push(t1);
|
||||||
|
assert_eq!(textures.len(), 1);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 128, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::LongSideFit,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let file_path = PathBuf::from("test_data/atlas_rotated.png");
|
||||||
|
|
||||||
|
packer.save_image(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
|
||||||
|
let mut output = Texture::new();
|
||||||
|
output.load(
|
||||||
|
&PathBuf::from("test_data/atlas_rotated.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
assert_eq!(output.width, 64);
|
||||||
|
assert_eq!(output.height, 128);
|
||||||
|
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn packer_save_all_supported_types() {
|
||||||
|
let mut textures = load_textures();
|
||||||
|
assert_eq!(textures.len(), 4);
|
||||||
|
|
||||||
|
let mut packer = Packer::new(64, 64, 0, true).unwrap();
|
||||||
|
packer.pack(
|
||||||
|
&mut textures,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
FreeRectHeuristic::ShortSideFit,
|
||||||
|
);
|
||||||
|
assert_eq!(textures.len() == 0, true);
|
||||||
|
|
||||||
|
let base_file_path = PathBuf::from("test_data/atlas_save");
|
||||||
|
let image_types = vec![
|
||||||
|
AtlasImage::Png,
|
||||||
|
AtlasImage::Tga,
|
||||||
|
AtlasImage::Tiff,
|
||||||
|
AtlasImage::Webp,
|
||||||
|
];
|
||||||
|
|
||||||
|
for image_type in image_types {
|
||||||
|
let file_path = PathBuf::from(format!(
|
||||||
|
"{}.{}",
|
||||||
|
base_file_path.display(),
|
||||||
|
get_atlas_image_extension(image_type.clone())
|
||||||
|
));
|
||||||
|
|
||||||
|
println!("{}", file_path.display());
|
||||||
|
|
||||||
|
packer.save_image(&file_path, image_type.clone());
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
596
src/texpack/texture.rs
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
// flow-texpack: A program that will allow you to generate texture atlas.
|
||||||
|
// zlib License (see LICENSE)
|
||||||
|
|
||||||
|
use crate::texpack::app::AtlasImage;
|
||||||
|
use crate::texpack::packer::MAX_SIZE;
|
||||||
|
|
||||||
|
use image::GenericImageView;
|
||||||
|
use image::{DynamicImage, ImageBuffer, ImageReader, Rgba, RgbaImage, imageops::FilterType};
|
||||||
|
use log::info;
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Specifies the different error types that can occur.
|
||||||
|
#[derive(PartialEq, Clone, Debug)]
|
||||||
|
pub enum TextureError {
|
||||||
|
/// Invalid argument
|
||||||
|
InvalidArg,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specifies the properties of a `Texture`.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Texture {
|
||||||
|
/// is the file path.
|
||||||
|
pub file_path: PathBuf,
|
||||||
|
/// is the file name.
|
||||||
|
pub file_name: String,
|
||||||
|
/// is the width.
|
||||||
|
pub width: u32,
|
||||||
|
/// is the height.
|
||||||
|
pub height: u32,
|
||||||
|
/// is the orignal x position (valid if trimmed).
|
||||||
|
pub frame_x: i32,
|
||||||
|
/// is the orignal y position (valid if trimmed).
|
||||||
|
pub frame_y: i32,
|
||||||
|
/// is the orignal width (valid if trimmed).
|
||||||
|
pub frame_w: u32,
|
||||||
|
/// is the orignal height (valid if trimmed).
|
||||||
|
pub frame_h: u32,
|
||||||
|
/// is the hash value (width, height and buffer combined).
|
||||||
|
pub hash_value: u64,
|
||||||
|
/// is the raw buffer in RGBA format.
|
||||||
|
pub buffer: RgbaImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Texture {
|
||||||
|
/// Instantiates a new `Texture` instance.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
file_path: PathBuf::new(),
|
||||||
|
file_name: String::new(),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
frame_x: 0,
|
||||||
|
frame_y: 0,
|
||||||
|
frame_w: 0,
|
||||||
|
frame_h: 0,
|
||||||
|
hash_value: 0,
|
||||||
|
buffer: RgbaImage::new(1, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instantiates a new `Texture` instance based on given input params.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `width` - is the `Texture` width.
|
||||||
|
/// * `height` - is the `Texture` height.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// [`InvalidArg`](crate::texpack::texture::TextureError) error is returned if:
|
||||||
|
/// `width == 0 || width > 8192 ||
|
||||||
|
/// height == 0 || height > 8192`.
|
||||||
|
pub fn with_details(width: u32, height: u32) -> Result<Self, TextureError> {
|
||||||
|
if width > 0 && width <= MAX_SIZE && height > 0 && height <= MAX_SIZE {
|
||||||
|
Ok(Self {
|
||||||
|
file_path: PathBuf::new(),
|
||||||
|
file_name: String::new(),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
frame_x: 0,
|
||||||
|
frame_y: 0,
|
||||||
|
frame_w: 0,
|
||||||
|
frame_h: 0,
|
||||||
|
hash_value: 0,
|
||||||
|
buffer: RgbaImage::new(width, height),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(TextureError::InvalidArg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load texture.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `file_path` - is the texture file path.
|
||||||
|
/// * `premultiply` - is a flag determining whether to premultiply RBG by alpha channel or not.
|
||||||
|
/// * `trim` - is a flag determining whether to trim excess transparent pixels or not.
|
||||||
|
/// * `adjust_fit` - is a flag determining whether to adjust fit automatically or not.
|
||||||
|
/// * `padding` - is the padding to use between textures.
|
||||||
|
/// * `atlas_size` - is the atlas size.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If loading fails.
|
||||||
|
pub fn load(
|
||||||
|
&mut self,
|
||||||
|
file_path: &PathBuf,
|
||||||
|
premultiply: bool,
|
||||||
|
trim: bool,
|
||||||
|
adjust_fit: bool,
|
||||||
|
padding: u32,
|
||||||
|
atlas_size: u32,
|
||||||
|
) {
|
||||||
|
// remember file path and file name:
|
||||||
|
self.file_path = file_path.clone();
|
||||||
|
if let Some(file_name) = file_path.file_name() {
|
||||||
|
if let Some(file_name_str) = file_name.to_str() {
|
||||||
|
self.file_name = String::from(file_name_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// load the image:
|
||||||
|
let image = ImageReader::open(file_path).unwrap().decode().unwrap();
|
||||||
|
let (width, height) = image.dimensions();
|
||||||
|
self.update_initial_size(width, height);
|
||||||
|
|
||||||
|
// trim excess transparent pixels off the texture:
|
||||||
|
if trim {
|
||||||
|
self.buffer = self.trim(&image.to_rgba8());
|
||||||
|
} else {
|
||||||
|
self.buffer = image.to_rgba8();
|
||||||
|
}
|
||||||
|
|
||||||
|
// premultiply all the pixels by their alpha value:
|
||||||
|
if premultiply {
|
||||||
|
self.premultiply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if needing to adjust / scale texture size to fit atlas size:
|
||||||
|
if adjust_fit
|
||||||
|
&& (((self.width + padding) > atlas_size) || ((self.height + padding) > atlas_size))
|
||||||
|
{
|
||||||
|
self.buffer = self.resize_to_fit(padding, atlas_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
self.width.hash(&mut hasher);
|
||||||
|
self.height.hash(&mut hasher);
|
||||||
|
self.buffer.hash(&mut hasher);
|
||||||
|
self.hash_value = hasher.finish();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Loaded texture: '{}' w: {} h: {}, hash_value: {}",
|
||||||
|
self.file_name, self.width, self.height, self.hash_value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves texture to disk.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `file_path` - is the output file path.
|
||||||
|
/// * `image_type` - is the output image type.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If save fails.
|
||||||
|
pub fn save(&self, file_path: &PathBuf, image_type: AtlasImage) {
|
||||||
|
let dst_image = DynamicImage::ImageRgba8(self.buffer.clone());
|
||||||
|
|
||||||
|
// make sure extension supplied is valid to help out with 'guessing' of type:
|
||||||
|
if let Some(extension) = file_path.extension() {
|
||||||
|
let ext_lc = extension.to_ascii_lowercase();
|
||||||
|
if image_type == AtlasImage::Png && ext_lc == "png"
|
||||||
|
|| image_type == AtlasImage::Tga && ext_lc == "tga"
|
||||||
|
|| image_type == AtlasImage::Tiff && ext_lc == "tiff"
|
||||||
|
|| image_type == AtlasImage::Webp && ext_lc == "webp"
|
||||||
|
{
|
||||||
|
dst_image.save(file_path).unwrap();
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"Supplied file_path: {} does not have a valid extension that matches image type: {:?}!",
|
||||||
|
file_path.display(),
|
||||||
|
image_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy pixels from given `src` texture into this texture.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `src` - is the source texture to copy from.
|
||||||
|
/// * `tx` - is the x offset to use when copying.
|
||||||
|
/// * `ty` - is the y offset to use when copying.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If pixel is out of bounds.
|
||||||
|
pub fn copy_pixels(&mut self, src: &Texture, tx: u32, ty: u32) {
|
||||||
|
let (src_width, src_height) = src.buffer.dimensions();
|
||||||
|
|
||||||
|
for y in 0..src_height {
|
||||||
|
for x in 0..src_width {
|
||||||
|
let pixel = src.buffer.get_pixel(x, y);
|
||||||
|
self.buffer.put_pixel(x + tx, y + ty, *pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy pixels from given `src` texture into this texture rotated 90 degrees clockwise.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `src` - is the source texture to copy from.
|
||||||
|
/// * `tx` - is the x offset to use when copying.
|
||||||
|
/// * `ty` - is the y offset to use when copying.
|
||||||
|
pub fn copy_pixels_rot_90cw(&mut self, src: &Texture, tx: u32, ty: u32) {
|
||||||
|
let (src_width, src_height) = src.buffer.dimensions();
|
||||||
|
let r = src_height - 1;
|
||||||
|
|
||||||
|
for y in 0..src_height {
|
||||||
|
for x in 0..src_width {
|
||||||
|
let pixel = src.buffer.get_pixel(x, y);
|
||||||
|
self.buffer.put_pixel(r - y + tx, x + ty, *pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the texture area (width * height).
|
||||||
|
pub fn get_area(&self) -> u32 {
|
||||||
|
return self.width * self.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates initial size properties.
|
||||||
|
fn update_initial_size(&mut self, width: u32, height: u32) {
|
||||||
|
self.frame_x = 0;
|
||||||
|
self.frame_y = 0;
|
||||||
|
self.frame_w = width;
|
||||||
|
self.frame_h = height;
|
||||||
|
self.width = width;
|
||||||
|
self.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims out excess pixels and returns new RBGA buffer.
|
||||||
|
fn trim(&mut self, img: &RgbaImage) -> RgbaImage {
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return ImageBuffer::new(1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounding box of non-transparent pixels:
|
||||||
|
let mut min_x = width;
|
||||||
|
let mut min_y = height;
|
||||||
|
let mut max_x = 0u32;
|
||||||
|
let mut max_y = 0u32;
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let pixel = img.get_pixel(x, y);
|
||||||
|
if pixel[3] > 0 {
|
||||||
|
// non-transparent pixel:
|
||||||
|
min_x = min_x.min(x);
|
||||||
|
min_y = min_y.min(y);
|
||||||
|
max_x = max_x.max(x);
|
||||||
|
max_y = max_y.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no non-transparent pixels found, return 1x1 transparent buffer:
|
||||||
|
if max_x < min_x || max_y < min_y {
|
||||||
|
return ImageBuffer::from_pixel(1, 1, Rgba([0, 0, 0, 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// calc new dimensions (add 1 for bounds):
|
||||||
|
let new_width = (max_x - min_x) + 1;
|
||||||
|
let new_height = (max_y - min_y) + 1;
|
||||||
|
|
||||||
|
// no trimming needed -> just clone it:
|
||||||
|
if new_width == width && new_height == height {
|
||||||
|
return img.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// crop the image:
|
||||||
|
let mut trimmed = ImageBuffer::new(new_width, new_height);
|
||||||
|
for y in 0..new_height {
|
||||||
|
for x in 0..new_width {
|
||||||
|
let src_pixel = img.get_pixel(min_x + x, min_y + y);
|
||||||
|
trimmed.put_pixel(x, y, *src_pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let x: i32 = min_x.try_into().unwrap();
|
||||||
|
let y: i32 = min_y.try_into().unwrap();
|
||||||
|
self.frame_x = -x;
|
||||||
|
self.frame_y = -y;
|
||||||
|
self.frame_w = width;
|
||||||
|
self.frame_h = height;
|
||||||
|
self.width = new_width;
|
||||||
|
self.height = new_height;
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Premultiply destination pixel by alpha.
|
||||||
|
fn premultiply(&mut self) {
|
||||||
|
let (width, height) = self.buffer.dimensions();
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
// get source pixel:
|
||||||
|
let src_pixel = self.buffer.get_pixel(x, y);
|
||||||
|
|
||||||
|
// premultiply destination pixel by alpha:
|
||||||
|
let alpha = src_pixel[3] as f32 / 255.0;
|
||||||
|
let dst_pixel = Rgba([
|
||||||
|
(src_pixel[0] as f32 * alpha) as u8,
|
||||||
|
(src_pixel[1] as f32 * alpha) as u8,
|
||||||
|
(src_pixel[2] as f32 * alpha) as u8,
|
||||||
|
src_pixel[3],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// set new pixel value:
|
||||||
|
self.buffer.put_pixel(x, y, dst_pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize buffer so that it fits given `atlas_size + padding`.
|
||||||
|
fn resize_to_fit(&mut self, padding: u32, atlas_size: u32) -> RgbaImage {
|
||||||
|
let (src_width, src_height) = self.buffer.dimensions();
|
||||||
|
|
||||||
|
if src_width == 0 || src_height == 0 || atlas_size == 0 {
|
||||||
|
panic!("Invalid internal buffer state or atlas_size is 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate scale factor:
|
||||||
|
let mut _scale_factor = 0.0;
|
||||||
|
if src_width > src_height {
|
||||||
|
_scale_factor = src_width as f32 / atlas_size as f32;
|
||||||
|
} else if src_height > src_width {
|
||||||
|
_scale_factor = src_height as f32 / atlas_size as f32;
|
||||||
|
} else {
|
||||||
|
_scale_factor = src_width as f32 / atlas_size as f32;
|
||||||
|
}
|
||||||
|
info!("scale_factor is: {}", _scale_factor);
|
||||||
|
|
||||||
|
// calculate new size of texture:
|
||||||
|
let mut new_width = (src_width as f32 / _scale_factor).floor() as u32;
|
||||||
|
let mut new_height = (src_height as f32 / _scale_factor).floor() as u32;
|
||||||
|
|
||||||
|
// adjust for padding too:
|
||||||
|
new_width -= padding;
|
||||||
|
new_height -= padding;
|
||||||
|
|
||||||
|
// make sure width and height is at least 1 pixel after scaling and padding:
|
||||||
|
if new_width <= 0 {
|
||||||
|
new_width = 1;
|
||||||
|
}
|
||||||
|
if new_height <= 0 {
|
||||||
|
new_height = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_image = DynamicImage::ImageRgba8(self.buffer.clone());
|
||||||
|
let dst_image = src_image.resize(new_width, new_height, FilterType::Lanczos3);
|
||||||
|
info!(
|
||||||
|
"Resized image from {}x{} to {}x{}",
|
||||||
|
src_width, src_height, new_width, new_height
|
||||||
|
);
|
||||||
|
|
||||||
|
// reset:
|
||||||
|
self.frame_x = 0;
|
||||||
|
self.frame_y = 0;
|
||||||
|
self.frame_w = new_width;
|
||||||
|
self.frame_h = new_height;
|
||||||
|
self.width = new_width;
|
||||||
|
self.height = new_height;
|
||||||
|
|
||||||
|
return dst_image.to_rgba8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unit tests:
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::texpack::app::{exists_file, get_atlas_image_extension, remove_file};
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_error() {
|
||||||
|
assert_eq!(
|
||||||
|
Texture::with_details(0, 0).unwrap_err(),
|
||||||
|
TextureError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Texture::with_details(32, 0).unwrap_err(),
|
||||||
|
TextureError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Texture::with_details(0, 32).unwrap_err(),
|
||||||
|
TextureError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Texture::with_details(8192, 8193).unwrap_err(),
|
||||||
|
TextureError::InvalidArg
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Texture::with_details(8193, 8192).unwrap_err(),
|
||||||
|
TextureError::InvalidArg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_basics() {
|
||||||
|
let t1 = Texture::new();
|
||||||
|
assert_eq!(t1.width, 0);
|
||||||
|
assert_eq!(t1.height, 0);
|
||||||
|
assert_eq!(t1.frame_x, 0);
|
||||||
|
assert_eq!(t1.frame_y, 0);
|
||||||
|
assert_eq!(t1.frame_w, 0);
|
||||||
|
assert_eq!(t1.frame_h, 0);
|
||||||
|
assert_eq!(t1.hash_value, 0);
|
||||||
|
|
||||||
|
let t2 = Texture::with_details(32, 32).unwrap();
|
||||||
|
assert_eq!(t2.width, 32);
|
||||||
|
assert_eq!(t2.height, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_load_all_supported_formats() {
|
||||||
|
let supported_extensions = vec![
|
||||||
|
OsString::from("bmp"),
|
||||||
|
OsString::from("hdr"),
|
||||||
|
OsString::from("jpg"),
|
||||||
|
OsString::from("jpeg"),
|
||||||
|
OsString::from("png"),
|
||||||
|
OsString::from("tga"),
|
||||||
|
OsString::from("tiff"),
|
||||||
|
OsString::from("webp"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for ext in &supported_extensions {
|
||||||
|
let base_file_path = "test_data/white_32x32";
|
||||||
|
let file_path = PathBuf::from(format!("{}.{}", base_file_path, ext.display()));
|
||||||
|
|
||||||
|
let mut t = Texture::new();
|
||||||
|
t.load(&file_path, false, false, false, 0, 64);
|
||||||
|
assert_eq!(t.width, 32);
|
||||||
|
assert_eq!(t.height, 32);
|
||||||
|
assert_eq!(t.file_path, file_path);
|
||||||
|
if let Some(file_name) = file_path.file_name() {
|
||||||
|
if let Some(file_name_str) = file_name.to_str() {
|
||||||
|
assert_eq!(t.file_name, file_name_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(t.frame_x, 0);
|
||||||
|
assert_eq!(t.frame_y, 0);
|
||||||
|
assert_eq!(t.frame_w, 32);
|
||||||
|
assert_eq!(t.frame_h, 32);
|
||||||
|
assert_eq!(t.hash_value > 0, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_save_all_supported_formats() {
|
||||||
|
let atlas_image_types = vec![
|
||||||
|
AtlasImage::Png,
|
||||||
|
AtlasImage::Tga,
|
||||||
|
AtlasImage::Tiff,
|
||||||
|
AtlasImage::Webp,
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/white_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut output = Texture::with_details(64, 64).unwrap();
|
||||||
|
output.copy_pixels(&t1, 16, 16);
|
||||||
|
|
||||||
|
let base_file_path = "test_data/save_64x64";
|
||||||
|
for atlas_image_type in &atlas_image_types {
|
||||||
|
let file_path = PathBuf::from(format!(
|
||||||
|
"{}.{}",
|
||||||
|
base_file_path,
|
||||||
|
get_atlas_image_extension(atlas_image_type.clone())
|
||||||
|
));
|
||||||
|
output.save(&file_path, atlas_image_type.clone());
|
||||||
|
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_copy_pixels() {
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/white_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut t2 = Texture::new();
|
||||||
|
t2.load(
|
||||||
|
&PathBuf::from("test_data/red_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut t3 = Texture::new();
|
||||||
|
t3.load(
|
||||||
|
&PathBuf::from("test_data/green_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut t4 = Texture::new();
|
||||||
|
t4.load(
|
||||||
|
&PathBuf::from("test_data/blue_32x32.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut output = Texture::with_details(64, 64).unwrap();
|
||||||
|
let file_path = PathBuf::from("test_data/copy_pixels_64x64.png");
|
||||||
|
|
||||||
|
output.copy_pixels(&t1, 0, 0);
|
||||||
|
output.copy_pixels(&t2, 32, 0);
|
||||||
|
output.copy_pixels(&t3, 32, 32);
|
||||||
|
output.copy_pixels(&t4, 0, 32);
|
||||||
|
|
||||||
|
output.save(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_copy_pixels_rot_90cw() {
|
||||||
|
let mut t1 = Texture::new();
|
||||||
|
t1.load(
|
||||||
|
&PathBuf::from("test_data/white_32x16.png"),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
64,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut output = Texture::with_details(64, 64).unwrap();
|
||||||
|
let file_path = PathBuf::from("test_data/copy_pixels_rot_90cw_64x64.png");
|
||||||
|
|
||||||
|
output.copy_pixels_rot_90cw(&t1, 0, 0);
|
||||||
|
output.copy_pixels_rot_90cw(&t1, 32, 0);
|
||||||
|
|
||||||
|
output.save(&file_path, AtlasImage::Png);
|
||||||
|
assert_eq!(exists_file(&file_path), true);
|
||||||
|
remove_file(&file_path);
|
||||||
|
assert_eq!(exists_file(&file_path), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texture_get_area() {
|
||||||
|
let t1 = Texture::with_details(32, 32).unwrap();
|
||||||
|
assert_eq!(t1.get_area(), 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
test_data/blue_32x32.png
Normal file
|
After Width: | Height: | Size: 212 B |
BIN
test_data/blue_trimmable_128x128.png
Normal file
|
After Width: | Height: | Size: 317 B |
BIN
test_data/green_32x32.png
Normal file
|
After Width: | Height: | Size: 214 B |
BIN
test_data/red_32x32.png
Normal file
|
After Width: | Height: | Size: 212 B |
BIN
test_data/white_128x128.png
Normal file
|
After Width: | Height: | Size: 366 B |
BIN
test_data/white_128x128_v2.png
Normal file
|
After Width: | Height: | Size: 366 B |
BIN
test_data/white_128x64.png
Normal file
|
After Width: | Height: | Size: 282 B |
BIN
test_data/white_32x16.png
Normal file
|
After Width: | Height: | Size: 202 B |
BIN
test_data/white_32x32.bmp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
6
test_data/white_32x32.hdr
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#?RADIANCE
|
||||||
|
SOFTWARE=GEGL
|
||||||
|
FORMAT=32-bit_rle_rgbe
|
||||||
|
|
||||||
|
-Y 32 +X 32
|
||||||
|
€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>€€€<EFBFBD>
|
||||||
BIN
test_data/white_32x32.jpeg
Normal file
|
After Width: | Height: | Size: 530 B |
BIN
test_data/white_32x32.jpg
Normal file
|
After Width: | Height: | Size: 530 B |
BIN
test_data/white_32x32.png
Normal file
|
After Width: | Height: | Size: 210 B |
BIN
test_data/white_32x32.tga
Normal file
BIN
test_data/white_32x32.tiff
Normal file
BIN
test_data/white_32x32.webp
Normal file
|
After Width: | Height: | Size: 58 B |