Cleanup, better basis support, new convert command

This commit is contained in:
Piotr Siuszko 2025-10-25 13:28:29 +02:00
parent e1b1d562b4
commit dbe4db42b8
7 changed files with 196 additions and 122 deletions

View File

@ -9,7 +9,7 @@ version = "0.3.0"
edition = "2024"
[features]
default = ["cli", "dds"]
default = ["cli", "dds", "basis"]
cli = ["dep:clap", "dep:glob", "config_ext"]
basis = ["dep:basis-universal"]
dds = ["dep:image_dds"]

View File

@ -1,7 +1,9 @@
use std::io::Write;
use std::path::PathBuf;
use clap::Subcommand;
use rpack_cli::TilemapGenerationConfig;
use rpack_cli::saving::SaveableImage;
use rpack_cli::SaveImageFormat;
@ -56,6 +58,15 @@ pub enum Commands {
#[clap(action)]
config_path: String,
},
/// Converts a texture between formats
Convert {
/// path of the config to create
#[clap(action)]
source_path: PathBuf,
/// path of the output texture
#[clap(action)]
output_path: PathBuf,
},
}
impl Commands {
pub(crate) fn run(&self) -> anyhow::Result<()> {
@ -95,9 +106,19 @@ impl Commands {
Commands::GenerateFromConfig { config_path } => {
Self::generate_tilemap_from_config(config_path)
}
Commands::Convert {
source_path,
output_path,
} => Self::convert(source_path, output_path),
}
}
fn convert(source_path: PathBuf, output_path: PathBuf) -> anyhow::Result<()> {
let image = image::open(source_path)?;
image.save_with_format_autodetection(output_path)?;
Ok(())
}
fn generate_tilemap(
name: Option<String>,
size: Option<u32>,

View File

@ -0,0 +1,40 @@
use std::{ffi::OsStr, fmt::Display, path::Path};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Copy, Serialize, Deserialize, PartialEq)]
#[cfg_attr(
all(feature = "cli", not(target_arch = "wasm32")),
derive(clap::ValueEnum)
)]
pub enum SaveImageFormat {
#[default]
Png,
Dds,
Basis,
}
impl SaveImageFormat {
/// Try to gets file extension from a path
pub fn from_path(v: impl AsRef<Path>) -> Option<Self> {
let path = v.as_ref();
let extension = path.extension().and_then(OsStr::to_str)?;
match extension {
"png" => Some(SaveImageFormat::Png),
"dds" => Some(SaveImageFormat::Dds),
"basis" => Some(SaveImageFormat::Basis),
_ => None,
}
}
}
impl Display for SaveImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SaveImageFormat::Png => f.write_str(".png"),
SaveImageFormat::Dds => f.write_str(".dds"),
#[cfg(feature = "basis")]
SaveImageFormat::Basis => f.write_str(".basis"),
}
}
}

View File

@ -6,13 +6,16 @@ use serde_json::Value;
use std::io::Write;
use std::{
ffi::OsStr,
fmt::Display,
path::{Path, PathBuf},
};
use texture_packer::{TexturePacker, TexturePackerConfig, importer::ImageImporter};
use thiserror::Error;
pub use crate::formats::SaveImageFormat;
pub mod formats;
pub mod packer;
pub mod saving;
#[derive(Clone)]
pub struct Spritesheet {
@ -89,30 +92,6 @@ where
prefix
}
#[derive(Clone, Debug, Default, Copy, Serialize, Deserialize, PartialEq)]
#[cfg_attr(
all(feature = "cli", not(target_arch = "wasm32")),
derive(clap::ValueEnum)
)]
pub enum SaveImageFormat {
#[default]
Png,
Dds,
#[cfg(feature = "basis")]
Basis,
}
impl Display for SaveImageFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SaveImageFormat::Png => f.write_str(".png"),
SaveImageFormat::Dds => f.write_str(".dds"),
#[cfg(feature = "basis")]
SaveImageFormat::Basis => f.write_str(".basis"),
}
}
}
/// Errors that can occur while building a `Spritesheet`.
#[non_exhaustive]
#[derive(Debug, Error, Clone)]
@ -204,77 +183,6 @@ impl Spritesheet {
atlas_asset_json,
})
}
#[cfg(all(feature = "dds", not(target_arch = "wasm32")))]
pub fn save_as_dds<R>(&self, output_path: R)
where
R: AsRef<Path>,
{
let rgba_image = self.image_data.to_rgba8();
let dds = image_dds::dds_from_image(
&rgba_image,
image_dds::ImageFormat::Rgba8Unorm,
image_dds::Quality::Fast,
image_dds::Mipmaps::GeneratedAutomatic,
)
.unwrap();
let mut writer =
std::io::BufWriter::new(std::fs::File::create(output_path.as_ref()).unwrap());
dds.write(&mut writer).unwrap();
}
#[cfg(all(feature = "basis", not(target_arch = "wasm32")))]
pub fn save_as_basis<R>(&self, output_path: R) -> anyhow::Result<()>
where
R: AsRef<Path>,
{
use basis_universal::{BasisTextureFormat, Compressor, Transcoder};
use image::{EncodableLayout, GenericImageView};
let rgba_image = self.image_data.to_rgba8();
let channel_count = 4;
let (pixel_width, pixel_height) = self.image_data.dimensions();
let mut compressor_params = basis_universal::CompressorParams::new();
compressor_params.set_generate_mipmaps(true);
compressor_params.set_basis_format(BasisTextureFormat::ETC1S);
compressor_params.set_etc1s_quality_level(basis_universal::ETC1S_QUALITY_MAX);
compressor_params.set_print_status_to_stdout(false);
let mut compressor_image = compressor_params.source_image_mut(0);
compressor_image.init(
rgba_image.as_bytes(),
pixel_width,
pixel_height,
channel_count,
);
//
// Create the compressor and compress
//
let mut compressor = Compressor::default();
let compression_time = unsafe {
compressor.init(&compressor_params);
let t0 = std::time::Instant::now();
compressor.process().expect("Failed to compress the image.");
let t1 = std::time::Instant::now();
t1 - t0
};
// You could write it to disk like this
let basis_file = compressor.basis_file();
let transcoder = Transcoder::new();
let mip_level_count = transcoder.image_level_count(basis_file, 0);
println!(
"Compressed {} mip levels to {} total bytes in {} ms",
mip_level_count,
compressor.basis_file_size(),
compression_time.as_secs_f64() * 1000.0
);
std::fs::write(output_path.as_ref(), basis_file)?;
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize, Default, PartialEq)]
@ -368,6 +276,8 @@ impl TilemapGenerationConfig {
}
pub fn generate(&self) -> anyhow::Result<()> {
use crate::saving::SaveableImage;
let working_dir = self.working_dir();
let (file_paths, prefix) = self.get_file_paths_and_prefix();
@ -401,28 +311,9 @@ impl TilemapGenerationConfig {
if Path::new(&atlas_image_path).exists() {
std::fs::remove_file(&atlas_image_path).expect("Could not remove the old file");
}
match self.format.unwrap_or_default() {
SaveImageFormat::Dds => {
#[cfg(feature = "dds")]
spritesheet.save_as_dds(&atlas_image_path);
#[cfg(not(feature = "dds"))]
panic!(
"Program is compiled without support for dds. Compile it yourself with feature `dds` enabled."
);
}
SaveImageFormat::Png => {
spritesheet
.image_data
.save_with_format(&atlas_image_path, image::ImageFormat::Png)?;
}
#[cfg(feature = "basis")]
SaveImageFormat::Basis => {
spritesheet.save_as_basis(&atlas_image_path)?;
panic!(
"Program is compiled without support for basis. Compile it yourself with feature `basis` enabled."
);
}
}
spritesheet
.image_data
.save_with_format_autodetection(&atlas_image_path)?;
let json = serde_json::to_string_pretty(&spritesheet.atlas_asset_json)?;
let mut file = std::fs::File::create(&atlas_config_path)?;
file.write_all(json.as_bytes())?;

View File

@ -0,0 +1,122 @@
use image::{DynamicImage, RgbaImage};
use std::path::Path;
use crate::formats::SaveImageFormat;
pub trait SaveableImage {
fn save_with_format_autodetection<R: AsRef<Path>>(&self, path: R) -> anyhow::Result<()> {
let output_path = path.as_ref().to_owned();
let format = SaveImageFormat::from_path(&output_path);
match format {
None => {
let output_extension = output_path
.extension()
.map_or(String::from("png"), |e| e.to_string_lossy().to_string());
let Some(output_format) = image::ImageFormat::from_extension(&output_extension)
else {
anyhow::bail!("Unsupported output format");
};
self.to_rgba8()
.save_with_format(output_path, output_format)?;
}
Some(format) => match format {
SaveImageFormat::Png => {
self.to_rgba8()
.save_with_format(output_path, image::ImageFormat::Png)?;
}
SaveImageFormat::Basis => {
#[cfg(feature = "basis")]
self.save_as_basis(&output_path)?;
#[cfg(not(feature = "basis"))]
anyhow::bail!(
"Program is compiled without support for basis. Compile it yourself with feature `basis` enabled."
);
}
SaveImageFormat::Dds => {
#[cfg(feature = "dds")]
self.save_as_dds(&output_path)?;
#[cfg(not(feature = "basis"))]
anyhow::bail!(
"Program is compiled without support for basis. Compile it yourself with feature `basis` enabled."
);
}
},
}
Ok(())
}
fn to_rgba8(&self) -> RgbaImage;
#[cfg(all(feature = "basis", not(target_arch = "wasm32")))]
fn save_as_basis(&self, output_path: impl AsRef<Path>) -> anyhow::Result<()> {
use basis_universal::{BasisTextureFormat, Compressor, Transcoder};
use image::EncodableLayout;
let rgba_image = self.to_rgba8();
let channel_count = 4;
let (pixel_width, pixel_height) = rgba_image.dimensions();
let mut compressor_params = basis_universal::CompressorParams::new();
compressor_params.set_generate_mipmaps(true);
compressor_params.set_basis_format(BasisTextureFormat::ETC1S);
compressor_params.set_etc1s_quality_level(basis_universal::ETC1S_QUALITY_MAX);
compressor_params.set_print_status_to_stdout(false);
let mut compressor_image = compressor_params.source_image_mut(0);
compressor_image.init(
rgba_image.as_bytes(),
pixel_width,
pixel_height,
channel_count,
);
//
// Create the compressor and compress
//
let mut compressor = Compressor::default();
let compression_time = unsafe {
compressor.init(&compressor_params);
let t0 = std::time::Instant::now();
compressor.process().expect("Failed to compress the image.");
let t1 = std::time::Instant::now();
t1 - t0
};
// You could write it to disk like this
let basis_file = compressor.basis_file();
let transcoder = Transcoder::new();
let mip_level_count = transcoder.image_level_count(basis_file, 0);
println!(
"Compressed {} mip levels to {} total bytes in {} ms",
mip_level_count,
compressor.basis_file_size(),
compression_time.as_secs_f64() * 1000.0
);
std::fs::write(output_path.as_ref(), basis_file)?;
Ok(())
}
#[cfg(all(feature = "dds", not(target_arch = "wasm32")))]
fn save_as_dds<R>(&self, output_path: R) -> anyhow::Result<()>
where
R: AsRef<Path>,
{
let rgba_image = self.to_rgba8();
let dds = image_dds::dds_from_image(
&rgba_image,
image_dds::ImageFormat::Rgba8Unorm,
image_dds::Quality::Fast,
image_dds::Mipmaps::GeneratedAutomatic,
)?;
let mut writer = std::io::BufWriter::new(std::fs::File::create(output_path.as_ref())?);
dds.write(&mut writer)?;
Ok(())
}
}
impl SaveableImage for DynamicImage {
fn to_rgba8(&self) -> RgbaImage {
self.to_rgba8()
}
}

View File

@ -67,7 +67,7 @@ ico = "0.4.0"
name = "Rpack"
icon = ["static/base_icon.png"]
resources_mapping = [["static/JetBrains*","./"]]
identifier = "io.github.leinnan.rpack"
osx_url_schemes = ["io.github.leinnan.rpack"]
identifier = "com.mevlyshkin.rpack"
osx_url_schemes = ["com.mevlyshkin.rpack"]
short_description = "Tilemap Editor"
long_description = "Tilemap Editor built with egui in Rust"

View File

@ -6,7 +6,7 @@
<key>CFBundleName</key>
<string>Rpack</string>
<key>CFBundleIdentifier</key>
<string>io.github.leinnan.rpack</string>
<string>com.mevlyshkin.rpack</string>
<key>CFBundleVersion</key>
<string>0.3.0</string>
<key>CFBundleExecutable</key>