use crate::atlas::{pack_atlas, AtlasBox, AtlasConfig as PackerConfig, SpriteInput};
use crate::build::{BuildContext, BuildPlan, BuildResult, BuildTarget, TargetKind, TargetResult};
use crate::models::TtpObject;
use crate::parser::parse_stream;
use crate::registry::{PaletteRegistry, ResolvedSprite, SpriteRegistry};
use crate::renderer::{render_resolved, render_sprite};
use rayon::prelude::*;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::BufReader;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("Discovery error: {0}")]
Discovery(#[from] crate::build::DiscoveryError),
#[error("Build order error: {0}")]
BuildOrder(#[from] crate::build::target::BuildOrderError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Build error: {0}")]
Build(String),
}
pub struct BuildPipeline {
context: BuildContext,
fail_fast: bool,
dry_run: bool,
}
impl BuildPipeline {
pub fn new(context: BuildContext) -> Self {
Self { context, fail_fast: false, dry_run: false }
}
pub fn with_fail_fast(mut self, fail_fast: bool) -> Self {
self.fail_fast = fail_fast;
self
}
pub fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn build(&self) -> Result<BuildResult, BuildError> {
let start = Instant::now();
let plan = crate::build::create_build_plan(&self.context)?;
let plan = if let Some(filter) = self.context.target_filter() {
plan.filter(filter)
} else {
plan
};
let mut result = self.execute_plan(&plan)?;
result.total_duration = start.elapsed();
Ok(result)
}
pub fn build_plan(&self, plan: &BuildPlan) -> Result<BuildResult, BuildError> {
let start = Instant::now();
let mut result = self.execute_plan(plan)?;
result.total_duration = start.elapsed();
Ok(result)
}
fn execute_plan(&self, plan: &BuildPlan) -> Result<BuildResult, BuildError> {
let mut result = BuildResult::new();
let ordered = plan.build_order()?;
if self.context.is_verbose() {
println!("Build plan: {} targets", ordered.len());
for target in &ordered {
println!(" - {} ({})", target.id, target.kind);
}
}
if !self.dry_run {
fs::create_dir_all(self.context.out_dir())?;
}
for target in ordered {
let target_result = self.execute_target(target);
if target_result.status.is_failure() && self.fail_fast {
result.add_result(target_result);
return Ok(result);
}
result.add_result(target_result);
}
Ok(result)
}
fn execute_target(&self, target: &BuildTarget) -> TargetResult {
let start = Instant::now();
if self.context.is_verbose() {
println!("Building: {} ...", target.id);
}
if self.dry_run {
return TargetResult::skipped(target.id.clone());
}
if let Some(parent) = target.output.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return TargetResult::failed(
target.id.clone(),
format!("Failed to create output directory: {}", e),
start.elapsed(),
);
}
}
let build_result = match target.kind {
TargetKind::Sprite => self.build_sprite(target),
TargetKind::Atlas => self.build_atlas(target),
TargetKind::Animation => self.build_animation(target),
TargetKind::AnimationPreview => self.build_animation_preview(target),
TargetKind::Export => self.build_export(target),
};
let duration = start.elapsed();
match build_result {
Ok(outputs) => {
if self.context.is_verbose() {
println!(" Done in {:?}", duration);
}
TargetResult::success(target.id.clone(), outputs, duration)
}
Err(e) => {
if self.context.is_verbose() {
println!(" Failed: {}", e);
}
TargetResult::failed(target.id.clone(), e, duration)
}
}
}
fn build_sprite(&self, target: &BuildTarget) -> Result<Vec<PathBuf>, String> {
for source in &target.sources {
if !source.exists() {
return Err(format!("Source file not found: {}", source.display()));
}
}
let source = target
.sources
.first()
.ok_or_else(|| "No source file specified for sprite target".to_string())?;
let file = File::open(source)
.map_err(|e| format!("Failed to open {}: {}", source.display(), e))?;
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
if !parse_result.warnings.is_empty() && self.context.is_strict() {
let warnings: Vec<String> =
parse_result.warnings.iter().map(|w| w.message.clone()).collect();
return Err(format!("Parse warnings in {}: {}", source.display(), warnings.join("; ")));
}
let mut palette_registry = PaletteRegistry::new();
let mut sprite_registry = SpriteRegistry::new();
let mut sprites = Vec::new();
for obj in parse_result.objects {
match obj {
TtpObject::Palette(p) => {
palette_registry.register(p);
}
TtpObject::Sprite(s) => {
sprites.push(s);
}
_ => {
}
}
}
for sprite in &sprites {
sprite_registry.register_sprite(sprite.clone());
}
let sprite_name = if sprites.len() == 1 {
sprites[0].name.clone()
} else {
sprites.iter().find(|s| s.name == target.name).map(|s| s.name.clone()).ok_or_else(
|| format!("Sprite '{}' not found in {}", target.name, source.display()),
)?
};
let sprite = sprite_registry
.get_sprite(&sprite_name)
.ok_or_else(|| format!("Sprite '{}' not found in registry", sprite_name))?;
let needs_transform_resolution = sprite.source.is_some() || sprite.transform.is_some();
let (image, render_warnings) = if needs_transform_resolution {
let resolved = sprite_registry
.resolve(&sprite_name, &palette_registry, self.context.is_strict())
.map_err(|e| format!("Failed to resolve sprite '{}': {}", sprite_name, e))?;
if self.context.is_verbose() {
for warning in &resolved.warnings {
eprintln!("Warning: sprite '{}': {}", sprite_name, warning.message);
}
}
render_resolved(&resolved)
} else {
let resolved_palette = if self.context.is_strict() {
palette_registry.resolve_strict(sprite).map_err(|e| {
format!("Failed to resolve palette for '{}': {}", sprite.name, e)
})?
} else {
let result = palette_registry.resolve_lenient(sprite);
if let Some(warning) = result.warning {
if self.context.is_verbose() {
eprintln!("Warning: {}", warning.message);
}
}
result.palette
};
render_sprite(sprite, &resolved_palette.colors)
};
if !render_warnings.is_empty() {
if self.context.is_strict() {
let warnings: Vec<String> =
render_warnings.iter().map(|w| w.message.clone()).collect();
return Err(format!(
"Render warnings for '{}': {}",
sprite.name,
warnings.join("; ")
));
} else if self.context.is_verbose() {
for warning in &render_warnings {
eprintln!("Warning: sprite '{}': {}", sprite.name, warning.message);
}
}
}
let scale = self.context.default_scale();
let final_image = if scale > 1 {
image::imageops::resize(
&image,
image.width() * scale,
image.height() * scale,
image::imageops::FilterType::Nearest,
)
} else {
image
};
final_image
.save(&target.output)
.map_err(|e| format!("Failed to save {}: {}", target.output.display(), e))?;
Ok(vec![target.output.clone()])
}
fn build_atlas(&self, target: &BuildTarget) -> Result<Vec<std::path::PathBuf>, String> {
for source in &target.sources {
if !source.exists() {
return Err(format!("Source file not found: {}", source.display()));
}
}
let atlas_config = self
.context
.config()
.atlases
.get(&target.name)
.ok_or_else(|| format!("Atlas config '{}' not found", target.name))?;
let padding = self.context.config().effective_padding(atlas_config);
let packer_config = PackerConfig {
max_size: (atlas_config.max_size[0], atlas_config.max_size[1]),
padding,
power_of_two: atlas_config.power_of_two,
};
let scale = self.context.default_scale();
let is_strict = self.context.is_strict();
let is_verbose = self.context.is_verbose();
let multi_source = target.sources.len() > 1;
struct RenderTask {
grid: Vec<String>,
colors: HashMap<String, String>,
sprite: crate::models::Sprite,
qualified_name: String,
}
let mut render_tasks: Vec<RenderTask> = Vec::new();
for source in &target.sources {
let file = File::open(source)
.map_err(|e| format!("Failed to open {}: {}", source.display(), e))?;
let reader = BufReader::new(file);
let parse_result = parse_stream(reader);
let mut palette_registry = PaletteRegistry::new();
let mut sprite_registry = SpriteRegistry::new();
let mut sprites = Vec::new();
for obj in parse_result.objects {
match obj {
TtpObject::Palette(p) => {
palette_registry.register(p);
}
TtpObject::Sprite(s) => {
sprites.push(s);
}
_ => {
}
}
}
for sprite in &sprites {
sprite_registry.register_sprite(sprite.clone());
}
let file_stem = source.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
for sprite in sprites {
let qualified_name = if multi_source {
format!("{}:{}", file_stem, sprite.name)
} else {
sprite.name.clone()
};
let needs_transform_resolution =
sprite.source.is_some() || sprite.transform.is_some();
let (grid, colors) = if needs_transform_resolution {
let resolved = sprite_registry
.resolve(&sprite.name, &palette_registry, is_strict)
.map_err(|e| {
format!(
"Failed to resolve sprite '{}' in {}: {}",
sprite.name,
source.display(),
e
)
})?;
if is_verbose {
for warning in &resolved.warnings {
eprintln!("Warning: sprite '{}': {}", sprite.name, warning.message);
}
}
(resolved.grid, resolved.palette)
} else {
let resolved_palette = if is_strict {
palette_registry.resolve_strict(&sprite).map_err(|e| {
format!(
"Failed to resolve palette for '{}' in {}: {}",
sprite.name,
source.display(),
e
)
})?
} else {
let result = palette_registry.resolve_lenient(&sprite);
if let Some(warning) = result.warning {
if is_verbose {
eprintln!("Warning: {}", warning.message);
}
}
result.palette
};
(sprite.grid.clone(), resolved_palette.colors)
};
render_tasks.push(RenderTask { grid, colors, sprite, qualified_name });
}
}
let render_results: Vec<Result<SpriteInput, String>> = render_tasks
.into_par_iter()
.map(|task| {
let resolved = ResolvedSprite {
name: task.sprite.name.clone(),
size: task.sprite.size,
grid: task.grid,
palette: task.colors,
warnings: vec![],
nine_slice: task.sprite.nine_slice.clone(),
};
let (image, render_warnings) = render_resolved(&resolved);
if !render_warnings.is_empty() {
if is_strict {
let warnings: Vec<String> =
render_warnings.iter().map(|w| w.message.clone()).collect();
return Err(format!(
"Render warnings for '{}': {}",
task.sprite.name,
warnings.join("; ")
));
} else if is_verbose {
for warning in &render_warnings {
eprintln!(
"Warning: sprite '{}': {}",
task.sprite.name, warning.message
);
}
}
}
let final_image = if scale > 1 {
image::imageops::resize(
&image,
image.width() * scale,
image.height() * scale,
image::imageops::FilterType::Nearest,
)
} else {
image
};
let origin = task.sprite.metadata.as_ref().and_then(|m| m.origin);
let boxes = task.sprite.metadata.as_ref().and_then(|m| {
m.boxes.as_ref().map(|b| {
b.iter()
.map(|(name, cb)| {
(name.clone(), AtlasBox { x: cb.x, y: cb.y, w: cb.w, h: cb.h })
})
.collect::<HashMap<_, _>>()
})
});
Ok(SpriteInput { name: task.qualified_name, image: final_image, origin, boxes })
})
.collect();
let sprite_inputs: Vec<SpriteInput> =
render_results.into_iter().collect::<Result<Vec<_>, _>>()?;
if sprite_inputs.is_empty() {
return Err(format!("No sprites found in source files for atlas '{}'", target.name));
}
let base_name = target.output.file_stem().and_then(|s| s.to_str()).unwrap_or(&target.name);
let result = pack_atlas(&sprite_inputs, &packer_config, base_name);
if result.atlases.is_empty() {
return Err("Failed to pack any sprites into atlas".to_string());
}
let mut outputs = Vec::new();
let out_dir = target.output.parent().unwrap_or_else(|| std::path::Path::new("."));
for (image, metadata) in &result.atlases {
let png_path = out_dir.join(&metadata.image);
image
.save(&png_path)
.map_err(|e| format!("Failed to save atlas PNG {}: {}", png_path.display(), e))?;
outputs.push(png_path);
let json_name = metadata.image.replace(".png", ".json");
let json_path = out_dir.join(&json_name);
let json_content = serde_json::to_string_pretty(&metadata)
.map_err(|e| format!("Failed to serialize atlas metadata: {}", e))?;
fs::write(&json_path, json_content).map_err(|e| {
format!("Failed to write atlas JSON {}: {}", json_path.display(), e)
})?;
outputs.push(json_path);
}
Ok(outputs)
}
fn build_animation(&self, target: &BuildTarget) -> Result<Vec<std::path::PathBuf>, String> {
for source in &target.sources {
if !source.exists() {
return Err(format!("Source file not found: {}", source.display()));
}
}
Ok(vec![target.output.clone()])
}
fn build_animation_preview(
&self,
target: &BuildTarget,
) -> Result<Vec<std::path::PathBuf>, String> {
for source in &target.sources {
if !source.exists() {
return Err(format!("Source file not found: {}", source.display()));
}
}
Ok(vec![target.output.clone()])
}
fn build_export(&self, target: &BuildTarget) -> Result<Vec<std::path::PathBuf>, String> {
use crate::atlas::AtlasMetadata;
use crate::export::{
godot::{GodotExportOptions, GodotExporter},
libgdx::{LibGdxExportOptions, LibGdxExporter},
unity::{UnityExportOptions, UnityExporter, UnityFilterMode},
};
let parts: Vec<&str> = target.id.split(':').collect();
if parts.len() < 3 {
return Err(format!("Invalid export target ID: {}", target.id));
}
let format = parts[1];
let atlas_name = parts[2];
let atlas_json_path = self.context.out_dir().join(format!("{}.json", atlas_name));
if !atlas_json_path.exists() {
return Err(format!(
"Atlas metadata not found: {}. Build the atlas first.",
atlas_json_path.display()
));
}
let json_content = fs::read_to_string(&atlas_json_path)
.map_err(|e| format!("Failed to read atlas metadata: {}", e))?;
let metadata: AtlasMetadata = serde_json::from_str(&json_content)
.map_err(|e| format!("Failed to parse atlas metadata: {}", e))?;
if let Some(parent) = target.output.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create output directory: {}", e))?;
}
let outputs = match format {
"godot" => {
let config = &self.context.config().exports.godot;
let exporter = GodotExporter::new()
.with_resource_path(&config.resource_path)
.with_sprite_frames(config.sprite_frames)
.with_animation_player(config.animation_player);
let options = GodotExportOptions {
resource_path: config.resource_path.clone(),
sprite_frames: config.sprite_frames,
animation_player: config.animation_player,
atlas_textures: true,
..Default::default()
};
let output_dir =
target.output.parent().unwrap_or_else(|| std::path::Path::new("."));
exporter
.export_godot(&metadata, output_dir, &options)
.map_err(|e| format!("Godot export failed: {}", e))?
}
"unity" => {
let config = &self.context.config().exports.unity;
let filter_mode = UnityFilterMode::from_config(&config.filter_mode);
let exporter = UnityExporter::new()
.with_pixels_per_unit(config.pixels_per_unit)
.with_filter_mode(filter_mode);
let options = UnityExportOptions {
pixels_per_unit: config.pixels_per_unit,
filter_mode,
..Default::default()
};
exporter
.export_unity(&metadata, &target.output, &options)
.map_err(|e| format!("Unity export failed: {}", e))?;
vec![target.output.clone()]
}
"libgdx" => {
let exporter = LibGdxExporter::new();
let options = LibGdxExportOptions::default();
exporter
.export_libgdx(&metadata, &target.output, &options)
.map_err(|e| format!("libGDX export failed: {}", e))?;
vec![target.output.clone()]
}
_ => {
return Err(format!("Unknown export format: {}", format));
}
};
Ok(outputs)
}
}
pub struct Build {
context: Option<BuildContext>,
fail_fast: bool,
dry_run: bool,
verbose: bool,
strict: bool,
filter: Option<Vec<String>>,
}
impl Build {
pub fn new() -> Self {
Self {
context: None,
fail_fast: false,
dry_run: false,
verbose: false,
strict: false,
filter: None,
}
}
pub fn context(mut self, context: BuildContext) -> Self {
self.context = Some(context);
self
}
pub fn fail_fast(mut self, fail_fast: bool) -> Self {
self.fail_fast = fail_fast;
self
}
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub fn filter(mut self, targets: Vec<String>) -> Self {
self.filter = Some(targets);
self
}
pub fn run(self) -> Result<BuildResult, BuildError> {
let mut context = self
.context
.ok_or_else(|| BuildError::Build("No build context provided".to_string()))?;
context = context.with_verbose(self.verbose).with_strict(self.strict);
if let Some(filter) = self.filter {
context = context.with_filter(filter);
}
BuildPipeline::new(context)
.with_fail_fast(self.fail_fast)
.with_dry_run(self.dry_run)
.build()
}
}
impl Default for Build {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::default_config;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn create_test_context() -> (TempDir, BuildContext) {
let temp = TempDir::new().unwrap();
let config = default_config();
let ctx = BuildContext::new(config, temp.path().to_path_buf());
let src_dir = temp.path().join("src/pxl");
fs::create_dir_all(&src_dir).unwrap();
(temp, ctx)
}
#[test]
fn test_build_pipeline_new() {
let (_temp, ctx) = create_test_context();
let pipeline = BuildPipeline::new(ctx);
assert!(!pipeline.fail_fast);
assert!(!pipeline.dry_run);
}
#[test]
fn test_build_pipeline_with_options() {
let (_temp, ctx) = create_test_context();
let pipeline = BuildPipeline::new(ctx).with_fail_fast(true).with_dry_run(true);
assert!(pipeline.fail_fast);
assert!(pipeline.dry_run);
}
#[test]
fn test_build_pipeline_empty_build() {
let (_temp, ctx) = create_test_context();
let pipeline = BuildPipeline::new(ctx);
let result = pipeline.build().unwrap();
assert!(result.is_success());
assert_eq!(result.targets.len(), 0);
}
#[test]
fn test_build_pipeline_dry_run() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("test.pxl");
File::create(&sprite_file).unwrap().write_all(b"{}").unwrap();
let pipeline = BuildPipeline::new(ctx).with_dry_run(true);
let result = pipeline.build().unwrap();
assert!(result.is_success());
}
#[test]
fn test_build_builder() {
let (_temp, ctx) = create_test_context();
let result = Build::new().context(ctx).dry_run(true).verbose(false).run().unwrap();
assert!(result.is_success());
}
#[test]
fn test_execute_target_missing_source() {
let (_temp, ctx) = create_test_context();
let pipeline = BuildPipeline::new(ctx);
let target = BuildTarget::sprite(
"missing".to_string(),
std::path::PathBuf::from("/nonexistent/file.pxl"),
std::path::PathBuf::from("/output/missing.png"),
);
let result = pipeline.execute_target(&target);
assert!(result.status.is_failure());
}
#[test]
fn test_build_sprite_renders_png() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("red_dot.pxl");
let sprite_content = r##"{"type": "sprite", "name": "red_dot", "palette": {"{r}": "#FF0000"}, "grid": ["{r}"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("red_dot.png");
let pipeline = BuildPipeline::new(ctx);
let target = BuildTarget::sprite("red_dot".to_string(), sprite_file, output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
assert!(output_file.exists(), "Output PNG file should exist");
let img = image::open(&output_file).expect("Should open as valid PNG");
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
#[test]
fn test_build_sprite_with_named_palette() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("green_pixel.pxl");
let sprite_content = r##"{"type": "palette", "name": "colors", "colors": {"{g}": "#00FF00"}}
{"type": "sprite", "name": "green_pixel", "palette": "colors", "grid": ["{g}"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("green_pixel.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::sprite("green_pixel".to_string(), sprite_file, output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
assert!(output_file.exists(), "Output PNG file should exist");
let img = image::open(&output_file).expect("Should open as valid PNG").to_rgba8();
let pixel = img.get_pixel(0, 0);
assert_eq!(pixel[0], 0, "Red channel should be 0");
assert_eq!(pixel[1], 255, "Green channel should be 255");
assert_eq!(pixel[2], 0, "Blue channel should be 0");
}
#[test]
fn test_build_sprite_2x2_grid() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("checkerboard.pxl");
let sprite_content = r##"{"type": "sprite", "name": "checkerboard", "palette": {"{b}": "#000000", "{w}": "#FFFFFF"}, "grid": ["{b}{w}", "{w}{b}"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("checkerboard.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::sprite("checkerboard".to_string(), sprite_file, output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let img = image::open(&output_file).expect("Should open as valid PNG");
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 2);
}
#[test]
fn test_build_sprite_with_source_and_transform() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("arrows.pxl");
let sprite_content = r##"{"type": "palette", "name": "colors", "colors": {"{r}": "#FF0000", "{ }": "#00000000"}}
{"type": "sprite", "name": "arrow_right", "palette": "colors", "grid": ["{ }{r}", "{r}{r}", "{ }{r}"]}
{"type": "sprite", "name": "arrow_left", "palette": "colors", "source": "arrow_right", "transform": ["mirror-h"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("arrow_left.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::sprite("arrow_left".to_string(), sprite_file, output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
assert!(output_file.exists(), "Output PNG file should exist");
let img = image::open(&output_file).expect("Should open as valid PNG").to_rgba8();
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 3);
let top_left = img.get_pixel(0, 0);
let top_right = img.get_pixel(1, 0);
assert_eq!(top_left[0], 255, "Top-left red channel should be 255 (mirrored)");
assert_eq!(top_left[3], 255, "Top-left alpha should be 255 (opaque red)");
assert_eq!(top_right[3], 0, "Top-right alpha should be 0 (transparent)");
}
#[test]
fn test_build_sprite_with_rotate_transform() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("rotated.pxl");
let sprite_content = r##"{"type": "sprite", "name": "bar", "palette": {"{r}": "#FF0000", "{g}": "#00FF00"}, "grid": ["{r}{g}"]}
{"type": "sprite", "name": "rotated", "palette": {"{r}": "#FF0000", "{g}": "#00FF00"}, "source": "bar", "transform": ["rotate:90"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("rotated.png");
let pipeline = BuildPipeline::new(ctx);
let target = BuildTarget::sprite("rotated".to_string(), sprite_file, output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let img = image::open(&output_file).expect("Should open as valid PNG").to_rgba8();
assert_eq!(img.width(), 1, "Width should be 1 after 90 degree rotation");
assert_eq!(img.height(), 2, "Height should be 2 after 90 degree rotation");
}
#[test]
fn test_build_sprite_with_direct_transform() {
let (temp, ctx) = create_test_context();
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("tiled.pxl");
let sprite_content = r##"{"type": "sprite", "name": "tiled", "palette": {"{r}": "#FF0000"}, "grid": ["{r}"], "transform": [{"op": "tile", "w": 2, "h": 2}]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("tiled.png");
let pipeline = BuildPipeline::new(ctx);
let target = BuildTarget::sprite("tiled".to_string(), sprite_file, output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let img = image::open(&output_file).expect("Should open as valid PNG").to_rgba8();
assert_eq!(img.width(), 2, "Width should be 2 after 2x2 tiling");
assert_eq!(img.height(), 2, "Height should be 2 after 2x2 tiling");
for y in 0..2 {
for x in 0..2 {
let pixel = img.get_pixel(x, y);
assert_eq!(pixel[0], 255, "Red channel should be 255");
assert_eq!(pixel[1], 0, "Green channel should be 0");
assert_eq!(pixel[2], 0, "Blue channel should be 0");
}
}
}
fn create_atlas_test_context(atlas_name: &str, sources: Vec<&str>) -> (TempDir, BuildContext) {
use crate::config::{AtlasConfig as ConfigAtlas, ProjectConfig, PxlConfig};
let temp = TempDir::new().unwrap();
let mut atlases = std::collections::HashMap::new();
atlases.insert(
atlas_name.to_string(),
ConfigAtlas {
sources: sources.into_iter().map(String::from).collect(),
max_size: [1024, 1024],
padding: Some(0),
power_of_two: false,
nine_slice: false,
},
);
let config = PxlConfig {
project: ProjectConfig {
name: "test".to_string(),
version: "0.1.0".to_string(),
src: std::path::PathBuf::from("src/pxl"),
out: std::path::PathBuf::from("build"),
},
atlases,
..default_config()
};
let ctx = BuildContext::new(config, temp.path().to_path_buf());
let src_dir = temp.path().join("src/pxl");
fs::create_dir_all(&src_dir).unwrap();
(temp, ctx)
}
#[test]
fn test_build_atlas_single_sprite() {
let (temp, ctx) = create_atlas_test_context("test_atlas", vec!["sprites/*.pxl"]);
let src_dir = temp.path().join("src/pxl/sprites");
fs::create_dir_all(&src_dir).unwrap();
let sprite_file = src_dir.join("red.pxl");
let sprite_content = r##"{"type": "sprite", "name": "red", "palette": {"{r}": "#FF0000"}, "grid": ["{r}{r}", "{r}{r}"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("test_atlas.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::atlas("test_atlas".to_string(), vec![sprite_file], output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let png_path = out_dir.join("test_atlas.png");
assert!(png_path.exists(), "Atlas PNG should exist");
let img = image::open(&png_path).expect("Should open as valid PNG");
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 2);
let json_path = out_dir.join("test_atlas.json");
assert!(json_path.exists(), "Atlas JSON should exist");
let json_content = fs::read_to_string(&json_path).expect("Should read JSON");
assert!(json_content.contains("\"red\""), "JSON should contain sprite name");
assert!(json_content.contains("\"frames\""), "JSON should contain frames");
}
#[test]
fn test_build_atlas_multiple_sprites() {
let (temp, ctx) = create_atlas_test_context("chars", vec!["**/*.pxl"]);
let src_dir = temp.path().join("src/pxl");
let red_file = src_dir.join("red.pxl");
let red_content = r##"{"type": "sprite", "name": "red", "palette": {"{r}": "#FF0000"}, "grid": ["{r}"]}"##;
File::create(&red_file).unwrap().write_all(red_content.as_bytes()).unwrap();
let green_file = src_dir.join("green.pxl");
let green_content = r##"{"type": "sprite", "name": "green", "palette": {"{g}": "#00FF00"}, "grid": ["{g}"]}"##;
File::create(&green_file).unwrap().write_all(green_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("chars.png");
let pipeline = BuildPipeline::new(ctx);
let target = BuildTarget::atlas(
"chars".to_string(),
vec![red_file, green_file],
output_file.clone(),
);
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let png_path = out_dir.join("chars.png");
assert!(png_path.exists(), "Atlas PNG should exist");
let json_path = out_dir.join("chars.json");
assert!(json_path.exists(), "Atlas JSON should exist");
let json_content = fs::read_to_string(&json_path).expect("Should read JSON");
assert!(json_content.contains("red"), "JSON should contain red sprite");
assert!(json_content.contains("green"), "JSON should contain green sprite");
}
#[test]
fn test_build_atlas_with_metadata() {
let (temp, ctx) = create_atlas_test_context("player", vec!["*.pxl"]);
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("player.pxl");
let sprite_content = r##"{"type": "sprite", "name": "player", "palette": {"{r}": "#FF0000"}, "grid": ["{r}{r}{r}{r}", "{r}{r}{r}{r}", "{r}{r}{r}{r}", "{r}{r}{r}{r}"], "metadata": {"origin": [2, 4], "boxes": {"hurt": {"x": 0, "y": 0, "w": 4, "h": 4}}}}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("player.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::atlas("player".to_string(), vec![sprite_file], output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let json_path = out_dir.join("player.json");
let json_content = fs::read_to_string(&json_path).expect("Should read JSON");
assert!(json_content.contains("\"origin\""), "JSON should contain origin");
assert!(json_content.contains("\"boxes\""), "JSON should contain boxes");
assert!(json_content.contains("\"hurt\""), "JSON should contain hurt box");
}
#[test]
fn test_build_atlas_no_sprites_error() {
let (temp, ctx) = create_atlas_test_context("empty", vec!["*.pxl"]);
let src_dir = temp.path().join("src/pxl");
let palette_file = src_dir.join("colors.pxl");
let palette_content =
r##"{"type": "palette", "name": "colors", "colors": {"{r}": "#FF0000"}}"##;
File::create(&palette_file).unwrap().write_all(palette_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("empty.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::atlas("empty".to_string(), vec![palette_file], output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_failure(), "Should fail when no sprites found");
}
#[test]
fn test_build_atlas_power_of_two() {
use crate::config::{AtlasConfig as ConfigAtlas, ProjectConfig, PxlConfig};
let temp = TempDir::new().unwrap();
let mut atlases = std::collections::HashMap::new();
atlases.insert(
"pot".to_string(),
ConfigAtlas {
sources: vec!["*.pxl".to_string()],
max_size: [1024, 1024],
padding: Some(0),
power_of_two: true,
nine_slice: false,
},
);
let config = PxlConfig {
project: ProjectConfig {
name: "test".to_string(),
version: "0.1.0".to_string(),
src: std::path::PathBuf::from("src/pxl"),
out: std::path::PathBuf::from("build"),
},
atlases,
..default_config()
};
let ctx = BuildContext::new(config, temp.path().to_path_buf());
let src_dir = temp.path().join("src/pxl");
fs::create_dir_all(&src_dir).unwrap();
let sprite_file = src_dir.join("small.pxl");
let sprite_content = r##"{"type": "sprite", "name": "small", "palette": {"{r}": "#FF0000"}, "grid": ["{r}{r}{r}", "{r}{r}{r}", "{r}{r}{r}"]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("pot.png");
let pipeline = BuildPipeline::new(ctx);
let target = BuildTarget::atlas("pot".to_string(), vec![sprite_file], output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let png_path = out_dir.join("pot.png");
let img = image::open(&png_path).expect("Should open as valid PNG");
assert_eq!(img.width(), 4, "Width should be power of 2 (4)");
assert_eq!(img.height(), 4, "Height should be power of 2 (4)");
}
#[test]
fn test_build_atlas_with_transforms() {
let (temp, ctx) = create_atlas_test_context("transformed", vec!["*.pxl"]);
let src_dir = temp.path().join("src/pxl");
let sprite_file = src_dir.join("shapes.pxl");
let sprite_content = r##"{"type": "sprite", "name": "bar", "palette": {"{r}": "#FF0000"}, "grid": ["{r}{r}"]}
{"type": "sprite", "name": "block", "palette": {"{r}": "#FF0000"}, "source": "bar", "transform": [{"op": "tile", "w": 1, "h": 2}]}"##;
File::create(&sprite_file).unwrap().write_all(sprite_content.as_bytes()).unwrap();
let out_dir = temp.path().join("build");
fs::create_dir_all(&out_dir).unwrap();
let output_file = out_dir.join("transformed.png");
let pipeline = BuildPipeline::new(ctx);
let target =
BuildTarget::atlas("transformed".to_string(), vec![sprite_file], output_file.clone());
let result = pipeline.execute_target(&target);
assert!(result.status.is_success(), "Expected success, got: {:?}", result.status);
let json_path = out_dir.join("transformed.json");
let json_content = fs::read_to_string(&json_path).expect("Should read JSON");
assert!(json_content.contains("\"bar\""), "Atlas should contain original bar sprite");
assert!(
json_content.contains("\"block\""),
"Atlas should contain transformed block sprite"
);
let metadata: serde_json::Value = serde_json::from_str(&json_content).unwrap();
let frames = metadata.get("frames").expect("Should have frames");
let block_frame = frames.get("block").expect("Should have block frame");
assert_eq!(
block_frame.get("w").and_then(|v| v.as_u64()),
Some(2),
"Block width should be 2"
);
assert_eq!(
block_frame.get("h").and_then(|v| v.as_u64()),
Some(2),
"Block height should be 2 (tiled)"
);
}
}