Skip to main content

fastpack_gui/
worker.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use fastpack_core::{
5    algorithms::{
6        basic::Basic,
7        grid::Grid,
8        maxrects::MaxRects,
9        packer::{PackInput, Packer},
10    },
11    imaging::{alias::detect_aliases, extrude, loader, trim},
12    types::{
13        atlas::AtlasFrame,
14        config::{AlgorithmConfig, Project},
15        rect::{Rect, SourceRect},
16        sprite::Sprite,
17    },
18};
19use rayon::prelude::*;
20use walkdir::WalkDir;
21
22/// A single packed frame returned to the UI thread.
23pub struct FrameInfo {
24    /// Sprite identifier.
25    pub id: String,
26    /// Packed X position in atlas pixels.
27    pub x: u32,
28    /// Packed Y position in atlas pixels.
29    pub y: u32,
30    /// Packed frame width in pixels.
31    pub w: u32,
32    /// Packed frame height in pixels.
33    pub h: u32,
34    /// Canonical sprite ID if this frame is a duplicate.
35    pub alias_of: Option<String>,
36}
37
38/// One packed sheet (atlas texture + frame metadata).
39pub struct SheetOutput {
40    /// Raw RGBA pixel data for this sheet.
41    pub rgba: Vec<u8>,
42    /// Atlas width in pixels.
43    pub width: u32,
44    /// Atlas height in pixels.
45    pub height: u32,
46    /// Per-frame positioning data for the UI.
47    pub frames: Vec<FrameInfo>,
48    /// Full atlas frame data for exporters.
49    pub atlas_frames: Vec<AtlasFrame>,
50}
51
52/// Data returned to the UI after a successful pack.
53pub struct WorkerOutput {
54    /// All packed sheets produced by this run.
55    pub sheets: Vec<SheetOutput>,
56    /// Unique sprites packed (excluding aliases).
57    pub sprite_count: usize,
58    /// Sprites deduplicated as aliases.
59    pub alias_count: usize,
60    /// Sprites that did not fit (only non-zero when multipack is disabled).
61    pub overflow_count: usize,
62}
63
64/// Messages sent from the worker thread to the UI thread.
65pub enum WorkerMessage {
66    /// The worker thread has begun processing.
67    Started,
68    /// Incremental progress update.
69    Progress { done: usize, total: usize },
70    /// Pack completed successfully.
71    Finished(Box<WorkerOutput>),
72    /// Pack failed with this error message.
73    Failed(String),
74}
75
76static IMAGE_EXTENSIONS: &[&str] = &[
77    "png", "jpg", "jpeg", "bmp", "tga", "webp", "tiff", "tif", "gif",
78];
79
80fn is_image(path: &Path) -> bool {
81    path.extension()
82        .and_then(|e| e.to_str())
83        .map(|e| IMAGE_EXTENSIONS.contains(&e.to_lowercase().as_str()))
84        .unwrap_or(false)
85}
86
87fn file_id(path: &Path, base: &Path) -> String {
88    let rel = path.strip_prefix(base).unwrap_or(path);
89    rel.with_extension("").to_string_lossy().replace('\\', "/")
90}
91
92fn collect_images(project: &Project) -> Vec<(PathBuf, String)> {
93    let mut paths = Vec::new();
94    for source in &project.sources {
95        if source.path.is_file() {
96            if is_image(&source.path) {
97                let base = source.path.parent().unwrap_or(Path::new(""));
98                paths.push((source.path.clone(), file_id(&source.path, base)));
99            }
100        } else {
101            for entry in WalkDir::new(&source.path)
102                .sort_by_file_name()
103                .into_iter()
104                .flatten()
105            {
106                if entry.file_type().is_file() && is_image(entry.path()) {
107                    let id = file_id(entry.path(), &source.path);
108                    paths.push((entry.path().to_path_buf(), id));
109                }
110            }
111        }
112    }
113    paths
114}
115
116fn build_sheet(
117    packer: &dyn Packer,
118    sprites: Vec<Sprite>,
119    project: &Project,
120) -> Result<(SheetOutput, Vec<Sprite>)> {
121    let sprite_cfg = &project.config.sprites;
122    let pack_output = packer
123        .pack(PackInput {
124            sprites,
125            config: project.config.layout.clone(),
126            sprite_config: sprite_cfg.clone(),
127        })
128        .map_err(|e| anyhow::anyhow!("packing failed: {e}"))?;
129
130    let overflow = pack_output.overflow;
131
132    // Packer guarantees non-overlapping placements, so parallel writes are sound.
133    // Transmit the pointer as usize (Send + Sync) to satisfy the closure bounds.
134    let aw = pack_output.atlas_size.w as usize;
135    let ah = pack_output.atlas_size.h as usize;
136    let mut canvas_raw = vec![0u8; aw * ah * 4];
137    let buf_ptr = canvas_raw.as_mut_ptr() as usize;
138    let buf_stride = aw;
139
140    pack_output.placed.par_iter().for_each(move |ps| {
141        let dx = ps.placement.dest.x as usize;
142        let dy = ps.placement.dest.y as usize;
143        let dw = ps.placement.dest.w as usize;
144        let dh = ps.placement.dest.h as usize;
145        let rgba = ps.sprite.image.as_rgba8().expect("sprite is rgba8");
146        let dst = buf_ptr as *mut u8;
147
148        if ps.placement.rotated {
149            let rotated = image::imageops::rotate90(rgba);
150            let src_raw = rotated.as_raw();
151            for row in 0..dh {
152                unsafe {
153                    std::ptr::copy_nonoverlapping(
154                        src_raw.as_ptr().add(row * dw * 4),
155                        dst.add(((dy + row) * buf_stride + dx) * 4),
156                        dw * 4,
157                    );
158                }
159            }
160        } else {
161            let src_raw = rgba.as_raw();
162            let src_stride = rgba.width() as usize * 4;
163            for row in 0..dh {
164                unsafe {
165                    std::ptr::copy_nonoverlapping(
166                        src_raw.as_ptr().add(row * src_stride),
167                        dst.add(((dy + row) * buf_stride + dx) * 4),
168                        dw * 4,
169                    );
170                }
171            }
172        }
173    });
174
175    let frames: Vec<FrameInfo> = pack_output
176        .placed
177        .iter()
178        .map(|ps| FrameInfo {
179            id: ps.placement.sprite_id.clone(),
180            x: ps.placement.dest.x,
181            y: ps.placement.dest.y,
182            w: ps.placement.dest.w,
183            h: ps.placement.dest.h,
184            alias_of: None,
185        })
186        .collect();
187
188    let atlas_frames: Vec<AtlasFrame> = pack_output
189        .placed
190        .iter()
191        .map(|ps| {
192            let trimmed = ps.sprite.trim_rect.is_some();
193            let sss = ps.sprite.trim_rect.unwrap_or(SourceRect {
194                x: 0,
195                y: 0,
196                w: ps.sprite.original_size.w,
197                h: ps.sprite.original_size.h,
198            });
199            AtlasFrame {
200                id: ps.placement.sprite_id.clone(),
201                frame: Rect::new(
202                    ps.placement.dest.x,
203                    ps.placement.dest.y,
204                    ps.placement.dest.w,
205                    ps.placement.dest.h,
206                ),
207                rotated: ps.placement.rotated,
208                trimmed,
209                sprite_source_size: sss,
210                source_size: ps.sprite.original_size,
211                polygon: ps.sprite.polygon.clone(),
212                nine_patch: ps.sprite.nine_patch,
213                pivot: ps.sprite.pivot,
214                alias_of: None,
215            }
216        })
217        .collect();
218
219    let width = pack_output.atlas_size.w;
220    let height = pack_output.atlas_size.h;
221    let rgba = canvas_raw;
222
223    Ok((
224        SheetOutput {
225            rgba,
226            width,
227            height,
228            frames,
229            atlas_frames,
230        },
231        overflow,
232    ))
233}
234
235/// Run the full pack pipeline for the given project and return raw atlas data.
236///
237/// Intended to be called from a background thread.
238pub fn run_pack(project: &Project) -> Result<WorkerOutput> {
239    let n = std::thread::available_parallelism()
240        .map(|p| p.get().saturating_sub(2).max(1))
241        .unwrap_or(1);
242    rayon::ThreadPoolBuilder::new()
243        .num_threads(n)
244        .build()
245        .map_err(|e| anyhow::anyhow!("{e}"))?
246        .install(|| run_pack_impl(project))
247}
248
249fn run_pack_impl(project: &Project) -> Result<WorkerOutput> {
250    // 1. Collect
251    let paths = collect_images(project);
252    if paths.is_empty() {
253        anyhow::bail!("no images found in the configured sources");
254    }
255
256    // 2. Load (parallel)
257    let mut sprites: Vec<Sprite> = paths
258        .par_iter()
259        .filter_map(|(path, id)| match loader::load(path, id.clone()) {
260            Ok(s) => Some(s),
261            Err(e) => {
262                tracing::warn!("failed to load {}: {e}", path.display());
263                None
264            }
265        })
266        .collect();
267    if sprites.is_empty() {
268        anyhow::bail!("all images failed to load");
269    }
270
271    let sprite_cfg = &project.config.sprites;
272
273    // 3. Trim (parallel)
274    sprites
275        .par_iter_mut()
276        .for_each(|s| trim::trim(s, sprite_cfg));
277
278    // 3.5 Extrude (parallel)
279    if sprite_cfg.extrude > 0 {
280        sprites
281            .par_iter_mut()
282            .for_each(|s| extrude::extrude(s, sprite_cfg.extrude));
283    }
284
285    let sprite_count = sprites.len();
286
287    // 4. Alias detection
288    let (base_sprites, base_aliases) = if sprite_cfg.detect_aliases {
289        detect_aliases(sprites)
290    } else {
291        (sprites, Vec::new())
292    };
293    let alias_count = base_aliases.len();
294
295    // 5. Build packer
296    let packer: Box<dyn Packer> = match &project.config.algorithm {
297        AlgorithmConfig::Grid {
298            cell_width,
299            cell_height,
300        } => Box::new(Grid {
301            cell_width: if *cell_width == 0 {
302                None
303            } else {
304                Some(*cell_width)
305            },
306            cell_height: if *cell_height == 0 {
307                None
308            } else {
309                Some(*cell_height)
310            },
311        }),
312        AlgorithmConfig::Basic => Box::new(Basic),
313        AlgorithmConfig::MaxRects { heuristic } => Box::new(MaxRects {
314            heuristic: *heuristic,
315        }),
316        AlgorithmConfig::Polygon => Box::new(MaxRects::default()),
317    };
318
319    // 6. Pack loop (multipack produces multiple sheets)
320    let multipack = project.config.output.multipack;
321    let mut remaining = base_sprites;
322    let mut overflow_count = 0;
323    let mut sheets: Vec<SheetOutput> = Vec::new();
324
325    loop {
326        let (mut sheet, overflow) = build_sheet(packer.as_ref(), remaining, project)?;
327        remaining = overflow;
328
329        // Aliases point into sheet 0; append them there only.
330        if sheets.is_empty() {
331            let alias_coords: Vec<(u32, u32, u32, u32)> = {
332                let frame_id_to_rect: std::collections::HashMap<&str, (u32, u32, u32, u32)> = sheet
333                    .frames
334                    .iter()
335                    .map(|f| (f.id.as_str(), (f.x, f.y, f.w, f.h)))
336                    .collect();
337                base_aliases
338                    .iter()
339                    .map(|alias| {
340                        let canon = alias.alias_of.as_deref().unwrap_or("");
341                        frame_id_to_rect.get(canon).copied().unwrap_or_default()
342                    })
343                    .collect()
344            };
345
346            for (alias, (x, y, w, h)) in base_aliases.iter().zip(alias_coords) {
347                sheet.frames.push(FrameInfo {
348                    id: alias.id.clone(),
349                    x,
350                    y,
351                    w,
352                    h,
353                    alias_of: alias.alias_of.clone(),
354                });
355                sheet.atlas_frames.push(AtlasFrame {
356                    id: alias.id.clone(),
357                    frame: Rect::new(x, y, w, h),
358                    rotated: false,
359                    trimmed: false,
360                    sprite_source_size: SourceRect {
361                        x: 0,
362                        y: 0,
363                        w: alias.original_size.w,
364                        h: alias.original_size.h,
365                    },
366                    source_size: alias.original_size,
367                    polygon: None,
368                    nine_patch: alias.nine_patch,
369                    pivot: alias.pivot,
370                    alias_of: alias.alias_of.clone(),
371                });
372            }
373        }
374
375        sheets.push(sheet);
376
377        if remaining.is_empty() {
378            break;
379        }
380        if !multipack {
381            overflow_count = remaining.len();
382            break;
383        }
384    }
385
386    Ok(WorkerOutput {
387        sheets,
388        sprite_count,
389        alias_count,
390        overflow_count,
391    })
392}