diff --git a/.gitignore b/.gitignore deleted file mode 100644 index c60ed37..0000000 --- a/.gitignore +++ /dev/null @@ -1,60 +0,0 @@ -# Prerequisites -*.d - -# Compiled Object files -*.slo -*.lo -*.o -*.obj - -# Precompiled Headers -*.gch -*.pch - -# Linker files -*.ilk - -# Debugger Files -*.pdb - -# Compiled Dynamic libraries -*.so -*.dylib -*.dll - -# Fortran module files -*.mod -*.smod - -# Compiled Static libraries -*.lai -*.la -*.a -*.lib - -# Executables -*.exe -*.out -*.app - -# debug information files -*.dwo - -# More -**/CMakeFiles/ -**/CMakeCache -**/CMakeCache.txt -*.cmake -**/Makefile -**/build/ -**/Testing -stb/* -layout.* -spritesheet.* -frames -export -spratlayout -spratpack -spratconvert -README-assets/* -!README-assets/*.png diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index 2acb26a..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,85 +0,0 @@ -cmake_minimum_required(VERSION 3.15) -project(sprat LANGUAGES CXX) -include(GNUInstallDirs) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -# External headers -set(STB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/stb) -file(MAKE_DIRECTORY ${STB_DIR}) - -option(SPRAT_DOWNLOAD_STB "Download stb headers when missing" OFF) -set(STB_REF "master" CACHE STRING "Git ref (branch, tag, or commit) used when downloading stb headers") -set(STB_BASE_URL https://raw.githubusercontent.com/nothings/stb/${STB_REF}) - -function(ensure_stb_header header_name) - set(target_path ${STB_DIR}/${header_name}) - if(EXISTS ${target_path}) - file(SIZE ${target_path} target_size) - if(target_size GREATER 0) - return() - endif() - message(WARNING "Found empty ${header_name}") - endif() - - if(NOT SPRAT_DOWNLOAD_STB) - message(FATAL_ERROR - "Missing ${header_name} in ${STB_DIR}. " - "Either provide vendored headers or configure with " - "-DSPRAT_DOWNLOAD_STB=ON (optionally set -DSTB_REF=).") - endif() - - set(download_path ${target_path}.tmp) - if(EXISTS ${download_path}) - file(REMOVE ${download_path}) - endif() - - message(STATUS "Downloading ${header_name} from ${STB_BASE_URL}") - file( - DOWNLOAD - ${STB_BASE_URL}/${header_name} - ${download_path} - STATUS download_status - TLS_VERIFY ON - ) - - list(GET download_status 0 status_code) - list(GET download_status 1 status_message) - if(NOT status_code EQUAL 0) - file(REMOVE ${download_path}) - message(FATAL_ERROR "Failed to download ${header_name}: ${status_message}") - endif() - - file(SIZE ${download_path} download_size) - if(download_size EQUAL 0) - file(REMOVE ${download_path}) - message(FATAL_ERROR "Downloaded ${header_name} is empty") - endif() - - file(RENAME ${download_path} ${target_path}) -endfunction() - -ensure_stb_header(stb_image.h) -ensure_stb_header(stb_image_write.h) - -include_directories(${STB_DIR}) - -# Binaries -add_executable(spratlayout spratlayout.cpp) -add_executable(spratpack spratpack.cpp) -add_executable(spratconvert spratconvert.cpp) -target_compile_definitions(spratconvert PRIVATE SPRAT_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") -target_compile_definitions(spratlayout PRIVATE - SPRAT_GLOBAL_PROFILE_CONFIG="${CMAKE_INSTALL_FULL_DATADIR}/sprat/spratprofiles.cfg") - -install(TARGETS spratlayout spratpack spratconvert - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) -install(FILES spratprofiles.cfg - DESTINATION ${CMAKE_INSTALL_DATADIR}/sprat) -install(FILES man/sprat-cli.1 - DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) - -enable_testing() -add_subdirectory(tests) diff --git a/README-assets/compact_gpu_pad2.png b/README-assets/compact_gpu_pad2.png index c74f14a..9160286 100644 Binary files a/README-assets/compact_gpu_pad2.png and b/README-assets/compact_gpu_pad2.png differ diff --git a/README-assets/compact_space_pad2.png b/README-assets/compact_space_pad2.png index 2953892..9132a9f 100644 Binary files a/README-assets/compact_space_pad2.png and b/README-assets/compact_space_pad2.png differ diff --git a/README-assets/fast_pad2.png b/README-assets/fast_pad2.png index 982d803..3d6885a 100644 Binary files a/README-assets/fast_pad2.png and b/README-assets/fast_pad2.png differ diff --git a/README-assets/pot_pad2.png b/README-assets/pot_pad2.png index 2c79a3a..27f1766 100644 Binary files a/README-assets/pot_pad2.png and b/README-assets/pot_pad2.png differ diff --git a/README-assets/res_3840x2160_1920x1080_pad2.png b/README-assets/res_3840x2160_1920x1080_pad2.png index 0fc070d..6b8fc2b 100644 Binary files a/README-assets/res_3840x2160_1920x1080_pad2.png and b/README-assets/res_3840x2160_1920x1080_pad2.png differ diff --git a/README-assets/screenshot.png b/README-assets/screenshot.png index 40b55a5..bda4b84 100644 Binary files a/README-assets/screenshot.png and b/README-assets/screenshot.png differ diff --git a/README-assets/snapshot_2026-02-13_19-04-25.png b/README-assets/snapshot_2026-02-13_19-04-25.png new file mode 100644 index 0000000..e4578ac Binary files /dev/null and b/README-assets/snapshot_2026-02-13_19-04-25.png differ diff --git a/README-assets/snapshot_2026-02-13_19-37-53.png b/README-assets/snapshot_2026-02-13_19-37-53.png new file mode 100644 index 0000000..ffc3e6c Binary files /dev/null and b/README-assets/snapshot_2026-02-13_19-37-53.png differ diff --git a/README-assets/snapshot_2026-02-13_21-38-36.png b/README-assets/snapshot_2026-02-13_21-38-36.png new file mode 100644 index 0000000..8fd4f39 Binary files /dev/null and b/README-assets/snapshot_2026-02-13_21-38-36.png differ diff --git a/README-assets/snapshot_2026-02-15_03-07-40.png b/README-assets/snapshot_2026-02-15_03-07-40.png new file mode 100644 index 0000000..191c8fa Binary files /dev/null and b/README-assets/snapshot_2026-02-15_03-07-40.png differ diff --git a/README-assets/trim_pad2_lines.png b/README-assets/trim_pad2_lines.png index 0205689..d697d58 100644 Binary files a/README-assets/trim_pad2_lines.png and b/README-assets/trim_pad2_lines.png differ diff --git a/README.md b/README.md deleted file mode 100644 index 59f7c3b..0000000 --- a/README.md +++ /dev/null @@ -1,471 +0,0 @@ -# sprat-cli -Command-line sprite sheet generator. - -![sprat-cli screenshot](README-assets/screenshot.png) - -## Motivation - -This project started after using TexturePacker and looking for free, open-source CLI tools to support a sprite sheet generation pipeline. - -## Principles - -- Keep it simple. -- Do one thing, and do it right. -- Prioritize output-driven workflows. -- Work naturally with `|` and `>`. -- Keep dependencies minimal. -- Automate setup during compilation when possible (for example, downloading dependencies). -- Input flexibility: accept sprite frames of any size. -- Prioritize generated image optimization for GPU usage over packing algorithm runtime. -- Focus on usefulness. -- No GUI: the CLI can later be used to build one. -- Be usable in command lines, CI/CD, and Git-based pipelines. -- Encourage AI-assisted workflows (for example, Codex) for faster iteration and maintenance. -- Stay open source and free. - -## Getting started - -Build: - -```sh -sh build.sh -``` - -Generate layout first: - -```sh -./spratlayout ./frames > layout.txt -``` - -Inspect layout text: - -```sh -cat layout.txt -``` - -Pack PNG from that layout: - -```sh -./spratpack < layout.txt > spritesheet.png -``` - -Optional one-pipe run: - -```sh -./spratlayout ./frames --trim-transparent --padding 2 | ./spratpack > spritesheet.png -``` - -Convert layout to JSON/CSV/XML/CSS: - -```sh -./spratconvert --transform json < layout.txt > layout.json -``` - -Manual page: - -```sh -man ./man/sprat-cli.1 -``` - -## Installation - -Install binaries, man page, and global profile config: - -```sh -sudo cmake --install . -``` - -## Workflow - -`spratlayout` scans a folder of input images (or a plaintext list of image paths) and prints a text layout to stdout: - -```sh -./spratlayout ./frames > layout.txt -``` - -If the first argument is a file, `spratlayout` treats it as a newline-separated list of image paths. Blank lines and lines beginning with `#` are ignored. Relative paths are resolved relative to the list file, each path must exist, be a regular image file (`.png`, `.jpg`, `.bmp`, etc.), and they are loaded in the order listed; otherwise the command fails. - -Profiles are flexible named rule sets. A profile groups packing rules (for example mode, optimize target, limits, padding, trim, scale, threads) under one name, so you can run `--profile NAME` instead of repeating many options each time. - -Profile definitions are driven by `spratprofiles.cfg`. The lookup order is: - -1. `--profiles-config PATH` (when provided) -2. `~/.config/sprat/spratprofiles.cfg` -3. `spratprofiles.cfg` in the same directory as `spratlayout` -4. Global installed config (from `make install`, typically `${prefix}/share/sprat/spratprofiles.cfg`) - -Each `[profile name]` section can define: - -- `mode=compact|pot|fast` -- `optimize=gpu|space` -- `max_width` and `max_height` (optional atlas limits) -- `padding` (integer >= 0) -- `max_combinations` (integer >= 0) -- `scale` (number > 0 and <= 1) -- `trim_transparent=true|false` -- `threads` (integer > 0) -- `source_resolution` (WxH) -- `target_resolution` (WxH or `source`) -- `resolution_reference=largest|smallest` - -Add new sections to define custom profiles and refer to them with `--profile NAME`. Profile values are defaults; command options override them per run. - -Examples (concept): - -- `--profile mobile` applies the rules stored under `mobile`. -- `--profile mobile --padding 4` uses `mobile` and overrides only padding for that run. - -`spratlayout` options: - -- `--profile NAME` (default: `fast`) -- `--profiles-config PATH` (override the config file path; can be relative or absolute) -- `--mode compact|pot|fast` -- `--optimize gpu|space` -- `--padding N` (default: `0`) -- `--max-combinations N` (default: `0` = auto/unlimited; caps compact candidate trials) -- `--scale F` (default: `1`, valid range: `(0, 1]`; applies before resolution mapping) -- `--trim-transparent` / `--no-trim-transparent` -- `--max-width N` / `--max-height N` (optional atlas limits) -- `--threads N` (override worker count for compact profile search; default: auto) - -Layout caching: - -- `spratlayout` keeps metadata and output caches in the system temp directory (for example `/tmp` on Linux/macOS, `%TEMP%` on Windows). -- Cache entries are reused when inputs and options are unchanged. -- Cache entries older than one hour are pruned automatically. - -Why these options help: - -- `--padding N`: avoids texture bleeding/artifacts from sampling and subpixel math. -- `--scale F`: normalize intentionally oversized source sprites before target resolution mapping. -- `--trim-transparent`: removes empty borders to reduce atlas usage. -- `--max-width/--max-height`: enforce hardware/platform texture limits. -- `spratpack --frame-lines`: visual debug of sprite bounds, spacing, and overlaps. - -## Recipes - -### Compact Mode (GPU Optimized) - -Default behavior. Tries to keep the atlas square-ish but prioritizes width/height that fits well in GPU memory. - -!Compact GPU - -```sh -./spratlayout ./frames --mode compact --optimize gpu --padding 2 > layout.txt -./spratpack < layout.txt > compact_gpu_pad2.png -``` -![compact gpu](README-assets/compact_gpu_pad2.png) - -### Compact Mode (Space Optimized) - -Tries to minimize total area, regardless of aspect ratio. - -```sh -./spratlayout ./frames --mode compact --optimize space --padding 2 > layout.txt -./spratpack < layout.txt > compact_space.png -``` -![compact space](README-assets/compact_space_pad2.png) - -### Fast Mode - -Uses a shelf packing algorithm. Much faster for huge datasets, but less efficient packing. - -```sh -./spratlayout ./frames --mode fast --padding 2 > layout.txt -./spratpack < layout.txt > fast.png -``` -![fast](README-assets/fast_pad2.png) - -### Power of Two (POT) - -Forces the output atlas to be a power of two (e.g., 512x512, 1024x512). - -```sh -./spratlayout ./frames --mode pot --padding 2 > layout.txt -./spratpack < layout.txt > pot.png -``` -![pot](README-assets/pot_pad2.png) - -### Trimming Transparency - -Removes transparent pixels from sprite edges. `spratpack` can draw frame lines to visualize the trimmed bounds. - -```sh -./spratlayout ./frames --trim-transparent --padding 2 > layout.txt -./spratpack --frame-lines --line-color 0,255,0 < layout.txt > trim.png -``` -![trim](README-assets/trim_pad2_lines.png) - -### Resolution Mapping - -Automatically scales sprites based on a target resolution. Useful for multi-platform builds (e.g., designing for 4K, building for 1080p). - -```sh -# Scale = 1920 / 3840 = 0.5 -./spratlayout ./frames \ - --source-resolution 3840x2160 \ - --target-resolution 1920x1080 \ - --padding 2 > layout.txt -./spratpack < layout.txt > resolution.png -``` -![resolutions](README-assets/res_3840x2160_1920x1080_pad2.png) - -## Benchmarking - -Trim benchmark (repeatable local comparison): - -```sh -./scripts/benchmark-trim.sh ./build/spratlayout ./frames 5 -``` - -Scale recipe (smaller output for lower resolutions): - -```sh -./spratlayout ./frames --profile mobile --scale 0.5 > layout_mobile_half.txt -./spratpack < layout_mobile_half.txt > spritesheet_mobile_half.png -``` - -Resolution-aware scale recipe: - -```sh -./spratlayout ./frames --profile mobile \ - --source-resolution 3840x2160 --target-resolution 1920x1080 --scale 0.5 \ - > layout_mobile_targeted.txt -./spratpack < layout_mobile_targeted.txt > spritesheet_mobile_targeted.png -``` - -The output format is: - -- `atlas ,` -- `scale ` -- `sprite "" , ,` - -When `--trim-transparent` is enabled, sprite lines include crop offsets: - -- `sprite "" , , , ,` - -Example output from: - -```sh -./spratlayout ./frames --trim-transparent > layout.txt -``` - -```txt -atlas 1631,1963 -scale 1 -sprite "./tests/png/Run (6).png" 0,0 335,495 109,54 123,7 -sprite "./tests/png/RunShoot (6).png" 345,0 373,495 109,54 85,7 -sprite "./tests/png/RunShoot (2).png" 728,0 362,492 121,54 84,10 -``` - -## Layout transforms (`spratconvert`) - -`spratconvert` reads layout text from stdin and writes transformed output to stdout. -The term `transform` is used because conversion is template-driven and data-oriented. - -List built-in transforms: - -```sh -./spratconvert --list-transforms -``` - -Use a built-in transform: - -```sh -./spratconvert --transform json < layout.txt > layout.json -./spratconvert --transform csv < layout.txt > layout.csv -./spratconvert --transform xml < layout.txt > layout.xml -./spratconvert --transform css < layout.txt > layout.css -``` - -Optional extra data files: - -```sh -./spratconvert --transform json --markers markers.json --animations animations.json < layout.txt > layout.json -``` - -Built-in transform files live in `transforms/`: - -- `transforms/json.transform` -- `transforms/csv.transform` -- `transforms/xml.transform` -- `transforms/css.transform` - -Each transform is section-based: - -- Use explicit open/close tags for sections, for example `[meta]` ... `[/meta]`. -- `[meta]` for metadata like `name`, `description`, `extension` -- `[header]` printed once before sprites -- `[if_markers]` / `[if_no_markers]` conditional blocks based on marker items -- `[markers_header]`, `[markers]`, `[marker]`, `[markers_separator]`, `[markers_footer]` marker loop sections -- `[sprites]` container with `[sprite]` item template repeated for each sprite (required) -- `[separator]` inserted between sprite entries -- `[if_animations]` / `[if_no_animations]` conditional blocks based on animation items -- `[animations_header]`, `[animations]`, `[animation]`, `[animations_separator]`, `[animations_footer]` animation loop sections -- `[footer]` printed once after sprites - -Common placeholders: - -- `{{atlas_width}}`, `{{atlas_height}}`, `{{scale}}`, `{{sprite_count}}` -- `{{index}}`, `{{name}}`, `{{path}}`, `{{x}}`, `{{y}}`, `{{w}}`, `{{h}}` -- `{{src_x}}`, `{{src_y}}`, `{{trim_left}}`, `{{trim_top}}`, `{{trim_right}}`, `{{trim_bottom}}` -- Escaped sprite fields: `{{name_json}}`, `{{name_csv}}`, `{{name_xml}}`, `{{name_css}}`, `{{path_json}}`, `{{path_csv}}`, `{{path_xml}}`, `{{path_css}}` -- Per-sprite markers: `{{sprite_markers_count}}`, `{{sprite_markers_json}}`, `{{sprite_markers_csv}}`, `{{sprite_markers_xml}}`, `{{sprite_markers_css}}` -- Marker loop placeholders: - - `{{marker_index}}`, `{{marker_name}}`, `{{marker_type}}` - - `{{marker_x}}`, `{{marker_y}}`, `{{marker_radius}}`, `{{marker_w}}`, `{{marker_h}}` - - `{{marker_vertices}}`, `{{marker_vertices_json}}`, `{{marker_vertices_csv}}`, `{{marker_vertices_xml}}`, `{{marker_vertices_css}}` - - `{{marker_sprite_index}}`, `{{marker_sprite_name}}`, `{{marker_sprite_path}}` -- Animation loop placeholders: - - `{{animation_index}}`, `{{animation_name}}` - - `{{animation_sprite_count}}`, `{{animation_sprite_indexes}}`, `{{animation_sprite_indexes_json}}`, `{{animation_sprite_indexes_csv}}` -- Extra file placeholders: - - `{{has_markers}}`, `{{has_animations}}`, `{{marker_count}}`, `{{animation_count}}` - - `{{markers_path}}`, `{{animations_path}}` - - `{{markers_raw}}`, `{{animations_raw}}` - - `{{markers_json}}`, `{{markers_csv}}`, `{{markers_xml}}`, `{{markers_css}}` - - `{{animations_json}}`, `{{animations_csv}}`, `{{animations_xml}}`, `{{animations_css}}` - -Sprite names default to the source file basename without extension (for example `./frames/run_01.png` becomes `run_01`). - -`--markers` expects JSON with sprite associations. `markers` must be an array of objects with at least `name` and `type`. -Supported marker types: -- `point`: `x`, `y` -- `circle`: `x`, `y`, `radius` -- `rectangle`: `x`, `y`, `w`, `h` -- `polygon`: `vertices` (ordered list of `{x,y}` objects) -Example: `{"sprites":{"./frames/a.png":{"markers":[{"name":"hit","type":"point","x":3,"y":5}]}}}`. -`--animations` expects JSON timelines (for example `{"timelines":[{"name":"run","frames":["./frames/a.png","b"]}]}`), and frame entries are resolved to sprite indexes by path or sprite name. - -Custom transform example: - -```ini -[meta] -name=compact-log -[/meta] - -[header] -atlas={{atlas_width}}x{{atlas_height}} sprites={{sprite_count}} -[/header] - -[sprites] - [sprite] -{{index}} {{path}} @ {{x}},{{y}} {{w}}x{{h}} - [/sprite] -[/sprites] - -[separator] -; -[/separator] - -[footer] - -done -[/footer] -``` - -Run custom transform: - -```sh -./spratconvert --transform ./my.transform < layout.txt > layout.custom.txt -``` - -Column meanings for the `sprite` line in trim mode: - -- `""`: source image path. -- `,`: top-left position in the output atlas where the trimmed sprite is placed. -- `,`: trimmed width and height written into the atlas. -- `,`: pixels trimmed from the left and top of the original image. -- `,`: pixels trimmed from the right and bottom of the original image. - -`spratpack` reads that layout from stdin and writes the final PNG spritesheet to stdout: - -```sh -./spratpack < layout.txt > spritesheet.png -``` - -Optional frame divider overlay: - -- `--frame-lines` (draw sprite rectangle outlines) -- `--line-width N` (default: `1`) -- `--line-color R,G,B[,A]` (default: `255,0,0,255`) -- `--threads N` (parallel sprite decode/blit when sprite rectangles do not overlap) - -Example: - -```sh -./spratpack --frame-lines --line-width 2 --line-color 0,255,0 < layout.txt > spritesheet.png -``` - -You can also pipe both commands directly: - -```sh -./spratlayout ./frames | ./spratpack > spritesheet.png -``` - -License for third-party art is defined by the asset author; verify terms before redistribution. - -## Free Sprite Sources - -Sample asset source used in this page: https://opengameart.org/content/the-robot-free-sprite - -- https://kenney.nl/assets (CC0/public-domain-style game assets) -- https://opengameart.org/ (mixed licenses, check each pack) -- https://itch.io/game-assets/free/tag-sprites (license varies by author) - -## Texture Optimization References - -Shape and layout: - -- https://en.wikipedia.org/wiki/Texture_atlas (texture atlas overview) -- https://github.com/juj/RectangleBinPack (MaxRects and related bin-packing approaches) -- https://www.khronos.org/opengl/wiki/Texture (mipmaps, filtering, and texture behavior) - -Color formats and precision: - -- https://www.khronos.org/opengl/wiki/Image_Format (normalized, integer, float, and sRGB formats) -- https://learn.microsoft.com/windows/win32/direct3ddds/dx-graphics-dds-pguide (DDS format/container guidance) - -Compression formats: - -- https://www.khronos.org/opengl/wiki/S3_Texture_Compression (S3TC/BC-style compression in OpenGL) -- https://learn.microsoft.com/windows/win32/direct3d11/texture-block-compression-in-direct3d-11 (BC1-BC7 overview and tradeoffs) - -Sampling artifacts and alpha: - -- https://learnopengl.com/Advanced-OpenGL/Blending (alpha blending behavior) -- https://learnopengl.com/Advanced-OpenGL/Anti-Aliasing (sampling and edge artifacts) - -Platform and engine guidance: - -- https://docs.vulkan.org/guide/latest/ (modern cross-platform texture usage guidance) -- https://docs.unity3d.com/Manual/class-TextureImporter.html (Unity import/compression settings) - -## Contributing - -Suggestions, pull requests, and forks are welcome. - -High-impact contribution areas: - -- Packaging and distribution: - - Linux packages (deb/rpm), Homebrew formulae, Scoop/Chocolatey, Arch/AUR, Nix, etc. - - Release automation and artifact publication for multiple platforms. -- GUI frontends: - - Desktop/web/mobile wrappers around the CLI pipeline. - - Workflow-focused tools that call `spratlayout`, `spratpack`, and `spratconvert` under the hood. -- Engine/runtime integrations: - - Importers/exporters and transform templates for specific game engines or frameworks. - - Community-maintained presets and examples. -- CI/CD and developer tooling: - - Cross-platform build/test matrices. - - Reproducible packaging and versioned release pipelines. - -Core scope remains a free UNIX-style CLI. GUI and platform integrations are encouraged as companion projects or optional layers. - -## License - -MIT. See [LICENSE](LICENSE). - -## Support - -[![Buy Me A Coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=&slug=pedroac&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://buymeacoffee.com/pedroac) diff --git a/man/sprat-cli.1 b/man/sprat-cli.1 deleted file mode 100644 index 2e9b392..0000000 --- a/man/sprat-cli.1 +++ /dev/null @@ -1,217 +0,0 @@ -.TH SPRAT-CLI 1 "February 2026" "sprat-cli" "User Commands" -.SH NAME -sprat-cli \- sprite atlas layout and packing pipeline -.SH SYNOPSIS -.B spratlayout -.I folder -[\fB\-\-profile\fR \fINAME\fR] -[\fB\-\-profiles\-config\fR \fIPATH\fR] -[\fB\-\-mode\fR compact|pot|fast] -[\fB\-\-optimize\fR gpu|space] -[\fB\-\-max\-width\fR \fIN\fR] -[\fB\-\-max\-height\fR \fIN\fR] -[\fB\-\-padding\fR \fIN\fR] -[\fB\-\-max\-combinations\fR \fIN\fR] -[\fB\-\-source\-resolution\fR \fIWxH\fR] -[\fB\-\-target\-resolution\fR \fIWxH\fR] -[\fB\-\-resolution\-reference\fR largest|smallest] -[\fB\-\-scale\fR \fIF\fR] -[\fB\-\-trim\-transparent\fR] -[\fB\-\-no\-trim\-transparent\fR] -[\fB\-\-threads\fR \fIN\fR] -.PP -.B spratpack -[\fB\-\-frame\-lines\fR] -[\fB\-\-line\-width\fR \fIN\fR] -[\fB\-\-line\-color\fR \fIR,G,B[,A]\fR] -[\fB\-\-threads\fR \fIN\fR] -.PP -.B spratconvert -[\fB\-\-transform\fR \fINAME|PATH\fR] -[\fB\-\-list\-transforms\fR] -.SH DESCRIPTION -\fBsprat\-cli\fR is a UNIX pipeline for generating sprite sheets and transforming layout metadata. -.PP -\fBspratlayout\fR scans an image folder and writes a text layout to standard output. -.PP -\fBspratpack\fR reads the layout from standard input and writes a PNG atlas to standard output. -.PP -\fBspratconvert\fR reads the same layout format from standard input and transforms it into text formats (for example JSON, CSV, XML, CSS) using template-driven transforms. -.PP -Normal output is written to stdout. Errors are written to stderr. -.SH COMMANDS -.SS spratlayout -Reads image metadata from files in \fIfolder\fR and prints layout lines: -.PP -atlas \fIwidth,height\fR -.br -scale \fIfactor\fR -.br -sprite "\fIpath\fR" \fIx,y\fR \fIw,h\fR [\fIleft,top right,bottom\fR] -.PP -If \fB\-\-trim\-transparent\fR is enabled, trim offsets are included per sprite. -.PP -If \fIfolder\fR is actually a text file, it is treated as a newline-delimited list of image paths (comments starting with \fB#\fR and blank lines are ignored). Relative paths are resolved relative to the list file, and each path must point to an existing image or the command fails. -.PP -Profile configuration is loaded from the first existing file in this order: -.IP "\(bu" 2 -\fB\-\-profiles\-config\fR path (when provided) -.IP "\(bu" 2 -\fB~/.config/sprat/spratprofiles.cfg\fR -.IP "\(bu" 2 -\fBspratprofiles.cfg\fR in the same directory as \fBspratlayout\fR -.IP "\(bu" 2 -Global installed config (typically \fB/usr/local/share/sprat/spratprofiles.cfg\fR) -.PP -Profile values act as defaults; CLI options override them per run. -.PP -Performance cache: -.IP "\(bu" 2 -Metadata and output caches are stored in the system temp directory. -.IP "\(bu" 2 -Unchanged inputs/options reuse cached results. -.IP "\(bu" 2 -Entries older than one hour are pruned automatically. -.SS spratpack -Reads layout text from stdin and writes a PNG stream to stdout. -.PP -Layout accepts: -.br -atlas \fIwidth,height\fR (or legacy \fIatlas width height\fR) -.br -scale \fIfactor\fR -.br -sprite lines in current or legacy numeric format -.SS spratconvert -Reads layout text from stdin and writes transformed text to stdout. -.PP -Transforms can be selected by name from \fBtransforms/\fR (for example \fBjson\fR, \fBcsv\fR, \fBxml\fR, \fBcss\fR) or by path to a custom \fB.transform\fR file. -Optional \fB\-\-markers\fR and \fB\-\-animations\fR files can be loaded and referenced by transform placeholders. -Transform sections use explicit open/close tags (for example \fB[meta]\fR ... \fB[/meta]\fR). -Sprite iteration is defined as \fB[sprites]\fR containing \fB[sprite]\fR; animation iteration as \fB[animations]\fR containing \fB[animation]\fR; marker iteration as \fB[markers]\fR containing \fB[marker]\fR. -Templates support marker/animation conditional sections (\fB[if_markers]\fR, \fB[if_no_markers]\fR, \fB[if_animations]\fR, \fB[if_no_animations]\fR) and optional separator/header/footer sections. -Sprite placeholders include \fB{{name}}\fR (basename without extension), and marker loop placeholders include \fB{{marker_name}}\fR plus sprite association fields (\fB{{marker_sprite_index}}\fR, \fB{{marker_sprite_name}}\fR). -Animation loop placeholders include \fB{{animation_name}}\fR and \fB{{animation_sprite_indexes_json}}\fR. -Built-in JSON transform output omits \fBindex\fR fields from \fBsprites[]\fR and \fBanimations[]\fR. -.SH OPTIONS -.SS spratlayout -.TP -\fB\-\-profile\fR \fINAME\fR -Profile name loaded from profile config. Default: \fBfast\fR. -.TP -\fB\-\-profiles\-config\fR \fIPATH\fR -Use an explicit profile configuration file. -.TP -\fB\-\-mode\fR compact|pot|fast -Override packing mode from selected profile. -.TP -\fB\-\-optimize\fR gpu|space -Override optimization target from selected profile. -.TP -\fB\-\-max\-width\fR \fIN\fR -Maximum atlas width. Overrides profile value. -.TP -\fB\-\-max\-height\fR \fIN\fR -Maximum atlas height. Overrides profile value. -.TP -\fB\-\-padding\fR \fIN\fR -Extra pixels between packed sprites. Overrides profile value. -.TP -\fB\-\-max\-combinations\fR \fIN\fR -Maximum number of compact candidate combinations to test (\fB0\fR means auto/unlimited). Overrides profile value. -.TP -\fB\-\-source\-resolution\fR \fIWxH\fR -Source design resolution baseline (for example \fB800x600\fR). Must be used together with \fB\-\-target\-resolution\fR. -.TP -\fB\-\-target\-resolution\fR \fIWxH\fR -Target output resolution (for example \fB800x600\fR). Must be used together with \fB\-\-source\-resolution\fR. -.TP -\fB\-\-resolution\-reference\fR largest|smallest -Choose which axis ratio drives source/target resolution scaling. Default: \fBlargest\fR. -.TP -\fB\-\-scale\fR \fIF\fR -Pre-scale factor applied before source/target resolution mapping. Valid range: \fB0 < F <= 1\fR. Overrides profile value. -.TP -\fB\-\-trim\-transparent\fR -Enable transparent-border trimming (overrides profile value). -.TP -\fB\-\-no\-trim\-transparent\fR -Disable transparent-border trimming (overrides profile value). -.TP -\fB\-\-threads\fR \fIN\fR -Worker thread count for compact profile search. Overrides profile value. -.PP -Config keys available per profile section: -.IP "\(bu" 2 -\fBmode=compact|pot|fast\fR -.IP "\(bu" 2 -\fBoptimize=gpu|space\fR -.IP "\(bu" 2 -\fBmax_width\fR, \fBmax_height\fR -.IP "\(bu" 2 -\fBpadding\fR, \fBmax_combinations\fR -.IP "\(bu" 2 -\fBscale\fR (\fB0 < scale <= 1\fR), \fBtrim_transparent\fR, \fBthreads\fR -.SS spratpack -.TP -\fB\-\-frame\-lines\fR -Draw rectangle outlines for each sprite. -.TP -\fB\-\-line\-width\fR \fIN\fR -Outline thickness in pixels. Default: \fB1\fR. -.TP -\fB\-\-line\-color\fR \fIR,G,B[,A]\fR -Outline color channels (0-255). Default: \fB255,0,0,255\fR. -.TP -\fB\-\-threads\fR \fIN\fR -Worker thread count for sprite decode/blit. Used when sprite rectangles do not overlap. Default: auto. -.SS spratconvert -.TP -\fB\-\-transform\fR \fINAME|PATH\fR -Transform name from \fBtransforms/\fR or path to a custom transform file. Default: \fBjson\fR. -.TP -\fB\-\-list\-transforms\fR -Print available transforms and exit. -.TP -\fB\-\-markers\fR \fIPATH\fR -Load external markers text and expose it to transform placeholders. -Markers JSON expects \fB{"sprites":{...}}\fR with per-sprite \fBmarkers\fR arrays of objects (not strings), each with at least \fBname\fR and \fBtype\fR. -Supported marker types: \fBpoint\fR (\fBx\fR,\fBy\fR), \fBcircle\fR (\fBx\fR,\fBy\fR,\fBradius\fR), \fBrectangle\fR (\fBx\fR,\fBy\fR,\fBw\fR,\fBh\fR), \fBpolygon\fR (\fBvertices\fR list of \fB{x,y}\fR). -.TP -\fB\-\-animations\fR \fIPATH\fR -Load external animations text and expose it to transform placeholders. -.SH EXAMPLES -.TP -Generate layout text: -.B spratlayout ./frames > layout.txt -.TP -Pack PNG from layout: -.B spratpack < layout.txt > spritesheet.png -.TP -Transform layout to JSON: -.B spratconvert --transform json < layout.txt > layout.json -.TP -Transform layout with custom template: -.B spratconvert --transform ./my.transform < layout.txt > layout.custom.txt -.TP -Transform layout with extra files: -.B spratconvert --transform json --markers markers.json --animations animations.json < layout.txt > layout.json -.TP -Run as one pipeline: -.B spratlayout ./frames --trim-transparent --padding 2 | spratpack > spritesheet.png -.TP -Debug frame bounds: -.B spratpack --frame-lines --line-width 2 --line-color 0,255,0 < layout.txt > spritesheet_lines.png -.TP -Limit worker threads: -.B spratlayout ./frames --threads 4 > layout.txt ; spratpack --threads 4 < layout.txt > spritesheet.png -.SH EXIT STATUS -All commands return: -.TP -\fB0\fR -Success. -.TP -\fB1\fR -Invalid arguments, invalid input data, image decode/encode failure, or packing failure. -.SH SEE ALSO -\fBspratlayout\fR(1), \fBspratpack\fR(1), \fBspratconvert\fR(1) diff --git a/scripts/regenerate-readme-assets.sh b/scripts/regenerate-readme-assets.sh deleted file mode 100755 index f21d490..0000000 --- a/scripts/regenerate-readme-assets.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -set -e - -# Configuration -ASSET_URL="https://opengameart.org/sites/default/files/RobotFree.zip" -ASSET_ZIP="RobotFree.zip" -ASSET_DIR="README-assets" -FRAMES_DIR="$ASSET_DIR/frames" -FRAME_MAX_SIZE="64x64>" - -# Ensure directories exist -mkdir -p "$ASSET_DIR" -mkdir -p "$FRAMES_DIR" - -# Download assets -if [ ! -f "$ASSET_DIR/$ASSET_ZIP" ]; then - echo "Downloading assets..." - curl -L -o "$ASSET_DIR/$ASSET_ZIP" "$ASSET_URL" -fi - -# Extract and process frames -if [ -z "$(ls -A "$FRAMES_DIR")" ]; then - echo "Extracting and processing frames..." - unzip -j -o "$ASSET_DIR/$ASSET_ZIP" "png/*" -d "$FRAMES_DIR" - - # Resize frames to make spritesheets manageable for README - # Requires ImageMagick - mogrify -resize "$FRAME_MAX_SIZE" "$FRAMES_DIR"/*.png -fi - -# Build paths -SPRATLAYOUT="./spratlayout" -SPRATPACK="./spratpack" -PROFILES_CONFIG="./spratprofiles.cfg" - -if [ ! -f "$SPRATLAYOUT" ]; then - SPRATLAYOUT="./build/spratlayout" - SPRATPACK="./build/spratpack" -fi - -if [ ! -f "$SPRATLAYOUT" ] || [ ! -f "$SPRATPACK" ]; then - echo "Binaries not found. Please build sprat-cli first." - exit 1 -fi - -echo "Generating recipes..." - -# 1. Compact GPU -echo "Generating recipe: compact-gpu" -"$SPRATLAYOUT" "$FRAMES_DIR" --mode compact --optimize gpu --padding 2 > "$ASSET_DIR/compact_gpu_pad2.txt" -"$SPRATPACK" < "$ASSET_DIR/compact_gpu_pad2.txt" > "$ASSET_DIR/compact_gpu_pad2.png" - -# 2. Compact Space -echo "Generating recipe: compact-space" -"$SPRATLAYOUT" "$FRAMES_DIR" --mode compact --optimize space --padding 2 > "$ASSET_DIR/compact_space_pad2.txt" -"$SPRATPACK" < "$ASSET_DIR/compact_space_pad2.txt" > "$ASSET_DIR/compact_space_pad2.png" - -# 3. Fast -echo "Generating recipe: fast" -"$SPRATLAYOUT" "$FRAMES_DIR" --mode fast --padding 2 > "$ASSET_DIR/fast_pad2.txt" -"$SPRATPACK" < "$ASSET_DIR/fast_pad2.txt" > "$ASSET_DIR/fast_pad2.png" - -# 4. POT -echo "Generating recipe: pot" -"$SPRATLAYOUT" "$FRAMES_DIR" --mode pot --padding 2 > "$ASSET_DIR/pot_pad2.txt" -"$SPRATPACK" < "$ASSET_DIR/pot_pad2.txt" > "$ASSET_DIR/pot_pad2.png" - -# 5. Trim -echo "Generating recipe: trim" -"$SPRATLAYOUT" "$FRAMES_DIR" --trim-transparent --padding 2 > "$ASSET_DIR/trim_pad2_lines.txt" -"$SPRATPACK" --frame-lines --line-color 0,255,0 < "$ASSET_DIR/trim_pad2_lines.txt" > "$ASSET_DIR/trim_pad2_lines.png" - -# 6. Resolution -echo "Generating recipe: resolution" -"$SPRATLAYOUT" "$FRAMES_DIR" --source-resolution 3840x2160 --target-resolution 1920x1080 --padding 2 > "$ASSET_DIR/res_3840x2160_1920x1080_pad2.txt" -"$SPRATPACK" < "$ASSET_DIR/res_3840x2160_1920x1080_pad2.txt" > "$ASSET_DIR/res_3840x2160_1920x1080_pad2.png" - -echo "Done." \ No newline at end of file diff --git a/spratconvert.cpp b/spratconvert.cpp deleted file mode 100644 index 3e30ad0..0000000 --- a/spratconvert.cpp +++ /dev/null @@ -1,1719 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -struct Sprite { - std::string path; - int x = 0; - int y = 0; - int w = 0; - int h = 0; - int src_x = 0; - int src_y = 0; - int trim_right = 0; - int trim_bottom = 0; -}; - -struct Layout { - int atlas_width = 0; - int atlas_height = 0; - double scale = 1.0; - bool has_scale = false; - std::vector sprites; -}; - -struct Transform { - std::string name; - std::string description; - std::string extension; - std::string header; - std::string if_markers; - std::string if_no_markers; - std::string markers_header; - std::string markers; - std::string markers_separator; - std::string markers_footer; - std::string sprite; - std::string separator; - std::string if_animations; - std::string if_no_animations; - std::string animations_header; - std::string animations; - std::string animations_separator; - std::string animations_footer; - std::string footer; -}; - -struct MarkerItem { - size_t index = 0; - int sprite_index = -1; - std::string sprite_name; - std::string sprite_path; - std::string name; - std::string type; - int x = 0; - int y = 0; - int radius = 0; - int w = 0; - int h = 0; - std::vector> vertices; -}; - -struct AnimationItem { - size_t index = 0; - std::string name; - std::vector sprite_indexes; - int fps = 8; -}; - -std::string trim_copy(const std::string& s) { - size_t start = 0; - while (start < s.size() && std::isspace(static_cast(s[start])) != 0) { - ++start; - } - - size_t end = s.size(); - while (end > start && std::isspace(static_cast(s[end - 1])) != 0) { - --end; - } - - return s.substr(start, end - start); -} - -bool parse_int(const std::string& token, int& out) { - if (token.empty()) { - return false; - } - std::istringstream iss(token); - int value = 0; - char extra = '\0'; - if (!(iss >> value)) { - return false; - } - if (iss >> extra) { - return false; - } - out = value; - return true; -} - -bool parse_double(const std::string& token, double& out) { - if (token.empty()) { - return false; - } - std::istringstream iss(token); - double value = 0.0; - char extra = '\0'; - if (!(iss >> value)) { - return false; - } - if (iss >> extra) { - return false; - } - out = value; - return std::isfinite(value); -} - -bool parse_pair(const std::string& token, int& a, int& b) { - size_t comma = token.find(','); - if (comma == std::string::npos || comma == 0 || comma + 1 >= token.size()) { - return false; - } - if (token.find(',', comma + 1) != std::string::npos) { - return false; - } - return parse_int(token.substr(0, comma), a) && parse_int(token.substr(comma + 1), b); -} - -bool parse_quoted(std::string_view input, size_t& pos, std::string& out, std::string& error) { - if (pos >= input.size() || input[pos] != '"') { - error = "expected opening quote for sprite path"; - return false; - } - - ++pos; - out.clear(); - - while (pos < input.size()) { - char c = input[pos++]; - if (c == '\\') { - if (pos >= input.size()) { - error = "unterminated escape sequence in sprite path"; - return false; - } - char escaped = input[pos++]; - if (escaped == '"' || escaped == '\\') { - out.push_back(escaped); - } else { - out.push_back('\\'); - out.push_back(escaped); - } - } else if (c == '"') { - return true; - } else { - out.push_back(c); - } - } - - error = "unterminated quoted sprite path"; - return false; -} - -bool parse_sprite_line(const std::string& line, Sprite& out, std::string& error) { - constexpr std::string_view prefix = "sprite"; - if (line.rfind(prefix, 0) != 0) { - error = "line does not start with sprite"; - return false; - } - - size_t pos = prefix.size(); - while (pos < line.size() && std::isspace(static_cast(line[pos])) != 0) { - ++pos; - } - - std::string path; - if (pos < line.size() && line[pos] == '"') { - if (!parse_quoted(line, pos, path, error)) { - return false; - } - } else { - error = "sprite path must be quoted"; - return false; - } - - while (pos < line.size() && std::isspace(static_cast(line[pos])) != 0) { - ++pos; - } - - Sprite parsed; - parsed.path = path; - std::vector tokens; - std::istringstream tail(line.substr(pos)); - std::string token; - while (tail >> token) { - tokens.push_back(token); - } - - if (tokens.empty()) { - error = "sprite line is missing numeric fields"; - return false; - } - - if (tokens[0].find(',') != std::string::npos) { - if (tokens.size() != 2 && tokens.size() != 4) { - error = "sprite line must contain position/size and optional trim offsets"; - return false; - } - - if (!parse_pair(tokens[0], parsed.x, parsed.y) || - !parse_pair(tokens[1], parsed.w, parsed.h)) { - error = "invalid position or size pair"; - return false; - } - - if (tokens.size() == 4) { - if (!parse_pair(tokens[2], parsed.src_x, parsed.src_y) || - !parse_pair(tokens[3], parsed.trim_right, parsed.trim_bottom)) { - error = "invalid trim offset pair"; - return false; - } - } - } else { - if (tokens.size() != 4 && tokens.size() != 6) { - error = "legacy sprite line has invalid field count"; - return false; - } - if (!parse_int(tokens[0], parsed.x) || - !parse_int(tokens[1], parsed.y) || - !parse_int(tokens[2], parsed.w) || - !parse_int(tokens[3], parsed.h)) { - error = "legacy sprite line has invalid numeric fields"; - return false; - } - if (tokens.size() == 6) { - if (!parse_int(tokens[4], parsed.src_x) || - !parse_int(tokens[5], parsed.src_y)) { - error = "legacy sprite line has invalid crop offsets"; - return false; - } - } - } - - out = parsed; - return true; -} - -bool parse_atlas_line(const std::string& line, int& width, int& height) { - std::istringstream iss(line); - std::string tag; - std::string size_token; - std::string extra; - - if (!(iss >> tag >> size_token)) { - return false; - } - if (tag != "atlas") { - return false; - } - if (!parse_pair(size_token, width, height)) { - if (!parse_int(size_token, width) || !(iss >> height)) { - return false; - } - } - if (iss >> extra) { - return false; - } - - return true; -} - -bool parse_scale_line(const std::string& line, double& scale) { - std::istringstream iss(line); - std::string tag; - std::string value_token; - std::string extra; - - if (!(iss >> tag >> value_token)) { - return false; - } - if (tag != "scale") { - return false; - } - if (!parse_double(value_token, scale) || scale <= 0.0) { - return false; - } - if (iss >> extra) { - return false; - } - return true; -} - -bool parse_layout(std::istream& in, Layout& out, std::string& error) { - Layout parsed; - std::string line; - - while (std::getline(in, line)) { - if (line.empty()) { - continue; - } - - if (line.rfind("atlas", 0) == 0) { - if (!parse_atlas_line(line, parsed.atlas_width, parsed.atlas_height)) { - error = "Invalid atlas line: " + line; - return false; - } - } else if (line.rfind("scale", 0) == 0) { - if (parsed.has_scale) { - error = "Duplicate scale line"; - return false; - } - if (!parse_scale_line(line, parsed.scale)) { - error = "Invalid scale line: " + line; - return false; - } - parsed.has_scale = true; - } else if (line.rfind("sprite", 0) == 0) { - Sprite s; - std::string sprite_error; - if (!parse_sprite_line(line, s, sprite_error)) { - error = "Invalid sprite line: " + sprite_error; - return false; - } - parsed.sprites.push_back(s); - } else { - error = "Unknown line: " + line; - return false; - } - } - - if (parsed.atlas_width <= 0 || parsed.atlas_height <= 0) { - error = "Invalid atlas size"; - return false; - } - - if (!parsed.has_scale) { - parsed.scale = 1.0; - } - - out = std::move(parsed); - return true; -} - -bool read_text_file(const fs::path& path, std::string& out, std::string& error) { - std::ifstream in(path, std::ios::binary); - if (!in) { - error = "Failed to open file: " + path.string(); - return false; - } - std::ostringstream buffer; - buffer << in.rdbuf(); - if (!in.good() && !in.eof()) { - error = "Failed to read file: " + path.string(); - return false; - } - out = buffer.str(); - return true; -} - -std::string escape_json(const std::string& s) { - std::string out; - out.reserve(s.size() + 8); - for (char c : s) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - default: - if (static_cast(c) < 0x20) { - std::ostringstream hex; - hex << "\\u"; - const char* digits = "0123456789abcdef"; - unsigned char uc = static_cast(c); - hex << '0' << '0' << digits[(uc >> 4) & 0x0f] << digits[uc & 0x0f]; - out += hex.str(); - } else { - out.push_back(c); - } - break; - } - } - return out; -} - -std::string escape_xml(const std::string& s) { - std::string out; - out.reserve(s.size() + 8); - for (char c : s) { - switch (c) { - case '&': out += "&"; break; - case '<': out += "<"; break; - case '>': out += ">"; break; - case '"': out += """; break; - case '\'': out += "'"; break; - default: out.push_back(c); break; - } - } - return out; -} - -std::string escape_csv(const std::string& s) { - bool needs_quotes = false; - for (char c : s) { - if (c == '"' || c == ',' || c == '\n' || c == '\r') { - needs_quotes = true; - break; - } - } - if (!needs_quotes) { - return s; - } - - std::string out = "\""; - for (char c : s) { - if (c == '"') { - out += "\"\""; - } else { - out.push_back(c); - } - } - out.push_back('"'); - return out; -} - -std::string escape_css_string(const std::string& s) { - std::string out; - out.reserve(s.size() + 8); - for (char c : s) { - if (c == '\\' || c == '"') { - out.push_back('\\'); - } - if (c == '\n') { - out += "\\a "; - continue; - } - out.push_back(c); - } - return out; -} - -std::string replace_tokens(const std::string& input, const std::map& vars) { - std::string out; - out.reserve(input.size() + 64); - - size_t i = 0; - while (i < input.size()) { - size_t open = input.find("{{", i); - if (open == std::string::npos) { - out.append(input.substr(i)); - break; - } - - out.append(input.substr(i, open - i)); - size_t close = input.find("}}", open + 2); - if (close == std::string::npos) { - out.append(input.substr(open)); - break; - } - - std::string key = trim_copy(input.substr(open + 2, close - (open + 2))); - auto it = vars.find(key); - if (it != vars.end()) { - out.append(it->second); - } - i = close + 2; - } - - return out; -} - -size_t skip_json_ws(const std::string& s, size_t pos) { - while (pos < s.size() && std::isspace(static_cast(s[pos])) != 0) { - ++pos; - } - return pos; -} - -size_t scan_json_string(const std::string& s, size_t pos) { - if (pos >= s.size() || s[pos] != '"') { - return std::string::npos; - } - ++pos; - while (pos < s.size()) { - if (s[pos] == '\\') { - pos += 2; - continue; - } - if (s[pos] == '"') { - return pos + 1; - } - ++pos; - } - return std::string::npos; -} - -size_t find_matching_json_bracket(const std::string& s, size_t open_pos, char open_ch, char close_ch) { - if (open_pos >= s.size() || s[open_pos] != open_ch) { - return std::string::npos; - } - int depth = 0; - size_t pos = open_pos; - while (pos < s.size()) { - char c = s[pos]; - if (c == '"') { - size_t end = scan_json_string(s, pos); - if (end == std::string::npos) { - return std::string::npos; - } - pos = end; - continue; - } - if (c == open_ch) { - ++depth; - } else if (c == close_ch) { - --depth; - if (depth == 0) { - return pos; - } - } - ++pos; - } - return std::string::npos; -} - -bool parse_json_array_items(const std::string& text, std::vector& out_items) { - out_items.clear(); - size_t start = skip_json_ws(text, 0); - if (start >= text.size() || text[start] != '[') { - return false; - } - - size_t end = find_matching_json_bracket(text, start, '[', ']'); - if (end == std::string::npos) { - return false; - } - if (skip_json_ws(text, end + 1) != text.size()) { - return false; - } - - size_t item_start = start + 1; - int object_depth = 0; - int array_depth = 0; - size_t pos = item_start; - while (pos < end) { - char c = text[pos]; - if (c == '"') { - size_t str_end = scan_json_string(text, pos); - if (str_end == std::string::npos) { - return false; - } - pos = str_end; - continue; - } - if (c == '{') { - ++object_depth; - } else if (c == '}') { - if (object_depth <= 0) { - return false; - } - --object_depth; - } else if (c == '[') { - ++array_depth; - } else if (c == ']') { - if (array_depth <= 0) { - return false; - } - --array_depth; - } else if (c == ',' && object_depth == 0 && array_depth == 0) { - std::string item = trim_copy(text.substr(item_start, pos - item_start)); - if (!item.empty()) { - out_items.push_back(item); - } - item_start = pos + 1; - } - ++pos; - } - - std::string tail = trim_copy(text.substr(item_start, end - item_start)); - if (!tail.empty()) { - out_items.push_back(tail); - } - return true; -} - -bool extract_first_object_array(const std::string& text, std::string& out_array_text) { - size_t start = skip_json_ws(text, 0); - if (start >= text.size() || text[start] != '{') { - return false; - } - size_t end = find_matching_json_bracket(text, start, '{', '}'); - if (end == std::string::npos) { - return false; - } - if (skip_json_ws(text, end + 1) != text.size()) { - return false; - } - - int object_depth = 0; - int array_depth = 0; - size_t pos = start; - while (pos <= end) { - char c = text[pos]; - if (c == '"') { - size_t str_end = scan_json_string(text, pos); - if (str_end == std::string::npos) { - return false; - } - pos = str_end; - continue; - } - if (c == '{') { - ++object_depth; - } else if (c == '}') { - --object_depth; - } else if (c == '[') { - if (object_depth == 1 && array_depth == 0) { - size_t arr_end = find_matching_json_bracket(text, pos, '[', ']'); - if (arr_end == std::string::npos || arr_end > end) { - return false; - } - out_array_text = text.substr(pos, arr_end - pos + 1); - return true; - } - ++array_depth; - } else if (c == ']') { - --array_depth; - } - ++pos; - } - return false; -} - -bool parse_json_string_literal(const std::string& token, std::string& out) { - if (token.size() < 2 || token.front() != '"' || token.back() != '"') { - return false; - } - out.clear(); - out.reserve(token.size() - 2); - for (size_t i = 1; i + 1 < token.size(); ++i) { - char c = token[i]; - if (c != '\\') { - out.push_back(c); - continue; - } - if (i + 1 >= token.size() - 1) { - return false; - } - char e = token[++i]; - switch (e) { - case '"': out.push_back('"'); break; - case '\\': out.push_back('\\'); break; - case '/': out.push_back('/'); break; - case 'b': out.push_back('\b'); break; - case 'f': out.push_back('\f'); break; - case 'n': out.push_back('\n'); break; - case 'r': out.push_back('\r'); break; - case 't': out.push_back('\t'); break; - case 'u': - // Keep unicode escapes as-is for now. - out += "\\u"; - if (i + 4 >= token.size() - 1) { - return false; - } - out.push_back(token[++i]); - out.push_back(token[++i]); - out.push_back(token[++i]); - out.push_back(token[++i]); - break; - default: - out.push_back(e); - break; - } - } - return true; -} - -bool parse_json_object_entries(const std::string& text, std::vector>& entries) { - entries.clear(); - size_t start = skip_json_ws(text, 0); - if (start >= text.size() || text[start] != '{') { - return false; - } - size_t end = find_matching_json_bracket(text, start, '{', '}'); - if (end == std::string::npos) { - return false; - } - if (skip_json_ws(text, end + 1) != text.size()) { - return false; - } - - size_t pos = skip_json_ws(text, start + 1); - while (pos < end) { - if (text[pos] != '"') { - return false; - } - size_t key_end = scan_json_string(text, pos); - if (key_end == std::string::npos) { - return false; - } - - std::string key_token = text.substr(pos, key_end - pos); - std::string key; - if (!parse_json_string_literal(key_token, key)) { - return false; - } - pos = skip_json_ws(text, key_end); - if (pos >= end || text[pos] != ':') { - return false; - } - ++pos; - pos = skip_json_ws(text, pos); - if (pos >= end) { - return false; - } - - size_t value_start = pos; - if (text[pos] == '"') { - size_t value_end = scan_json_string(text, pos); - if (value_end == std::string::npos) { - return false; - } - pos = value_end; - } else if (text[pos] == '{') { - size_t value_end = find_matching_json_bracket(text, pos, '{', '}'); - if (value_end == std::string::npos) { - return false; - } - pos = value_end + 1; - } else if (text[pos] == '[') { - size_t value_end = find_matching_json_bracket(text, pos, '[', ']'); - if (value_end == std::string::npos) { - return false; - } - pos = value_end + 1; - } else { - while (pos < end && text[pos] != ',' && text[pos] != '}') { - ++pos; - } - } - std::string value = trim_copy(text.substr(value_start, pos - value_start)); - entries.push_back({key, value}); - - pos = skip_json_ws(text, pos); - if (pos < end && text[pos] == ',') { - ++pos; - pos = skip_json_ws(text, pos); - } - } - - return true; -} - -bool json_entry_value(const std::vector>& entries, - const std::string& key, - std::string& out_value) { - for (const auto& entry : entries) { - if (entry.first == key) { - out_value = entry.second; - return true; - } - } - return false; -} - -bool parse_json_int_literal(const std::string& token, int& out) { - return parse_int(trim_copy(token), out); -} - -std::string join_ints_csv(const std::vector& values, const std::string& sep) { - std::ostringstream oss; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) { - oss << sep; - } - oss << values[i]; - } - return oss.str(); -} - -std::string ints_to_json_array(const std::vector& values) { - std::ostringstream oss; - oss << "["; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) { - oss << ","; - } - oss << values[i]; - } - oss << "]"; - return oss.str(); -} - -std::string strings_to_json_array(const std::vector& values) { - std::ostringstream oss; - oss << "["; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) { - oss << ","; - } - oss << "\"" << escape_json(values[i]) << "\""; - } - oss << "]"; - return oss.str(); -} - -std::string markers_to_json_array(const std::vector& markers) { - std::ostringstream oss; - oss << "["; - for (size_t i = 0; i < markers.size(); ++i) { - if (i > 0) { - oss << ","; - } - const MarkerItem& marker = markers[i]; - oss << "{\"name\":\"" << escape_json(marker.name) << "\",\"type\":\"" << escape_json(marker.type) << "\""; - if (marker.type == "point") { - oss << ",\"x\":" << marker.x << ",\"y\":" << marker.y; - } else if (marker.type == "circle") { - oss << ",\"x\":" << marker.x << ",\"y\":" << marker.y << ",\"radius\":" << marker.radius; - } else if (marker.type == "rectangle") { - oss << ",\"x\":" << marker.x << ",\"y\":" << marker.y << ",\"w\":" << marker.w << ",\"h\":" << marker.h; - } else if (marker.type == "polygon") { - oss << ",\"vertices\":["; - for (size_t j = 0; j < marker.vertices.size(); ++j) { - if (j > 0) { - oss << ","; - } - oss << "{\"x\":" << marker.vertices[j].first << ",\"y\":" << marker.vertices[j].second << "}"; - } - oss << "]"; - } - oss << "}"; - } - oss << "]"; - return oss.str(); -} - -std::string marker_vertices_to_json_array(const std::vector>& vertices) { - std::ostringstream oss; - oss << "["; - for (size_t i = 0; i < vertices.size(); ++i) { - if (i > 0) { - oss << ","; - } - oss << "{\"x\":" << vertices[i].first << ",\"y\":" << vertices[i].second << "}"; - } - oss << "]"; - return oss.str(); -} - -std::string marker_vertices_to_string(const std::vector>& vertices) { - std::ostringstream oss; - for (size_t i = 0; i < vertices.size(); ++i) { - if (i > 0) { - oss << "|"; - } - oss << vertices[i].first << "," << vertices[i].second; - } - return oss.str(); -} - -std::string sprite_name_from_path(const std::string& path) { - fs::path p(path); - std::string name = p.stem().string(); - if (!name.empty()) { - return name; - } - name = p.filename().string(); - if (!name.empty()) { - return name; - } - return path; -} - -void collect_sprite_name_indexes(const Layout& layout, - std::unordered_map& by_path, - std::unordered_map& by_name, - std::vector& sprite_names) { - by_path.clear(); - by_name.clear(); - sprite_names.clear(); - sprite_names.reserve(layout.sprites.size()); - for (size_t i = 0; i < layout.sprites.size(); ++i) { - const Sprite& s = layout.sprites[i]; - const int idx = static_cast(i); - by_path[s.path] = idx; - fs::path p(s.path); - by_path[p.filename().string()] = idx; - std::string name = sprite_name_from_path(s.path); - sprite_names.push_back(name); - by_name[name] = idx; - } -} - -int resolve_sprite_index(const std::string& key, - const std::unordered_map& by_path, - const std::unordered_map& by_name) { - auto by_path_it = by_path.find(key); - if (by_path_it != by_path.end()) { - return by_path_it->second; - } - auto by_name_it = by_name.find(key); - if (by_name_it != by_name.end()) { - return by_name_it->second; - } - return -1; -} - -std::vector parse_markers_data(const std::string& markers_text, - const Layout& layout, - const std::unordered_map& by_path, - const std::unordered_map& by_name, - const std::vector& sprite_names, - std::vector>& sprite_markers) { - sprite_markers.assign(layout.sprites.size(), {}); - std::vector markers; - const std::string trimmed = trim_copy(markers_text); - if (trimmed.empty()) { - return markers; - } - - std::vector> root_entries; - if (!parse_json_object_entries(trimmed, root_entries)) { - return markers; - } - - std::string sprites_obj = ""; - std::string spritemarkers_obj; - if (json_entry_value(root_entries, "spritemarkers", spritemarkers_obj)) { - std::vector> sm_entries; - if (parse_json_object_entries(spritemarkers_obj, sm_entries)) { - json_entry_value(sm_entries, "sprites", sprites_obj); - } - } - if (sprites_obj.empty()) { - json_entry_value(root_entries, "sprites", sprites_obj); - } - if (sprites_obj.empty()) { - return markers; - } - - std::vector> sprite_entries; - if (!parse_json_object_entries(sprites_obj, sprite_entries)) { - return markers; - } - - for (const auto& sprite_entry : sprite_entries) { - const std::string& sprite_key = sprite_entry.first; - const std::string& sprite_value = sprite_entry.second; - int sprite_index = resolve_sprite_index(sprite_key, by_path, by_name); - - std::vector> sprite_obj_entries; - std::string markers_array; - if (parse_json_object_entries(sprite_value, sprite_obj_entries)) { - std::string explicit_name; - if (sprite_index < 0 && json_entry_value(sprite_obj_entries, "name", explicit_name)) { - std::string decoded_name; - if (parse_json_string_literal(explicit_name, decoded_name)) { - sprite_index = resolve_sprite_index(decoded_name, by_path, by_name); - } - } - json_entry_value(sprite_obj_entries, "markers", markers_array); - } - if (sprite_index < 0 || markers_array.empty()) { - continue; - } - - std::vector marker_values; - if (!parse_json_array_items(markers_array, marker_values)) { - continue; - } - for (const std::string& marker_value : marker_values) { - std::string marker_trimmed = trim_copy(marker_value); - if (marker_trimmed.empty()) { - continue; - } - std::vector> marker_obj_entries; - if (!parse_json_object_entries(marker_trimmed, marker_obj_entries)) { - continue; - } - - MarkerItem item; - item.index = markers.size(); - item.sprite_index = sprite_index; - item.sprite_name = sprite_names[static_cast(sprite_index)]; - item.sprite_path = layout.sprites[static_cast(sprite_index)].path; - - std::string marker_name_value; - if (!json_entry_value(marker_obj_entries, "name", marker_name_value) || - !parse_json_string_literal(marker_name_value, item.name) || - item.name.empty()) { - continue; - } - - std::string marker_type_value; - if (!json_entry_value(marker_obj_entries, "type", marker_type_value) || - !parse_json_string_literal(marker_type_value, item.type)) { - continue; - } - - if (item.type == "point") { - std::string x_token; - std::string y_token; - if (!json_entry_value(marker_obj_entries, "x", x_token) || - !json_entry_value(marker_obj_entries, "y", y_token) || - !parse_json_int_literal(x_token, item.x) || - !parse_json_int_literal(y_token, item.y)) { - continue; - } - } else if (item.type == "circle") { - std::string x_token; - std::string y_token; - std::string radius_token; - if (!json_entry_value(marker_obj_entries, "x", x_token) || - !json_entry_value(marker_obj_entries, "y", y_token) || - !json_entry_value(marker_obj_entries, "radius", radius_token) || - !parse_json_int_literal(x_token, item.x) || - !parse_json_int_literal(y_token, item.y) || - !parse_json_int_literal(radius_token, item.radius)) { - continue; - } - } else if (item.type == "rectangle") { - std::string x_token; - std::string y_token; - std::string w_token; - std::string h_token; - if (!json_entry_value(marker_obj_entries, "x", x_token) || - !json_entry_value(marker_obj_entries, "y", y_token) || - !json_entry_value(marker_obj_entries, "w", w_token) || - !json_entry_value(marker_obj_entries, "h", h_token) || - !parse_json_int_literal(x_token, item.x) || - !parse_json_int_literal(y_token, item.y) || - !parse_json_int_literal(w_token, item.w) || - !parse_json_int_literal(h_token, item.h)) { - continue; - } - } else if (item.type == "polygon") { - std::string vertices_array; - if (!json_entry_value(marker_obj_entries, "vertices", vertices_array)) { - json_entry_value(marker_obj_entries, "verticles", vertices_array); - } - if (vertices_array.empty()) { - continue; - } - std::vector vertex_values; - if (!parse_json_array_items(vertices_array, vertex_values) || vertex_values.empty()) { - continue; - } - for (const std::string& vertex_value : vertex_values) { - std::vector> vertex_entries; - if (!parse_json_object_entries(trim_copy(vertex_value), vertex_entries)) { - item.vertices.clear(); - break; - } - std::string x_token; - std::string y_token; - int vx = 0; - int vy = 0; - if (!json_entry_value(vertex_entries, "x", x_token) || - !json_entry_value(vertex_entries, "y", y_token) || - !parse_json_int_literal(x_token, vx) || - !parse_json_int_literal(y_token, vy)) { - item.vertices.clear(); - break; - } - item.vertices.push_back({vx, vy}); - } - if (item.vertices.empty()) { - continue; - } - } else { - continue; - } - - sprite_markers[static_cast(sprite_index)].push_back(item); - markers.push_back(std::move(item)); - } - } - - return markers; -} - -std::vector parse_animations_data(const std::string& animations_text, - const std::unordered_map& by_path, - const std::unordered_map& by_name, - int& animation_fps_out) { - std::vector animations; - animation_fps_out = -1; - const std::string trimmed = trim_copy(animations_text); - if (trimmed.empty()) { - return animations; - } - - std::string timelines_array = trimmed; - if (trimmed.front() == '{') { - std::vector> root_entries; - if (!parse_json_object_entries(trimmed, root_entries)) { - return animations; - } - std::string fps_token; - if (json_entry_value(root_entries, "fps", fps_token)) { - parse_json_int_literal(fps_token, animation_fps_out); - } - if (!json_entry_value(root_entries, "timelines", timelines_array)) { - if (!json_entry_value(root_entries, "animations", timelines_array)) { - return animations; - } - } - } - - std::vector timeline_values; - if (!parse_json_array_items(timelines_array, timeline_values)) { - return animations; - } - - for (const std::string& timeline : timeline_values) { - std::vector> timeline_entries; - if (!parse_json_object_entries(trim_copy(timeline), timeline_entries)) { - continue; - } - - std::string name_value; - std::string name = "animation_" + std::to_string(animations.size()); - if (json_entry_value(timeline_entries, "name", name_value)) { - std::string decoded_name; - if (parse_json_string_literal(name_value, decoded_name) && !decoded_name.empty()) { - name = decoded_name; - } - } - - int fps = animation_fps_out; - std::string fps_token; - if (json_entry_value(timeline_entries, "fps", fps_token)) { - parse_json_int_literal(fps_token, fps); - } - if (fps <= 0) { - fps = 8; - } - - std::vector indexes; - std::string indexes_array; - if (json_entry_value(timeline_entries, "sprite_indexes", indexes_array)) { - std::vector tokens; - if (parse_json_array_items(indexes_array, tokens)) { - for (const std::string& token : tokens) { - int idx = -1; - if (parse_json_int_literal(token, idx)) { - indexes.push_back(idx); - } - } - } - } else if (json_entry_value(timeline_entries, "frames", indexes_array)) { - std::vector frame_tokens; - if (parse_json_array_items(indexes_array, frame_tokens)) { - for (const std::string& frame_token : frame_tokens) { - std::string token = trim_copy(frame_token); - int idx = -1; - if (parse_json_int_literal(token, idx)) { - indexes.push_back(idx); - continue; - } - std::string path_or_name; - if (parse_json_string_literal(token, path_or_name)) { - idx = resolve_sprite_index(path_or_name, by_path, by_name); - if (idx >= 0) { - indexes.push_back(idx); - } - } - } - } - } - - AnimationItem item; - item.index = animations.size(); - item.name = name; - item.sprite_indexes = std::move(indexes); - item.fps = fps; - animations.push_back(std::move(item)); - } - - return animations; -} - -bool parse_transform_file(const fs::path& path, Transform& out, std::string& error) { - std::ifstream in(path); - if (!in) { - error = "Failed to open transform file: " + path.string(); - return false; - } - - Transform parsed; - std::vector section_stack; - std::string line; - std::string legacy_sprite_block; - std::string legacy_marker_block; - std::string legacy_animation_block; - bool saw_sprite_item = false; - bool saw_marker_item = false; - bool saw_animation_item = false; - - auto append_line = [](std::string& target, const std::string& value) { - if (!target.empty()) { - target.push_back('\n'); - } - target.append(value); - }; - - auto is_known_section = [](const std::string& s) { - return s == "meta" || - s == "header" || - s == "if_markers" || - s == "if_no_markers" || - s == "markers_header" || - s == "markers" || - s == "marker" || - s == "markers_separator" || - s == "markers_footer" || - s == "sprites" || - s == "sprite" || - s == "separator" || - s == "if_animations" || - s == "if_no_animations" || - s == "animations_header" || - s == "animations" || - s == "animation" || - s == "animations_separator" || - s == "animations_footer" || - s == "footer"; - }; - - while (std::getline(in, line)) { - if (line.empty() && section_stack.empty()) { - continue; - } - - std::string trimmed = trim_copy(line); - if (trimmed.empty() && section_stack.empty()) { - continue; - } - - if (!trimmed.empty() && trimmed.front() == '#') { - continue; - } - - if (trimmed.size() >= 3 && trimmed.front() == '[' && trimmed.back() == ']') { - std::string tag = trim_copy(trimmed.substr(1, trimmed.size() - 2)); - if (!tag.empty() && tag.front() == '/') { - std::string closing = trim_copy(tag.substr(1)); - if (section_stack.empty()) { - error = "Unexpected closing section [/" + closing + "]: " + path.string(); - return false; - } - if (closing != section_stack.back()) { - error = "Mismatched closing section [/" + closing + "] for [" + section_stack.back() + "]: " + path.string(); - return false; - } - section_stack.pop_back(); - continue; - } - - if (!is_known_section(tag)) { - error = "Unknown section [" + tag + "]: " + path.string(); - return false; - } - if (tag == "sprite") { - if (section_stack.empty() || section_stack.back() != "sprites") { - error = "Section [sprite] must be inside [sprites]: " + path.string(); - return false; - } - saw_sprite_item = true; - } else if (tag == "marker") { - if (section_stack.empty() || section_stack.back() != "markers") { - error = "Section [marker] must be inside [markers]: " + path.string(); - return false; - } - saw_marker_item = true; - } else if (tag == "animation") { - if (section_stack.empty() || section_stack.back() != "animations") { - error = "Section [animation] must be inside [animations]: " + path.string(); - return false; - } - saw_animation_item = true; - } - section_stack.push_back(tag); - continue; - } - - const std::string section = section_stack.empty() ? "" : section_stack.back(); - - if (section == "meta") { - size_t eq = line.find('='); - if (eq == std::string::npos) { - continue; - } - std::string key = trim_copy(line.substr(0, eq)); - std::string value = trim_copy(line.substr(eq + 1)); - if (key == "name") { - parsed.name = value; - } else if (key == "description") { - parsed.description = value; - } else if (key == "extension") { - parsed.extension = value; - } - continue; - } - - if (section == "header") { - append_line(parsed.header, line); - } else if (section == "if_markers") { - append_line(parsed.if_markers, line); - } else if (section == "if_no_markers") { - append_line(parsed.if_no_markers, line); - } else if (section == "markers_header") { - append_line(parsed.markers_header, line); - } else if (section == "markers") { - append_line(legacy_marker_block, line); - } else if (section == "marker") { - append_line(parsed.markers, line); - } else if (section == "markers_separator") { - append_line(parsed.markers_separator, line); - } else if (section == "markers_footer") { - append_line(parsed.markers_footer, line); - } else if (section == "sprites") { - append_line(legacy_sprite_block, line); - } else if (section == "sprite") { - append_line(parsed.sprite, line); - } else if (section == "separator") { - append_line(parsed.separator, line); - } else if (section == "if_animations") { - append_line(parsed.if_animations, line); - } else if (section == "if_no_animations") { - append_line(parsed.if_no_animations, line); - } else if (section == "animations_header") { - append_line(parsed.animations_header, line); - } else if (section == "animations") { - append_line(legacy_animation_block, line); - } else if (section == "animation") { - append_line(parsed.animations, line); - } else if (section == "animations_separator") { - append_line(parsed.animations_separator, line); - } else if (section == "animations_footer") { - append_line(parsed.animations_footer, line); - } else if (section == "footer") { - append_line(parsed.footer, line); - } - } - - if (!section_stack.empty()) { - error = "Unclosed section [" + section_stack.back() + "]: " + path.string(); - return false; - } - - if (!saw_sprite_item) { - parsed.sprite = legacy_sprite_block; - } - if (!saw_marker_item) { - parsed.markers = legacy_marker_block; - } - if (!saw_animation_item) { - parsed.animations = legacy_animation_block; - } - - if (parsed.name.empty()) { - parsed.name = path.stem().string(); - } - if (parsed.sprite.empty()) { - error = "Transform missing [sprite] section (or legacy [sprites] body): " + path.string(); - return false; - } - - out = std::move(parsed); - return true; -} - -std::string format_double(double value) { - std::ostringstream oss; - oss.setf(std::ios::fmtflags(0), std::ios::floatfield); - oss.precision(8); - oss << value; - return oss.str(); -} - -fs::path find_transforms_dir(const std::string& argv0) { - std::vector candidates; - candidates.push_back(fs::path("transforms")); -#ifdef SPRAT_SOURCE_DIR - candidates.push_back(fs::path(SPRAT_SOURCE_DIR) / "transforms"); -#endif - if (!argv0.empty()) { - fs::path exe_dir = fs::path(argv0).parent_path(); - if (!exe_dir.empty()) { - candidates.push_back(exe_dir / "transforms"); - candidates.push_back(exe_dir / ".." / "transforms"); - candidates.push_back(exe_dir / ".." / ".." / "transforms"); - } - } - - for (const auto& candidate : candidates) { - if (fs::exists(candidate) && fs::is_directory(candidate)) { - return candidate; - } - } - - return fs::path("transforms"); -} - -fs::path resolve_transform_path(const std::string& transform_arg, const std::string& argv0) { - fs::path candidate(transform_arg); - if (candidate.has_parent_path() || candidate.extension() == ".transform") { - return candidate; - } - return find_transforms_dir(argv0) / (transform_arg + ".transform"); -} - -bool load_transform_by_name(const std::string& transform_arg, const std::string& argv0, Transform& out, std::string& error) { - fs::path transform_path = resolve_transform_path(transform_arg, argv0); - return parse_transform_file(transform_path, out, error); -} - -void list_transforms(const std::string& argv0) { - const fs::path dir = find_transforms_dir(argv0); - if (!fs::exists(dir) || !fs::is_directory(dir)) { - return; - } - - std::vector paths; - for (const auto& entry : fs::directory_iterator(dir)) { - if (!entry.is_regular_file()) { - continue; - } - if (entry.path().extension() == ".transform") { - paths.push_back(entry.path()); - } - } - std::sort(paths.begin(), paths.end()); - - for (const auto& path : paths) { - Transform t; - std::string error; - if (!parse_transform_file(path, t, error)) { - std::cerr << "Warning: " << error << "\n"; - continue; - } - std::cout << t.name; - if (!t.description.empty()) { - std::cout << " - " << t.description; - } - std::cout << "\n"; - } -} - -void print_usage() { - std::cerr << "Usage: spratconvert [--transform NAME|PATH] [--markers FILE] [--animations FILE] [--list-transforms]\n"; -} - -int main(int argc, char** argv) { - std::string transform_arg = "json"; - std::string markers_path_arg; - std::string animations_path_arg; - bool list_only = false; - - for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; - if (arg == "--transform" && i + 1 < argc) { - transform_arg = argv[++i]; - } else if (arg == "--markers" && i + 1 < argc) { - markers_path_arg = argv[++i]; - } else if (arg == "--animations" && i + 1 < argc) { - animations_path_arg = argv[++i]; - } else if (arg == "--list-transforms") { - list_only = true; - } else if (arg == "--help" || arg == "-h") { - print_usage(); - return 0; - } else { - print_usage(); - return 1; - } - } - - if (list_only) { - list_transforms(argv[0] ? argv[0] : ""); - return 0; - } - - Transform transform; - std::string transform_error; - if (!load_transform_by_name(transform_arg, argv[0] ? argv[0] : "", transform, transform_error)) { - std::cerr << transform_error << "\n"; - return 1; - } - - std::string markers_text; - std::string animations_text; - if (!markers_path_arg.empty()) { - std::string file_error; - if (!read_text_file(fs::path(markers_path_arg), markers_text, file_error)) { - std::cerr << file_error << "\n"; - return 1; - } - } - if (!animations_path_arg.empty()) { - std::string file_error; - if (!read_text_file(fs::path(animations_path_arg), animations_text, file_error)) { - std::cerr << file_error << "\n"; - return 1; - } - } - Layout layout; - std::string layout_error; - if (!parse_layout(std::cin, layout, layout_error)) { - std::cerr << layout_error << "\n"; - return 1; - } - - std::unordered_map sprite_index_by_path; - std::unordered_map sprite_index_by_name; - std::vector sprite_names; - collect_sprite_name_indexes(layout, sprite_index_by_path, sprite_index_by_name, sprite_names); - - std::vector> sprite_markers; - const std::vector marker_items = - parse_markers_data(markers_text, layout, sprite_index_by_path, sprite_index_by_name, sprite_names, sprite_markers); - int animation_fps = -1; - const std::vector animation_items = - parse_animations_data(animations_text, sprite_index_by_path, sprite_index_by_name, animation_fps); - const int sprite_count_limit = static_cast(layout.sprites.size()); - std::vector normalized_animation_items = animation_items; - for (AnimationItem& item : normalized_animation_items) { - std::vector filtered; - filtered.reserve(item.sprite_indexes.size()); - for (int idx : item.sprite_indexes) { - if (idx >= 0 && idx < sprite_count_limit) { - filtered.push_back(idx); - } - } - item.sprite_indexes = std::move(filtered); - } - - std::map global_vars; - global_vars["atlas_width"] = std::to_string(layout.atlas_width); - global_vars["atlas_height"] = std::to_string(layout.atlas_height); - global_vars["scale"] = format_double(layout.scale); - global_vars["sprite_count"] = std::to_string(layout.sprites.size()); - global_vars["marker_count"] = std::to_string(marker_items.size()); - global_vars["animation_count"] = std::to_string(normalized_animation_items.size()); - global_vars["fps"] = std::to_string(animation_fps > 0 ? animation_fps : 8); - global_vars["animation_fps"] = global_vars["fps"]; - global_vars["markers_path"] = markers_path_arg; - global_vars["animations_path"] = animations_path_arg; - global_vars["has_markers"] = marker_items.empty() ? "false" : "true"; - global_vars["has_animations"] = normalized_animation_items.empty() ? "false" : "true"; - global_vars["markers_raw"] = markers_text; - global_vars["markers_json"] = escape_json(markers_text); - global_vars["markers_xml"] = escape_xml(markers_text); - global_vars["markers_csv"] = escape_csv(markers_text); - global_vars["markers_css"] = escape_css_string(markers_text); - global_vars["animations_raw"] = animations_text; - global_vars["animations_json"] = escape_json(animations_text); - global_vars["animations_xml"] = escape_xml(animations_text); - global_vars["animations_csv"] = escape_csv(animations_text); - global_vars["animations_css"] = escape_css_string(animations_text); - - if (!transform.header.empty()) { - std::cout << replace_tokens(transform.header, global_vars); - } - - if (!marker_items.empty()) { - if (!transform.if_markers.empty()) { - std::cout << replace_tokens(transform.if_markers, global_vars); - } - if (!transform.markers_header.empty()) { - std::cout << replace_tokens(transform.markers_header, global_vars); - } - if (!transform.markers.empty()) { - for (size_t i = 0; i < marker_items.size(); ++i) { - if (i > 0 && !transform.markers_separator.empty()) { - std::cout << replace_tokens(transform.markers_separator, global_vars); - } - const MarkerItem& marker = marker_items[i]; - std::map vars = global_vars; - vars["marker_index"] = std::to_string(i); - vars["marker_name"] = marker.name; - vars["marker_name_json"] = escape_json(marker.name); - vars["marker_name_xml"] = escape_xml(marker.name); - vars["marker_name_csv"] = escape_csv(marker.name); - vars["marker_name_css"] = escape_css_string(marker.name); - vars["marker_type"] = marker.type; - vars["marker_type_json"] = escape_json(marker.type); - vars["marker_type_xml"] = escape_xml(marker.type); - vars["marker_type_csv"] = escape_csv(marker.type); - vars["marker_type_css"] = escape_css_string(marker.type); - vars["marker_x"] = std::to_string(marker.x); - vars["marker_y"] = std::to_string(marker.y); - vars["marker_radius"] = std::to_string(marker.radius); - vars["marker_w"] = std::to_string(marker.w); - vars["marker_h"] = std::to_string(marker.h); - vars["marker_vertices"] = marker_vertices_to_string(marker.vertices); - vars["marker_vertices_json"] = marker_vertices_to_json_array(marker.vertices); - vars["marker_vertices_xml"] = escape_xml(vars["marker_vertices_json"]); - vars["marker_vertices_csv"] = escape_csv(vars["marker_vertices_json"]); - vars["marker_vertices_css"] = escape_css_string(vars["marker_vertices_json"]); - vars["marker_sprite_index"] = std::to_string(marker.sprite_index); - vars["marker_sprite_name"] = marker.sprite_name; - vars["marker_sprite_path"] = marker.sprite_path; - vars["marker_sprite_name_json"] = escape_json(marker.sprite_name); - vars["marker_sprite_name_xml"] = escape_xml(marker.sprite_name); - vars["marker_sprite_name_csv"] = escape_csv(marker.sprite_name); - vars["marker_sprite_name_css"] = escape_css_string(marker.sprite_name); - vars["marker_sprite_path_json"] = escape_json(marker.sprite_path); - vars["marker_sprite_path_xml"] = escape_xml(marker.sprite_path); - vars["marker_sprite_path_csv"] = escape_csv(marker.sprite_path); - vars["marker_sprite_path_css"] = escape_css_string(marker.sprite_path); - std::cout << replace_tokens(transform.markers, vars); - } - } - if (!transform.markers_footer.empty()) { - std::cout << replace_tokens(transform.markers_footer, global_vars); - } - } else if (!transform.if_no_markers.empty()) { - std::cout << replace_tokens(transform.if_no_markers, global_vars); - } - - for (size_t i = 0; i < layout.sprites.size(); ++i) { - if (i > 0 && !transform.separator.empty()) { - std::cout << replace_tokens(transform.separator, global_vars); - } - - const Sprite& s = layout.sprites[i]; - std::map vars = global_vars; - vars["index"] = std::to_string(i); - vars["path"] = s.path; - vars["name"] = sprite_names[i]; - vars["name_json"] = escape_json(sprite_names[i]); - vars["name_xml"] = escape_xml(sprite_names[i]); - vars["name_csv"] = escape_csv(sprite_names[i]); - vars["name_css"] = escape_css_string(sprite_names[i]); - vars["path_json"] = escape_json(s.path); - vars["path_xml"] = escape_xml(s.path); - vars["path_csv"] = escape_csv(s.path); - vars["path_css"] = escape_css_string(s.path); - vars["x"] = std::to_string(s.x); - vars["y"] = std::to_string(s.y); - vars["w"] = std::to_string(s.w); - vars["h"] = std::to_string(s.h); - vars["src_x"] = std::to_string(s.src_x); - vars["src_y"] = std::to_string(s.src_y); - vars["trim_left"] = std::to_string(s.src_x); - vars["trim_top"] = std::to_string(s.src_y); - vars["trim_right"] = std::to_string(s.trim_right); - vars["trim_bottom"] = std::to_string(s.trim_bottom); - const bool has_trim = (s.src_x != 0) || (s.src_y != 0) || (s.trim_right != 0) || (s.trim_bottom != 0); - vars["has_trim"] = has_trim ? "true" : "false"; - vars["sprite_markers_count"] = std::to_string(sprite_markers[i].size()); - vars["sprite_markers_json"] = markers_to_json_array(sprite_markers[i]); - vars["sprite_markers_xml"] = escape_xml(vars["sprite_markers_json"]); - vars["sprite_markers_csv"] = escape_csv(vars["sprite_markers_json"]); - vars["sprite_markers_css"] = escape_css_string(vars["sprite_markers_json"]); - - std::cout << replace_tokens(transform.sprite, vars); - } - - if (!normalized_animation_items.empty()) { - if (!transform.if_animations.empty()) { - std::cout << replace_tokens(transform.if_animations, global_vars); - } - if (!transform.animations_header.empty()) { - std::cout << replace_tokens(transform.animations_header, global_vars); - } - if (!transform.animations.empty()) { - for (size_t i = 0; i < normalized_animation_items.size(); ++i) { - if (i > 0 && !transform.animations_separator.empty()) { - std::cout << replace_tokens(transform.animations_separator, global_vars); - } - const AnimationItem& animation = normalized_animation_items[i]; - std::map vars = global_vars; - vars["animation_index"] = std::to_string(i); - vars["animation_name"] = animation.name; - vars["animation_name_json"] = escape_json(animation.name); - vars["animation_name_xml"] = escape_xml(animation.name); - vars["animation_name_csv"] = escape_csv(animation.name); - vars["animation_name_css"] = escape_css_string(animation.name); - vars["animation_sprite_count"] = std::to_string(animation.sprite_indexes.size()); - vars["animation_sprite_indexes_json"] = ints_to_json_array(animation.sprite_indexes); - vars["animation_sprite_indexes_csv"] = join_ints_csv(animation.sprite_indexes, "|"); - vars["animation_sprite_indexes"] = join_ints_csv(animation.sprite_indexes, ","); - vars["animation_sprite_indexes_xml"] = escape_xml(vars["animation_sprite_indexes_json"]); - vars["animation_sprite_indexes_css"] = escape_css_string(vars["animation_sprite_indexes_json"]); - vars["fps"] = std::to_string(animation.fps); - vars["animation_fps"] = vars["fps"]; - std::cout << replace_tokens(transform.animations, vars); - } - } - if (!transform.animations_footer.empty()) { - std::cout << replace_tokens(transform.animations_footer, global_vars); - } - } else if (!transform.if_no_animations.empty()) { - std::cout << replace_tokens(transform.if_no_animations, global_vars); - } - - if (!transform.footer.empty()) { - std::cout << replace_tokens(transform.footer, global_vars); - } - - return 0; -} diff --git a/spratlayout.cpp b/spratlayout.cpp deleted file mode 100644 index d592ccb..0000000 --- a/spratlayout.cpp +++ /dev/null @@ -1,3177 +0,0 @@ -// spratlayout.cpp -// MIT License (c) 2026 Pedro -// Compile: g++ -std=c++17 -O2 spratlayout.cpp -o spratlayout - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define STB_IMAGE_IMPLEMENTATION -#include "stb_image.h" - -namespace fs = std::filesystem; -constexpr int k_output_cache_format_version = 2; -constexpr int k_seed_cache_format_version = 2; -#ifndef SPRAT_GLOBAL_PROFILE_CONFIG -#define SPRAT_GLOBAL_PROFILE_CONFIG "/usr/local/share/sprat/spratprofiles.cfg" -#endif - -std::string trim_copy(const std::string& s) { - size_t start = 0; - while (start < s.size() && std::isspace(static_cast(s[start]))) { - ++start; - } - size_t end = s.size(); - while (end > start && std::isspace(static_cast(s[end - 1]))) { - --end; - } - return s.substr(start, end - start); -} - -enum class Mode { POT, COMPACT, FAST }; -enum class OptimizeTarget { GPU, SPACE }; -enum class ResolutionReference { Largest, Smallest }; - -struct ProfileDefinition { - std::string name; - Mode mode = Mode::COMPACT; - OptimizeTarget optimize_target = OptimizeTarget::GPU; - std::optional max_width; - std::optional max_height; - std::optional padding; - std::optional max_combinations; - std::optional scale; - std::optional trim_transparent; - std::optional threads; - std::optional> source_resolution; - std::optional> target_resolution; - std::optional resolution_reference; -}; - -constexpr const char* k_profiles_config_filename = "spratprofiles.cfg"; -constexpr const char* k_user_profiles_config_relpath = ".config/sprat/spratprofiles.cfg"; -constexpr const char* k_global_profiles_config_path = SPRAT_GLOBAL_PROFILE_CONFIG; -constexpr const char k_default_profile_name[] = "fast"; -constexpr Mode k_default_mode = Mode::FAST; -constexpr OptimizeTarget k_default_optimize_target = OptimizeTarget::GPU; -constexpr int k_default_padding = 0; -constexpr int k_default_max_combinations = 0; -constexpr double k_default_scale = 1.0; -constexpr bool k_default_trim_transparent = false; -constexpr unsigned int k_default_threads = 0; -constexpr std::array k_compact_prewarm_profiles = { - "desktop", - "mobile", - "space", -}; - -std::string to_lower_copy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return value; -} - -bool parse_mode_from_string(const std::string& value, Mode& out, std::string& error) { - std::string lower = to_lower_copy(value); - if (lower == "compact") { - out = Mode::COMPACT; - return true; - } - if (lower == "pot") { - out = Mode::POT; - return true; - } - if (lower == "fast") { - out = Mode::FAST; - return true; - } - error = "invalid mode '" + value + "'"; - return false; -} - -bool parse_optimize_target_from_string(const std::string& value, OptimizeTarget& out, std::string& error) { - std::string lower = to_lower_copy(value); - if (lower == "gpu") { - out = OptimizeTarget::GPU; - return true; - } - if (lower == "space") { - out = OptimizeTarget::SPACE; - return true; - } - error = "invalid optimize target '" + value + "'"; - return false; -} - -bool parse_resolution_reference_from_string(const std::string& value, - ResolutionReference& out, - std::string& error) { - std::string lower = to_lower_copy(value); - if (lower == "largest") { - out = ResolutionReference::Largest; - return true; - } - if (lower == "smallest") { - out = ResolutionReference::Smallest; - return true; - } - error = "invalid resolution reference '" + value + "'"; - return false; -} - -bool parse_positive_int(const std::string& value, int& out) { - try { - size_t idx = 0; - long long parsed = std::stoll(value, &idx); - if (idx != value.size() || parsed <= 0 || - parsed > static_cast(std::numeric_limits::max())) { - return false; - } - out = static_cast(parsed); - return true; - } catch (const std::exception&) { - return false; - } -} - -bool parse_non_negative_int(const std::string& value, int& out) { - try { - size_t idx = 0; - long long parsed = std::stoll(value, &idx); - if (idx != value.size() || parsed < 0 || - parsed > static_cast(std::numeric_limits::max())) { - return false; - } - out = static_cast(parsed); - return true; - } catch (const std::exception&) { - return false; - } -} - -bool parse_positive_uint(const std::string& value, unsigned int& out) { - int parsed = 0; - if (!parse_positive_int(value, parsed)) { - return false; - } - out = static_cast(parsed); - return true; -} - -bool parse_scale_factor(const std::string& value, double& out) { - try { - size_t idx = 0; - double parsed = std::stod(value, &idx); - if (idx != value.size() || !std::isfinite(parsed) || parsed <= 0.0 || parsed > 1.0) { - return false; - } - out = parsed; - return true; - } catch (const std::exception&) { - return false; - } -} - -bool parse_resolution(const std::string& value, int& out_width, int& out_height) { - if (value.empty()) { - return false; - } - const size_t sep = value.find('x'); - if (sep == std::string::npos || sep == 0 || sep + 1 >= value.size()) { - return false; - } - if (value.find('x', sep + 1) != std::string::npos) { - return false; - } - - const std::string width_str = value.substr(0, sep); - const std::string height_str = value.substr(sep + 1); - if (!parse_positive_int(width_str, out_width) || !parse_positive_int(height_str, out_height)) { - return false; - } - return true; -} - -bool parse_bool_value(const std::string& value, bool& out) { - std::string lower = to_lower_copy(value); - if (lower == "1" || lower == "true" || lower == "yes" || lower == "on") { - out = true; - return true; - } - if (lower == "0" || lower == "false" || lower == "no" || lower == "off") { - out = false; - return true; - } - return false; -} - -bool parse_profiles_config(std::istream& input, - std::vector& out, - std::string& error) { - out.clear(); - std::unordered_set seen_names; - std::optional current; - std::string line; - size_t line_number = 0; - while (std::getline(input, line)) { - ++line_number; - std::string trimmed = trim_copy(line); - if (trimmed.empty() || trimmed.front() == '#' || trimmed.front() == ';') { - continue; - } - - if (trimmed.front() == '[' && trimmed.back() == ']') { - if (current) { - out.push_back(*current); - current.reset(); - } - std::string header = trimmed.substr(1, trimmed.size() - 2); - std::istringstream iss(header); - std::string section_type; - if (!(iss >> section_type)) { - error = "empty section header at line " + std::to_string(line_number); - return false; - } - section_type = to_lower_copy(section_type); - if (section_type != "profile") { - error = "unsupported section '" + section_type + "' at line " + std::to_string(line_number); - return false; - } - std::string name; - if (!(iss >> name)) { - error = "missing profile name at line " + std::to_string(line_number); - return false; - } - std::string extra; - if (iss >> extra) { - error = "unexpected token '" + extra + "' in profile header at line " + - std::to_string(line_number); - return false; - } - if (seen_names.find(name) != seen_names.end()) { - error = "duplicate profile '" + name + "' at line " + std::to_string(line_number); - return false; - } - seen_names.insert(name); - ProfileDefinition def; - def.name = name; - def.mode = Mode::COMPACT; - def.optimize_target = OptimizeTarget::GPU; - current = def; - continue; - } - - if (!current) { - error = "entry outside of profile section at line " + std::to_string(line_number); - return false; - } - - size_t equals = trimmed.find('='); - if (equals == std::string::npos) { - error = "invalid line '" + trimmed + "' at line " + std::to_string(line_number); - return false; - } - std::string key = trim_copy(trimmed.substr(0, equals)); - std::string value = trim_copy(trimmed.substr(equals + 1)); - if (key.empty()) { - error = "empty key at line " + std::to_string(line_number); - return false; - } - if (value.empty()) { - error = "empty value for key '" + key + "' at line " + std::to_string(line_number); - return false; - } - - std::string lower_key = to_lower_copy(key); - if (lower_key == "mode") { - Mode parsed_mode; - if (!parse_mode_from_string(value, parsed_mode, error)) { - error += " at line " + std::to_string(line_number); - return false; - } - current->mode = parsed_mode; - } else if (lower_key == "optimize") { - OptimizeTarget parsed_target; - if (!parse_optimize_target_from_string(value, parsed_target, error)) { - error += " at line " + std::to_string(line_number); - return false; - } - current->optimize_target = parsed_target; - } else if (lower_key == "max_width" || lower_key == "default_max_width") { - int parsed_width = 0; - if (!parse_positive_int(value, parsed_width)) { - error = "invalid max_width '" + value + "' at line " + - std::to_string(line_number); - return false; - } - current->max_width = parsed_width; - } else if (lower_key == "max_height" || lower_key == "default_max_height") { - int parsed_height = 0; - if (!parse_positive_int(value, parsed_height)) { - error = "invalid max_height '" + value + "' at line " + - std::to_string(line_number); - return false; - } - current->max_height = parsed_height; - } else if (lower_key == "padding") { - int parsed_padding = 0; - if (!parse_non_negative_int(value, parsed_padding)) { - error = "invalid padding '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->padding = parsed_padding; - } else if (lower_key == "max_combinations") { - int parsed_max_combinations = 0; - if (!parse_non_negative_int(value, parsed_max_combinations)) { - error = "invalid max_combinations '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->max_combinations = parsed_max_combinations; - } else if (lower_key == "scale") { - double parsed_scale = 0.0; - if (!parse_scale_factor(value, parsed_scale)) { - error = "invalid scale '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->scale = parsed_scale; - } else if (lower_key == "trim_transparent") { - bool parsed_trim = false; - if (!parse_bool_value(value, parsed_trim)) { - error = "invalid trim_transparent '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->trim_transparent = parsed_trim; - } else if (lower_key == "threads") { - unsigned int parsed_threads = 0; - if (!parse_positive_uint(value, parsed_threads)) { - error = "invalid threads '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->threads = parsed_threads; - } else if (lower_key == "source_resolution") { - int w = 0, h = 0; - if (!parse_resolution(value, w, h)) { - error = "invalid source_resolution '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->source_resolution = std::make_pair(w, h); - } else if (lower_key == "target_resolution") { - if (to_lower_copy(value) == "source") { - current->target_resolution = std::make_pair(-1, -1); - } else { - int w = 0, h = 0; - if (!parse_resolution(value, w, h)) { - error = "invalid target_resolution '" + value + "' at line " + std::to_string(line_number); - return false; - } - current->target_resolution = std::make_pair(w, h); - } - } else if (lower_key == "resolution_reference") { - ResolutionReference ref; - if (!parse_resolution_reference_from_string(value, ref, error)) { - error += " at line " + std::to_string(line_number); - return false; - } - current->resolution_reference = ref; - } else { - error = "unknown key '" + key + "' at line " + std::to_string(line_number); - return false; - } - } - - if (current) { - out.push_back(*current); - } - - if (out.empty()) { - error = "no profiles defined"; - return false; - } - return true; -} - -bool load_profiles_config_from_file(const fs::path& path, - std::vector& out, - std::string& error) { - std::ifstream input(path); - if (!input) { - error = "failed to open '" + path.string() + "'"; - return false; - } - return parse_profiles_config(input, out, error); -} - -std::optional resolve_user_profiles_config_path() { - const char* home = std::getenv("HOME"); - if (home == nullptr || home[0] == '\0') { - return std::nullopt; - } - return fs::path(home) / k_user_profiles_config_relpath; -} - -struct Sprite { - std::string path; - int w, h; - int x = 0, y = 0; - int trim_left = 0, trim_top = 0; - int trim_right = 0, trim_bottom = 0; -}; - -struct ImageMeta { - uintmax_t file_size = 0; - long long mtime_ticks = 0; -}; - -struct ImageSource { - fs::path file_path; - std::string path; - ImageMeta meta; -}; - -struct ImageCacheEntry { - bool trim_transparent = false; - uintmax_t file_size = 0; - long long mtime_ticks = 0; - int w = 0; - int h = 0; - int trim_left = 0; - int trim_top = 0; - int trim_right = 0; - int trim_bottom = 0; - long long cached_at_unix = 0; -}; - -struct LayoutCandidate { - bool valid = false; - size_t area = 0; - int w = 0; - int h = 0; - std::vector sprites; -}; - -struct LayoutSeedEntry { - std::string path; - int x = 0; - int y = 0; - int w = 0; - int h = 0; - int trim_left = 0; - int trim_top = 0; - int trim_right = 0; - int trim_bottom = 0; -}; - -struct LayoutSeedCache { - std::string signature; - int padding = 0; - int atlas_width = 0; - int atlas_height = 0; - std::vector entries; -}; - -struct Node { - int x, y, w, h; - bool used = false; - std::unique_ptr right; - std::unique_ptr down; - - Node(int x_, int y_, int w_, int h_) : x(x_), y(y_), w(w_), h(h_) {} -}; - -bool checked_add_int(int a, int b, int& out) { - if (b > 0 && a > std::numeric_limits::max() - b) { - return false; - } - if (b < 0 && a < std::numeric_limits::min() - b) { - return false; - } - out = a + b; - return true; -} - -bool checked_mul_size_t(size_t a, size_t b, size_t& out) { - if (a == 0 || b <= std::numeric_limits::max() / a) { - out = a * b; - return true; - } - return false; -} - -inline bool pixel_is_opaque(const unsigned char* rgba, int width, int x, int y) { - if (rgba == nullptr || width <= 0 || x < 0 || y < 0 || x >= width) { - return false; - } - const size_t pixel_index = static_cast(y) * static_cast(width) + static_cast(x); - const size_t alpha_index = pixel_index * static_cast(4) + static_cast(3); - return rgba[alpha_index] != 0; -} - -bool compute_trim_bounds(const unsigned char* rgba, - int w, - int h, - int& min_x, - int& min_y, - int& max_x, - int& max_y) { - min_x = 0; - min_y = 0; - max_x = -1; - max_y = -1; - if (w <= 0 || h <= 0 || rgba == nullptr) { - return false; - } - - // Validate that the image dimensions are reasonable - if (w > 100000 || h > 100000) { - return false; - } - - int top_hit_x = -1; - for (int y = 0; y < h; ++y) { - for (int x = 0; x < w; ++x) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; - } - min_y = y; - top_hit_x = x; - break; - } - if (top_hit_x >= 0) { - break; - } - } - if (top_hit_x < 0) { - return false; - } - - int bottom_hit_x = -1; - for (int y = h - 1; y >= min_y; --y) { - for (int x = w - 1; x >= 0; --x) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; - } - max_y = y; - bottom_hit_x = x; - break; - } - if (bottom_hit_x >= 0) { - break; - } - } - - const int left_search_end = std::min(top_hit_x, bottom_hit_x); - min_x = left_search_end; - for (int x = 0; x <= left_search_end; ++x) { - bool found = false; - for (int y = min_y; y <= max_y; ++y) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; - } - min_x = x; - found = true; - break; - } - if (found) { - break; - } - } - - const int right_search_start = std::max(top_hit_x, bottom_hit_x); - max_x = right_search_start; - for (int x = w - 1; x >= right_search_start; --x) { - bool found = false; - for (int y = min_y; y <= max_y; ++y) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; - } - max_x = x; - found = true; - break; - } - if (found) { - break; - } - } - - return max_x >= min_x && max_y >= min_y; -} - -bool read_image_meta(const fs::path& path, ImageMeta& out) { - std::error_code ec; - uintmax_t size = fs::file_size(path, ec); - if (ec) { - return false; - } - if (size > 1000000000) { // 1GB limit - return false; - } - fs::file_time_type mtime = fs::last_write_time(path, ec); - if (ec) { - return false; - } - out.file_size = size; - out.mtime_ticks = mtime.time_since_epoch().count(); - return true; -} - -long long now_unix_seconds() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -bool is_supported_image_extension(const fs::path& path) { - std::string ext = path.extension().string(); - if (ext.empty() || ext.size() > 10) { - return false; - } - std::transform(ext.begin(), ext.end(), ext.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp" || - ext == ".tga" || ext == ".gif" || ext == ".psd" || ext == ".pic" || - ext == ".pnm" || ext == ".pgm" || ext == ".ppm" || ext == ".hdr" || - ext == ".webp"; -} - -void prune_stale_cache_entries(std::unordered_map& entries, - long long now_unix, - long long max_age_seconds) { - if (max_age_seconds < 0 || max_age_seconds > 31536000) { // 1 year limit - max_age_seconds = 86400; // default to 1 day - } - for (auto it = entries.begin(); it != entries.end();) { - const long long cached_at = it->second.cached_at_unix; - if (cached_at <= 0 || cached_at > now_unix || (now_unix - cached_at) > max_age_seconds) { - it = entries.erase(it); - } else { - ++it; - } - } -} - -bool load_image_cache(const fs::path& cache_path, - std::unordered_map& out) { - out.clear(); - std::ifstream in(cache_path); - if (!in) { - return false; - } - - std::string header_tag; - int version = 0; - if (!(in >> header_tag >> version)) { - return false; - } - if (header_tag != "spratlayout_cache" || (version != 1 && version != 2)) { - return false; - } - - std::string path; - int trim_flag = 0; - ImageCacheEntry entry; - while (true) { - entry = ImageCacheEntry{}; - if (!(in >> std::quoted(path) - >> trim_flag - >> entry.file_size - >> entry.mtime_ticks - >> entry.w - >> entry.h - >> entry.trim_left - >> entry.trim_top - >> entry.trim_right - >> entry.trim_bottom)) { - break; - } - if (version == 2) { - if (!(in >> entry.cached_at_unix)) { - break; - } - } - if (entry.w <= 0 || entry.h <= 0 || entry.w > 100000 || entry.h > 100000) { - continue; - } - entry.trim_transparent = trim_flag != 0; - const std::string key = path + (entry.trim_transparent ? "|1" : "|0"); - out[key] = entry; - } - - return true; -} - -bool save_image_cache(const fs::path& cache_path, - const std::unordered_map& entries) { - if (entries.size() > 10000) { // Limit cache size - return false; - } - - fs::path tmp = cache_path; - tmp += ".tmp"; - - std::ofstream out(tmp, std::ios::trunc); - if (!out) { - return false; - } - - out << "spratlayout_cache 2\n"; - for (const auto& kv : entries) { - std::string path = kv.first; - if (path.size() > 2 && - path[path.size() - 2] == '|' && - (path.back() == '0' || path.back() == '1')) { - path = path.substr(0, path.size() - 2); - } - const ImageCacheEntry& e = kv.second; - if (e.w <= 0 || e.h <= 0 || e.w > 100000 || e.h > 100000) { - continue; - } - out << std::quoted(path) << " " - << (e.trim_transparent ? 1 : 0) << " " - << e.file_size << " " - << e.mtime_ticks << " " - << e.w << " " - << e.h << " " - << e.trim_left << " " - << e.trim_top << " " - << e.trim_right << " " - << e.trim_bottom << " " - << e.cached_at_unix << "\n"; - } - out.close(); - if (!out) { - return false; - } - - std::error_code ec; - fs::rename(tmp, cache_path, ec); - if (ec) { - fs::remove(cache_path, ec); - ec.clear(); - fs::rename(tmp, cache_path, ec); - if (ec) { - fs::remove(tmp, ec); - return false; - } - } - return true; -} - -fs::path default_temp_dir() { - std::error_code ec; - fs::path path = fs::temp_directory_path(ec); - if (!ec && !path.empty()) { - return path; - } - - const char* tmp = std::getenv("TMP"); - if (tmp != nullptr && *tmp != '\0') { - return fs::path(tmp); - } - const char* temp = std::getenv("TEMP"); - if (temp != nullptr && *temp != '\0') { - return fs::path(temp); - } - const char* tmpdir = std::getenv("TMPDIR"); - if (tmpdir != nullptr && *tmpdir != '\0') { - return fs::path(tmpdir); - } - - return fs::path("/tmp"); -} - -fs::path cache_root_dir() { - fs::path root = default_temp_dir() / "sprat"; - std::error_code ec; - fs::create_directories(root, ec); - if (!ec) { - return root; - } - return default_temp_dir(); -} - -fs::path build_cache_path(const fs::path& folder) { - std::error_code ec; - fs::path normalized = fs::absolute(folder, ec); - std::string folder_key = (!ec ? normalized.lexically_normal().string() : folder.string()); - size_t hash = std::hash{}(folder_key); - - std::ostringstream name; - name << "spratlayout_" << std::hex << hash << ".cache"; - return cache_root_dir() / name.str(); -} - -fs::path build_output_cache_path(const fs::path& base_cache_path, - const std::string& layout_signature) { - return base_cache_path.string() + ".layout." + layout_signature; -} - -fs::path build_seed_cache_path(const fs::path& base_cache_path, - const std::string& seed_signature) { - return base_cache_path.string() + ".seed." + seed_signature; -} - -std::string to_hex_size_t(size_t value) { - std::ostringstream oss; - oss << std::hex << value; - return oss.str(); -} - -bool is_file_older_than_seconds(const fs::path& path, long long max_age_seconds) { - if (max_age_seconds < 0 || max_age_seconds > 31536000) { // 1 year limit - return true; - } - std::error_code ec; - if (!fs::exists(path, ec) || ec) { - return true; - } - fs::file_time_type file_time = fs::last_write_time(path, ec); - if (ec) { - return true; - } - fs::file_time_type now = fs::file_time_type::clock::now(); - if (file_time > now) { - return false; - } - long long age = std::chrono::duration_cast(now - file_time).count(); - return age > max_age_seconds; -} - -std::string build_layout_signature(const std::string& profile_name, - Mode mode, - OptimizeTarget optimize_target, - int max_width_limit, - int max_height_limit, - int padding, - int max_combinations, - double scale, - bool trim_transparent, - bool preserve_source_order, - const std::vector& sources) { - std::vector parts; - parts.reserve(sources.size()); - for (const auto& source : sources) { - std::ostringstream line; - line << source.path << "|" << source.meta.file_size << "|" << source.meta.mtime_ticks; - parts.push_back(line.str()); - } - if (!preserve_source_order) { - std::sort(parts.begin(), parts.end()); - } - - std::ostringstream sig; - sig << profile_name << "|" - << static_cast(mode) << "|" - << static_cast(optimize_target) << "|" - << max_width_limit << "|" - << max_height_limit << "|" - << padding << "|" - << max_combinations << "|" - << std::setprecision(17) << scale << "|" - << (trim_transparent ? 1 : 0) << "|" - << (preserve_source_order ? 1 : 0); - for (const std::string& part : parts) { - sig << "\n" << part; - } - return to_hex_size_t(std::hash{}(sig.str())); -} - -std::string build_layout_seed_signature(const std::string& profile_name, - Mode mode, - OptimizeTarget optimize_target, - int max_width_limit, - int max_height_limit, - int max_combinations, - double scale, - bool trim_transparent, - bool preserve_source_order, - const std::vector& sources) { - std::vector parts; - parts.reserve(sources.size()); - for (const auto& source : sources) { - std::ostringstream line; - line << source.path << "|" << source.meta.file_size << "|" << source.meta.mtime_ticks; - parts.push_back(line.str()); - } - if (!preserve_source_order) { - std::sort(parts.begin(), parts.end()); - } - - std::ostringstream sig; - sig << profile_name << "|" - << static_cast(mode) << "|" - << static_cast(optimize_target) << "|" - << max_width_limit << "|" - << max_height_limit << "|" - << max_combinations << "|" - << std::setprecision(17) << scale << "|" - << (trim_transparent ? 1 : 0) << "|" - << (preserve_source_order ? 1 : 0); - for (const std::string& part : parts) { - sig << "\n" << part; - } - return to_hex_size_t(std::hash{}(sig.str())); -} - -bool load_output_cache(const fs::path& cache_path, - const std::string& expected_signature, - std::string& output) { - std::ifstream in(cache_path, std::ios::binary); - if (!in) { - return false; - } - - std::string expected_header = "spratlayout_output_cache " + std::to_string(k_output_cache_format_version); - std::string header; - if (!std::getline(in, header) || header != expected_header) { - return false; - } - - std::string signature; - if (!std::getline(in, signature) || signature != expected_signature) { - return false; - } - - std::ostringstream buffer; - buffer << in.rdbuf(); - if (!in.good() && !in.eof()) { - return false; - } - output = buffer.str(); - return true; -} - -bool save_output_cache(const fs::path& cache_path, - const std::string& signature, - const std::string& output) { - fs::path tmp = cache_path; - tmp += ".tmp"; - - std::ofstream out(tmp, std::ios::binary | std::ios::trunc); - if (!out) { - return false; - } - out << "spratlayout_output_cache " << k_output_cache_format_version << "\n"; - out << signature << "\n"; - out << output; - out.close(); - if (!out) { - return false; - } - - std::error_code ec; - fs::rename(tmp, cache_path, ec); - if (ec) { - fs::remove(cache_path, ec); - ec.clear(); - fs::rename(tmp, cache_path, ec); - if (ec) { - fs::remove(tmp, ec); - return false; - } - } - return true; -} - -bool load_layout_seed_cache(const fs::path& cache_path, - const std::string& expected_signature, - LayoutSeedCache& out) { - out = LayoutSeedCache{}; - std::ifstream in(cache_path); - if (!in) { - return false; - } - - std::string header_tag; - int version = 0; - if (!(in >> header_tag >> version)) { - return false; - } - if (header_tag != "spratlayout_seed_cache" || version != k_seed_cache_format_version) { - return false; - } - - if (!(in >> out.signature) || out.signature != expected_signature) { - return false; - } - - size_t count = 0; - if (!(in >> out.padding >> out.atlas_width >> out.atlas_height >> count)) { - return false; - } - if (count == 0 || out.atlas_width <= 0 || out.atlas_height <= 0) { - return false; - } - - out.entries.reserve(count); - for (size_t i = 0; i < count; ++i) { - LayoutSeedEntry entry; - if (!(in >> std::quoted(entry.path) - >> entry.x >> entry.y - >> entry.w >> entry.h - >> entry.trim_left >> entry.trim_top - >> entry.trim_right >> entry.trim_bottom)) { - return false; - } - out.entries.push_back(std::move(entry)); - } - - return true; -} - -bool save_layout_seed_cache(const fs::path& cache_path, - const LayoutSeedCache& seed) { - if (seed.signature.empty() || seed.entries.empty() || - seed.atlas_width <= 0 || seed.atlas_height <= 0) { - return false; - } - - fs::path tmp = cache_path; - tmp += ".tmp"; - - std::ofstream out(tmp, std::ios::trunc); - if (!out) { - return false; - } - - out << "spratlayout_seed_cache " << k_seed_cache_format_version << "\n"; - out << seed.signature << "\n"; - out << seed.padding << " " - << seed.atlas_width << " " - << seed.atlas_height << " " - << seed.entries.size() << "\n"; - for (const auto& entry : seed.entries) { - out << std::quoted(entry.path) << " " - << entry.x << " " - << entry.y << " " - << entry.w << " " - << entry.h << " " - << entry.trim_left << " " - << entry.trim_top << " " - << entry.trim_right << " " - << entry.trim_bottom << "\n"; - } - out.close(); - if (!out) { - return false; - } - - std::error_code ec; - fs::rename(tmp, cache_path, ec); - if (ec) { - fs::remove(cache_path, ec); - ec.clear(); - fs::rename(tmp, cache_path, ec); - if (ec) { - fs::remove(tmp, ec); - return false; - } - } - return true; -} - -void prune_cache_family_group(const fs::path& base_cache_path, - const std::string& group_suffix, - long long max_age_seconds, - size_t max_files_to_keep) { - if (max_files_to_keep == 0) { - return; - } - - const fs::path parent = base_cache_path.parent_path(); - if (parent.empty()) { - return; - } - - const std::string prefix = base_cache_path.filename().string() + group_suffix; - std::error_code ec; - if (!fs::exists(parent, ec) || ec || !fs::is_directory(parent, ec)) { - return; - } - - const fs::file_time_type now = fs::file_time_type::clock::now(); - struct CacheFileInfo { - fs::path path; - fs::file_time_type mtime; - }; - std::vector keep_candidates; - - for (fs::directory_iterator it(parent, ec), end; it != end; it.increment(ec)) { - if (ec) { - ec.clear(); - continue; - } - const auto& entry = *it; - if (!entry.is_regular_file()) { - continue; - } - - const std::string name = entry.path().filename().string(); - if (name.rfind(prefix, 0) != 0) { - continue; - } - - if (name.size() >= 4 && name.substr(name.size() - 4) == ".tmp") { - fs::remove(entry.path(), ec); - ec.clear(); - continue; - } - - fs::file_time_type mtime = entry.last_write_time(ec); - if (ec) { - ec.clear(); - continue; - } - - bool remove_for_age = false; - if (mtime <= now) { - const long long age = std::chrono::duration_cast(now - mtime).count(); - if (age > max_age_seconds) { - remove_for_age = true; - } - } - if (remove_for_age) { - fs::remove(entry.path(), ec); - ec.clear(); - continue; - } - - keep_candidates.push_back(CacheFileInfo{entry.path(), mtime}); - } - - if (keep_candidates.size() <= max_files_to_keep) { - return; - } - - std::sort(keep_candidates.begin(), keep_candidates.end(), [](const CacheFileInfo& a, const CacheFileInfo& b) { - return a.mtime > b.mtime; - }); - - for (size_t i = max_files_to_keep; i < keep_candidates.size(); ++i) { - fs::remove(keep_candidates[i].path, ec); - ec.clear(); - } -} - -void prune_cache_family(const fs::path& base_cache_path, - long long max_age_seconds, - size_t max_layout_files_to_keep, - size_t max_seed_files_to_keep) { - prune_cache_family_group(base_cache_path, ".layout.", max_age_seconds, max_layout_files_to_keep); - prune_cache_family_group(base_cache_path, ".seed.", max_age_seconds, max_seed_files_to_keep); -} - -void prune_all_spratlayout_cache_families(long long max_age_seconds, - size_t max_layout_files_to_keep, - size_t max_seed_files_to_keep) { - const fs::path parent = cache_root_dir(); - std::error_code ec; - if (!fs::exists(parent, ec) || ec || !fs::is_directory(parent, ec)) { - return; - } - - std::unordered_set base_paths; - for (fs::directory_iterator it(parent, ec), end; it != end; it.increment(ec)) { - if (ec) { - ec.clear(); - continue; - } - const auto& entry = *it; - if (!entry.is_regular_file()) { - continue; - } - const std::string name = entry.path().filename().string(); - if (name.rfind("spratlayout_", 0) != 0) { - continue; - } - - size_t marker = name.find(".cache.layout."); - if (marker == std::string::npos) { - marker = name.find(".cache.seed."); - } - if (marker == std::string::npos) { - continue; - } - - const std::string base_name = name.substr(0, marker + std::string(".cache").size()); - base_paths.insert((parent / base_name).string()); - } - - for (const std::string& base_path : base_paths) { - prune_cache_family(base_path, max_age_seconds, max_layout_files_to_keep, max_seed_files_to_keep); - } -} - -void remove_legacy_top_level_cache_files() { - const fs::path parent = default_temp_dir(); - const fs::path active_root = cache_root_dir(); - if (parent == active_root) { - return; - } - - std::error_code ec; - if (!fs::exists(parent, ec) || ec || !fs::is_directory(parent, ec)) { - return; - } - - for (fs::directory_iterator it(parent, ec), end; it != end; it.increment(ec)) { - if (ec) { - ec.clear(); - continue; - } - const auto& entry = *it; - if (!entry.is_regular_file()) { - continue; - } - const std::string name = entry.path().filename().string(); - if (name.rfind("spratlayout_", 0) != 0) { - continue; - } - if (name.find(".cache") == std::string::npos) { - continue; - } - fs::remove(entry.path(), ec); - ec.clear(); - } -} - -bool try_apply_layout_seed(const LayoutSeedCache& seed, - int padding, - int width_upper_bound, - int height_upper_bound, - const std::vector& source_sprites, - std::vector& out_sprites, - int& out_atlas_width, - int& out_atlas_height) { - if (seed.entries.size() != source_sprites.size()) { - return false; - } - - std::unordered_map seed_by_path; - seed_by_path.reserve(seed.entries.size()); - for (const auto& entry : seed.entries) { - auto inserted = seed_by_path.emplace(entry.path, &entry); - if (!inserted.second) { - return false; - } - } - - std::unordered_set seen_paths; - seen_paths.reserve(source_sprites.size()); - - struct Rect { - int x0 = 0; - int y0 = 0; - int x1 = 0; - int y1 = 0; - }; - std::vector rects; - rects.reserve(source_sprites.size()); - - out_sprites.clear(); - out_sprites.reserve(source_sprites.size()); - out_atlas_width = 0; - out_atlas_height = 0; - - for (const auto& src : source_sprites) { - if (!seen_paths.emplace(src.path).second) { - return false; - } - auto it = seed_by_path.find(src.path); - if (it == seed_by_path.end()) { - return false; - } - const LayoutSeedEntry& entry = *it->second; - if (entry.x < 0 || entry.y < 0 || - entry.w != src.w || entry.h != src.h || - entry.trim_left != src.trim_left || entry.trim_top != src.trim_top || - entry.trim_right != src.trim_right || entry.trim_bottom != src.trim_bottom) { - return false; - } - - int padded_w = 0; - int padded_h = 0; - int x1 = 0; - int y1 = 0; - if (!checked_add_int(src.w, padding, padded_w) || - !checked_add_int(src.h, padding, padded_h) || - !checked_add_int(entry.x, padded_w, x1) || - !checked_add_int(entry.y, padded_h, y1)) { - return false; - } - if (padded_w <= 0 || padded_h <= 0 || x1 > width_upper_bound || y1 > height_upper_bound) { - return false; - } - - Sprite placed = src; - placed.x = entry.x; - placed.y = entry.y; - out_sprites.push_back(std::move(placed)); - rects.push_back(Rect{entry.x, entry.y, x1, y1}); - out_atlas_width = std::max(out_atlas_width, x1); - out_atlas_height = std::max(out_atlas_height, y1); - } - if (out_atlas_width <= 0 || out_atlas_height <= 0) { - return false; - } - - std::vector order(rects.size()); - for (size_t i = 0; i < order.size(); ++i) { - order[i] = i; - } - std::sort(order.begin(), order.end(), [&](size_t a, size_t b) { - return rects[a].x0 < rects[b].x0; - }); - - for (size_t i = 0; i < order.size(); ++i) { - const Rect& a = rects[order[i]]; - for (size_t j = i + 1; j < order.size(); ++j) { - const Rect& b = rects[order[j]]; - if (b.x0 >= a.x1) { - break; - } - if (a.y0 < b.y1 && b.y0 < a.y1) { - return false; - } - } - } - - return true; -} - -std::string build_layout_output_text(int atlas_width, - int atlas_height, - double scale, - bool trim_transparent, - const std::vector& sprites) { - std::ostringstream output; - output << "atlas " << atlas_width << "," << atlas_height << "\n"; - output << "scale " << std::setprecision(8) << scale << "\n"; - for (const auto& s : sprites) { - std::string path = s.path; - size_t pos = 0; - while ((pos = path.find('"', pos)) != std::string::npos) { - path.insert(pos, "\\"); - pos += 2; - } - output << "sprite \"" << path << "\" " - << s.x << "," << s.y << " " - << s.w << "," << s.h; - if (trim_transparent) { - output << " " << s.trim_left << "," << s.trim_top - << " " << s.trim_right << "," << s.trim_bottom; - } - output << "\n"; - } - return output.str(); -} - -bool scale_dimension(int input, double scale, int& output) { - if (input <= 0 || scale <= 0.0) { - return false; - } - long double scaled = static_cast(input) * static_cast(scale); - if (scaled > static_cast(std::numeric_limits::max())) { - return false; - } - int rounded = static_cast(std::lround(scaled)); - if (rounded <= 0) { - rounded = 1; - } - output = rounded; - return true; -} - -Node* insert(Node* node, Sprite& sprite, int w, int h) { - if (node->used) { - if (node->right) { - if (Node* r = insert(node->right.get(), sprite, w, h)) { - return r; - } - } - if (node->down) { - return insert(node->down.get(), sprite, w, h); - } - return nullptr; - } - if (w > node->w || h > node->h) { - return nullptr; - } - if (w == node->w && h == node->h) { - node->used = true; - return node; - } - node->used = true; - node->down = std::make_unique(node->x, node->y + h, node->w, node->h - h); - node->right = std::make_unique(node->x + w, node->y, node->w - w, h); - return node; -} - -bool try_pack(std::unique_ptr& root, std::vector& sprites, int padding = 0) { - root->used = false; - root->right.reset(); - root->down.reset(); - for (auto& sprite : sprites) { - int w = 0; - int h = 0; - if ( - !checked_add_int(sprite.w, padding, w) - || !checked_add_int(sprite.h, padding, h) - ) { - return false; - } - Node* node = insert(root.get(), sprite, w, h); - if (!node) { - return false; - } - sprite.x = node->x; - sprite.y = node->y; - } - return true; -} - -enum class SortMode { - Height, - Width, - Area, - MaxSide, - Perimeter -}; - -bool sort_sprites_by_mode(std::vector& sprites, SortMode mode) { - auto cmp_height = [](const Sprite& a, const Sprite& b) { - if (a.h != b.h) return a.h > b.h; - return a.w > b.w; - }; - auto cmp_width = [](const Sprite& a, const Sprite& b) { - if (a.w != b.w) return a.w > b.w; - return a.h > b.h; - }; - auto cmp_area = [](const Sprite& a, const Sprite& b) { - long long area_a = static_cast(a.w) * static_cast(a.h); - long long area_b = static_cast(b.w) * static_cast(b.h); - if (area_a != area_b) return area_a > area_b; - if (a.h != b.h) return a.h > b.h; - return a.w > b.w; - }; - auto cmp_max_side = [](const Sprite& a, const Sprite& b) { - int max_a = std::max(a.w, a.h); - int max_b = std::max(b.w, b.h); - if (max_a != max_b) return max_a > max_b; - long long area_a = static_cast(a.w) * static_cast(a.h); - long long area_b = static_cast(b.w) * static_cast(b.h); - return area_a > area_b; - }; - auto cmp_perimeter = [](const Sprite& a, const Sprite& b) { - int p_a = a.w + a.h; - int p_b = b.w + b.h; - if (p_a != p_b) return p_a > p_b; - long long area_a = static_cast(a.w) * static_cast(a.h); - long long area_b = static_cast(b.w) * static_cast(b.h); - return area_a > area_b; - }; - - switch (mode) { - case SortMode::Height: - std::sort(sprites.begin(), sprites.end(), cmp_height); - return true; - case SortMode::Width: - std::sort(sprites.begin(), sprites.end(), cmp_width); - return true; - case SortMode::Area: - std::sort(sprites.begin(), sprites.end(), cmp_area); - return true; - case SortMode::MaxSide: - std::sort(sprites.begin(), sprites.end(), cmp_max_side); - return true; - case SortMode::Perimeter: - std::sort(sprites.begin(), sprites.end(), cmp_perimeter); - return true; - } - return false; -} - -struct Rect { - int x = 0; - int y = 0; - int w = 0; - int h = 0; -}; - -enum class RectHeuristic { - BestShortSideFit, - BestAreaFit, - BottomLeft -}; - -bool rects_intersect(const Rect& a, const Rect& b) { - return !(a.x + a.w <= b.x || b.x + b.w <= a.x || - a.y + a.h <= b.y || b.y + b.h <= a.y); -} - -bool rect_contains(const Rect& a, const Rect& b) { - return b.x >= a.x && b.y >= a.y && - b.x + b.w <= a.x + a.w && - b.y + b.h <= a.y + a.h; -} - -bool split_free_rect(const Rect& free_rect, const Rect& used_rect, std::vector& out) { - if (!rects_intersect(free_rect, used_rect)) { - out.push_back(free_rect); - return true; - } - - int free_right = free_rect.x + free_rect.w; - int free_bottom = free_rect.y + free_rect.h; - int used_right = used_rect.x + used_rect.w; - int used_bottom = used_rect.y + used_rect.h; - - if (used_rect.x > free_rect.x) { - out.push_back({free_rect.x, free_rect.y, used_rect.x - free_rect.x, free_rect.h}); - } - if (used_right < free_right) { - out.push_back({used_right, free_rect.y, free_right - used_right, free_rect.h}); - } - if (used_rect.y > free_rect.y) { - int x0 = std::max(free_rect.x, used_rect.x); - int x1 = std::min(free_right, used_right); - if (x1 > x0) { - out.push_back({x0, free_rect.y, x1 - x0, used_rect.y - free_rect.y}); - } - } - if (used_bottom < free_bottom) { - int x0 = std::max(free_rect.x, used_rect.x); - int x1 = std::min(free_right, used_right); - if (x1 > x0) { - out.push_back({x0, used_bottom, x1 - x0, free_bottom - used_bottom}); - } - } - return true; -} - -void prune_free_rects(std::vector& free_rects) { - size_t i = 0; - while (i < free_rects.size()) { - bool removed_i = false; - size_t j = i + 1; - while (j < free_rects.size()) { - if (rect_contains(free_rects[i], free_rects[j])) { - free_rects.erase(free_rects.begin() + static_cast(j)); - continue; - } - if (rect_contains(free_rects[j], free_rects[i])) { - free_rects.erase(free_rects.begin() + static_cast(i)); - removed_i = true; - break; - } - ++j; - } - if (!removed_i) { - ++i; - } - } -} - -bool pack_compact_maxrects( - std::vector& sprites, - int width_limit, - int padding, - int max_height, - RectHeuristic heuristic, - int& out_width, - int& out_height -) { - if (width_limit <= 0 || max_height <= 0) { - return false; - } - - std::vector free_rects; - free_rects.push_back({0, 0, width_limit, max_height}); - - int used_w = 0; - int used_h = 0; - - for (auto& s : sprites) { - int rw = 0; - int rh = 0; - if ( - !checked_add_int(s.w, padding, rw) - || !checked_add_int(s.h, padding, rh) - || rw <= 0 || rh <= 0 || rw > width_limit || rh > max_height - ) { - return false; - } - - int best_index = -1; - int best_short_fit = std::numeric_limits::max(); - int best_long_fit = std::numeric_limits::max(); - int best_area_fit = std::numeric_limits::max(); - int best_top = std::numeric_limits::max(); - int best_left = std::numeric_limits::max(); - - for (size_t i = 0; i < free_rects.size(); ++i) { - const Rect& fr = free_rects[i]; - if (rw > fr.w || rh > fr.h) { - continue; - } - - int leftover_h = fr.h - rh; - int leftover_w = fr.w - rw; - int short_fit = std::min(leftover_h, leftover_w); - int long_fit = std::max(leftover_h, leftover_w); - int area_fit = leftover_h * leftover_w; - - bool better = false; - if (heuristic == RectHeuristic::BestShortSideFit) { - better = short_fit < best_short_fit || - (short_fit == best_short_fit && long_fit < best_long_fit) || - (short_fit == best_short_fit && long_fit == best_long_fit && fr.y < best_top) || - (short_fit == best_short_fit && long_fit == best_long_fit && fr.y == best_top && fr.x < best_left); - } else if (heuristic == RectHeuristic::BestAreaFit) { - better = area_fit < best_area_fit || - (area_fit == best_area_fit && short_fit < best_short_fit) || - (area_fit == best_area_fit && short_fit == best_short_fit && fr.y < best_top) || - (area_fit == best_area_fit && short_fit == best_short_fit && fr.y == best_top && fr.x < best_left); - } else { - better = fr.y < best_top || (fr.y == best_top && fr.x < best_left) || - (fr.y == best_top && fr.x == best_left && short_fit < best_short_fit); - } - - if (better) { - best_index = static_cast(i); - best_short_fit = short_fit; - best_long_fit = long_fit; - best_area_fit = area_fit; - best_top = fr.y; - best_left = fr.x; - } - } - - if (best_index < 0) { - return false; - } - - Rect used = {free_rects[static_cast(best_index)].x, - free_rects[static_cast(best_index)].y, - rw, rh}; - s.x = used.x; - s.y = used.y; - - if (used.x + used.w > used_w) used_w = used.x + used.w; - if (used.y + used.h > used_h) used_h = used.y + used.h; - - std::vector next_free; - next_free.reserve(free_rects.size() * 2); - for (const auto& fr : free_rects) { - if (!split_free_rect(fr, used, next_free)) { - return false; - } - } - - free_rects.clear(); - free_rects.reserve(next_free.size()); - for (const auto& r : next_free) { - if (r.w > 0 && r.h > 0) { - free_rects.push_back(r); - } - } - prune_free_rects(free_rects); - } - - out_width = used_w; - out_height = used_h; - return out_width > 0 && out_height > 0; -} - -bool pack_fast_shelf( - std::vector& sprites, - int max_row_width, - int padding, - int& out_width, - int& out_height -) { - int x = 0; - int y = 0; - int row_height = 0; - int atlas_width = 0; - if (max_row_width <= 0) { - return false; - } - - for (auto& s : sprites) { - int w = 0; - int h = 0; - int candidate_x = 0; - int next_y = 0; - - if ( - !checked_add_int(s.w, padding, w) - || !checked_add_int(s.h, padding, h) - || w <= 0 || h <= 0 - || w > max_row_width - || !checked_add_int(x, w, candidate_x) - ) { - return false; - } - - if (x > 0 && candidate_x > max_row_width) { - if (!checked_add_int(y, row_height, next_y)) { - return false; - } - y = next_y; - x = 0; - row_height = 0; - if (!checked_add_int(x, w, candidate_x)) { - return false; - } - } - - s.x = x; - s.y = y; - x = candidate_x; - if (h > row_height) { - row_height = h; - } - if (x > atlas_width) { - atlas_width = x; - } - } - - int total_height = 0; - if (!checked_add_int(y, row_height, total_height)) { - return false; - } - out_width = atlas_width; - out_height = total_height; - return out_width > 0 && out_height > 0; -} - -bool compute_tight_atlas_bounds(const std::vector& sprites, int& out_width, int& out_height) { - out_width = 0; - out_height = 0; - for (const auto& s : sprites) { - int x1 = 0; - int y1 = 0; - if (!checked_add_int(s.x, s.w, x1) || !checked_add_int(s.y, s.h, y1)) { - return false; - } - if (x1 > out_width) { - out_width = x1; - } - if (y1 > out_height) { - out_height = y1; - } - } - return out_width > 0 && out_height > 0; -} - -int next_power_of_two(int v) { - if (v <= 1) { - return 1; - } - - int p = 1; - while (p < v) { - if (p > std::numeric_limits::max() / 2) { - return -1; - } - p <<= 1; - } - return p; -} - -bool pick_better_layout_candidate( - size_t candidate_area, - int candidate_w, - int candidate_h, - bool have_best, - size_t best_area, - int best_w, - int best_h, - OptimizeTarget optimize_target -) { - if (!have_best) { - return true; - } - - int candidate_max_side = std::max(candidate_w, candidate_h); - int best_max_side = std::max(best_w, best_h); - int candidate_aspect_delta = std::abs(candidate_w - candidate_h); - int best_aspect_delta = std::abs(best_w - best_h); - - if (optimize_target == OptimizeTarget::GPU) { - if (candidate_max_side != best_max_side) { - return candidate_max_side < best_max_side; - } - if (candidate_area != best_area) { - return candidate_area < best_area; - } - if (candidate_aspect_delta != best_aspect_delta) { - return candidate_aspect_delta < best_aspect_delta; - } - return candidate_w < best_w; - } - - if (candidate_area != best_area) { - return candidate_area < best_area; - } - - if (candidate_max_side != best_max_side) { - return candidate_max_side < best_max_side; - } - - if (candidate_aspect_delta != best_aspect_delta) { - return candidate_aspect_delta < best_aspect_delta; - } - - return candidate_w < best_w; -} - -int main(int argc, char** argv) { - if (argc < 2) { - std::cerr << "Usage: spratlayout [--profile NAME] [--profiles-config PATH] " - << "[--mode compact|pot|fast] [--optimize gpu|space] [--max-width N] [--max-height N] " - << "[--padding N] [--max-combinations N] [--source-resolution WxH] [--target-resolution WxH] " - << "[--resolution-reference largest|smallest] " - << "[--scale F] [--trim-transparent|--no-trim-transparent] " - << "[--threads N]\n"; - return 1; - } - - fs::path folder = argv[1]; - std::string requested_profile_name; - std::string profiles_config_path; - bool has_mode_override = false; - Mode mode_override = Mode::COMPACT; - bool has_optimize_override = false; - OptimizeTarget optimize_override = OptimizeTarget::GPU; - Mode mode = Mode::COMPACT; - OptimizeTarget optimize_target = OptimizeTarget::GPU; - int max_width_limit = 0; - int max_height_limit = 0; - bool has_max_width_limit = false; - bool has_max_height_limit = false; - int padding = 0; - bool has_padding_override = false; - int max_combinations = 0; - bool has_max_combinations_override = false; - int source_resolution_width = 0; - int source_resolution_height = 0; - int target_resolution_width = 0; - int target_resolution_height = 0; - bool has_source_resolution = false; - bool has_target_resolution = false; - ResolutionReference resolution_reference = ResolutionReference::Largest; - bool has_resolution_reference_override = false; - double scale = 1.0; - bool has_scale_override = false; - bool trim_transparent = false; - bool has_trim_override = false; - unsigned int thread_limit = 0; - bool has_threads_override = false; - - // parse args - for (int i = 2; i < argc; ++i) { - std::string arg = argv[i]; - if (arg == "--profile" && i + 1 < argc) { - requested_profile_name = argv[++i]; - } else if (arg == "--profiles-config" && i + 1 < argc) { - profiles_config_path = argv[++i]; - } else if (arg == "--mode" && i + 1 < argc) { - std::string value = argv[++i]; - std::string error; - if (!parse_mode_from_string(value, mode_override, error)) { - std::cerr << "Invalid mode value: " << value << "\n"; - return 1; - } - has_mode_override = true; - } else if (arg == "--optimize" && i + 1 < argc) { - std::string value = argv[++i]; - std::string error; - if (!parse_optimize_target_from_string(value, optimize_override, error)) { - std::cerr << "Invalid optimize value: " << value << "\n"; - return 1; - } - has_optimize_override = true; - } else if (arg == "--max-width" && i + 1 < argc) { - std::string value = argv[++i]; - try { - size_t idx = 0; - max_width_limit = std::stoi(value, &idx); - if (idx != value.size() || max_width_limit <= 0) { - std::cerr << "Invalid max width value: " << value << "\n"; - return 1; - } - has_max_width_limit = true; - } catch (const std::exception&) { - std::cerr << "Invalid max width value: " << value << "\n"; - return 1; - } - } else if (arg == "--max-height" && i + 1 < argc) { - std::string value = argv[++i]; - try { - size_t idx = 0; - max_height_limit = std::stoi(value, &idx); - if (idx != value.size() || max_height_limit <= 0) { - std::cerr << "Invalid max height value: " << value << "\n"; - return 1; - } - has_max_height_limit = true; - } catch (const std::exception&) { - std::cerr << "Invalid max height value: " << value << "\n"; - return 1; - } - } else if (arg == "--padding" && i + 1 < argc) { - std::string value = argv[++i]; - try { - size_t idx = 0; - padding = std::stoi(value, &idx); - if (idx != value.size()) { - std::cerr << "Invalid padding value: " << value << "\n"; - return 1; - } - } catch (const std::exception&) { - std::cerr << "Invalid padding value: " << value << "\n"; - return 1; - } - if (padding < 0) { - padding = 0; - } - has_padding_override = true; - } else if (arg == "--max-combinations" && i + 1 < argc) { - std::string value = argv[++i]; - try { - size_t idx = 0; - max_combinations = std::stoi(value, &idx); - if (idx != value.size() || max_combinations < 0) { - std::cerr << "Invalid max combinations value: " << value << "\n"; - return 1; - } - } catch (const std::exception&) { - std::cerr << "Invalid max combinations value: " << value << "\n"; - return 1; - } - has_max_combinations_override = true; - } else if (arg == "--source-resolution" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_resolution(value, source_resolution_width, source_resolution_height)) { - std::cerr << "Invalid source resolution value: " << value << "\n"; - return 1; - } - has_source_resolution = true; - } else if (arg == "--target-resolution" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_resolution(value, target_resolution_width, target_resolution_height)) { - std::cerr << "Invalid target resolution value: " << value << "\n"; - return 1; - } - has_target_resolution = true; - } else if (arg == "--resolution-reference" && i + 1 < argc) { - if (has_resolution_reference_override) { - std::cerr << "Error: --resolution-reference can only be provided once\n"; - return 1; - } - std::string value = argv[++i]; - std::string error; - if (!parse_resolution_reference_from_string(value, resolution_reference, error)) { - std::cerr << "Invalid resolution reference value: " << value << "\n"; - return 1; - } - has_resolution_reference_override = true; - } else if (arg == "--scale" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_scale_factor(value, scale)) { - std::cerr << "Invalid scale value: " << value << "\n"; - return 1; - } - has_scale_override = true; - } else if (arg == "--trim-transparent") { - trim_transparent = true; - has_trim_override = true; - } else if (arg == "--no-trim-transparent") { - trim_transparent = false; - has_trim_override = true; - } else if (arg == "--threads" && i + 1 < argc) { - std::string value = argv[++i]; - try { - size_t idx = 0; - int parsed = std::stoi(value, &idx); - if (idx != value.size() || parsed <= 0) { - std::cerr << "Invalid thread count: " << value << "\n"; - return 1; - } - thread_limit = static_cast(parsed); - } catch (const std::exception&) { - std::cerr << "Invalid thread count: " << value << "\n"; - return 1; - } - has_threads_override = true; - } else { - std::cerr << "Unknown argument: " << arg << "\n"; - return 1; - } - } - - std::vector profile_definitions; - std::unordered_map profile_map; - std::string selected_profile_name = k_default_profile_name; - const bool has_requested_profile = !requested_profile_name.empty(); - if (has_requested_profile) { - selected_profile_name = requested_profile_name; - } - - if (!has_mode_override) { - mode = k_default_mode; - } else { - mode = mode_override; - } - if (!has_optimize_override) { - optimize_target = k_default_optimize_target; - } else { - optimize_target = optimize_override; - } - if (!has_padding_override) { - padding = k_default_padding; - } - if (!has_max_combinations_override) { - max_combinations = k_default_max_combinations; - } - if (!has_scale_override) { - scale = k_default_scale; - } - if (!has_trim_override) { - trim_transparent = k_default_trim_transparent; - } - if (!has_threads_override) { - thread_limit = k_default_threads; - } - - fs::path cwd = fs::current_path(); - fs::path exec_path(argv[0]); - if (exec_path.is_relative() && !cwd.empty()) { - exec_path = cwd / exec_path; - } - fs::path exec_dir = exec_path.parent_path(); - if (exec_dir.empty()) { - exec_dir = cwd; - } - - if (has_requested_profile) { - std::string config_error; - std::vector config_candidates; - if (!profiles_config_path.empty()) { - fs::path config_candidate(profiles_config_path); - if (config_candidate.is_relative() && !cwd.empty()) { - config_candidate = cwd / config_candidate; - } - config_candidates.push_back(std::move(config_candidate)); - } else { - if (std::optional user_config = resolve_user_profiles_config_path()) { - config_candidates.push_back(*user_config); - } - config_candidates.push_back(exec_dir / k_profiles_config_filename); - config_candidates.push_back(fs::path(k_global_profiles_config_path)); - } - - bool loaded_profile_file = false; - std::vector tried_candidates; - for (const fs::path& candidate : config_candidates) { - std::error_code ec; - const bool exists = fs::exists(candidate, ec); - if (ec || !exists) { - tried_candidates.push_back(candidate.string()); - continue; - } - if (!load_profiles_config_from_file(candidate, profile_definitions, config_error)) { - std::cerr << "Failed to load profile config (" << candidate << "): " << config_error << "\n"; - return 1; - } - loaded_profile_file = true; - break; - } - - if (!loaded_profile_file) { - std::cerr << "Failed to load profile config. Tried:"; - for (const std::string& candidate : tried_candidates) { - std::cerr << " " << candidate; - } - std::cerr << "\n"; - return 1; - } - - for (const auto& def : profile_definitions) { - profile_map.emplace(def.name, def); - } - - auto profile_it = profile_map.find(selected_profile_name); - if (profile_it == profile_map.end()) { - std::string available; - for (size_t idx = 0; idx < profile_definitions.size(); ++idx) { - if (idx > 0) { - available += ", "; - } - available += profile_definitions[idx].name; - } - std::cerr << "Invalid profile '" << selected_profile_name << "'. Available profiles: " - << available << "\n"; - return 1; - } - - const ProfileDefinition& selected_profile = profile_it->second; - if (!has_mode_override) { - mode = selected_profile.mode; - } - if (!has_optimize_override) { - optimize_target = selected_profile.optimize_target; - } - if (!has_max_width_limit && selected_profile.max_width) { - max_width_limit = *selected_profile.max_width; - } - if (!has_max_height_limit && selected_profile.max_height) { - max_height_limit = *selected_profile.max_height; - } - if (!has_padding_override && selected_profile.padding) { - padding = *selected_profile.padding; - } - if (!has_max_combinations_override && selected_profile.max_combinations) { - max_combinations = *selected_profile.max_combinations; - } - if (!has_scale_override && selected_profile.scale) { - scale = *selected_profile.scale; - } - if (!has_trim_override && selected_profile.trim_transparent) { - trim_transparent = *selected_profile.trim_transparent; - } - if (!has_threads_override && selected_profile.threads) { - thread_limit = *selected_profile.threads; - } - if (!has_source_resolution && selected_profile.source_resolution) { - source_resolution_width = selected_profile.source_resolution->first; - source_resolution_height = selected_profile.source_resolution->second; - has_source_resolution = true; - } - if (!has_target_resolution && selected_profile.target_resolution) { - if (selected_profile.target_resolution->first == -1 && - selected_profile.target_resolution->second == -1) { - if (has_source_resolution) { - target_resolution_width = source_resolution_width; - target_resolution_height = source_resolution_height; - has_target_resolution = true; - } - } else { - target_resolution_width = selected_profile.target_resolution->first; - target_resolution_height = selected_profile.target_resolution->second; - has_target_resolution = true; - } - } - if (!has_resolution_reference_override && selected_profile.resolution_reference) { - resolution_reference = *selected_profile.resolution_reference; - } - } - - if (has_source_resolution != has_target_resolution) { - std::cerr << "Error: --source-resolution and --target-resolution must be provided together\n"; - return 1; - } - if (has_source_resolution) { - const double scale_x = - static_cast(target_resolution_width) / static_cast(source_resolution_width); - const double scale_y = - static_cast(target_resolution_height) / static_cast(source_resolution_height); - const double resolution_scale = - (resolution_reference == ResolutionReference::Largest) - ? std::max(scale_x, scale_y) - : std::min(scale_x, scale_y); - scale *= resolution_scale; - } - - const bool is_dir = fs::exists(folder) && fs::is_directory(folder); - const bool is_file = fs::exists(folder) && fs::is_regular_file(folder); - if (!is_dir && !is_file) { - std::cerr << "Error: invalid directory or list file\n"; - return 1; - } - - const fs::path cache_path = build_cache_path(folder); - constexpr long long k_cache_max_age_seconds = 3600; - constexpr size_t k_cache_max_layout_files = 16; - constexpr size_t k_cache_max_seed_files = 8; - const long long now_unix = now_unix_seconds(); - remove_legacy_top_level_cache_files(); - prune_all_spratlayout_cache_families(k_cache_max_age_seconds, k_cache_max_layout_files, k_cache_max_seed_files); - prune_cache_family(cache_path, k_cache_max_age_seconds, k_cache_max_layout_files, k_cache_max_seed_files); - - std::vector sources; - auto add_source = [&](const fs::path& image_path, bool strict) -> bool { - if (!is_supported_image_extension(image_path)) { - if (strict) { - std::cerr << "Invalid extension in list input: " << image_path << "\n"; - return false; - } - return true; - } - ImageMeta meta; - if (!read_image_meta(image_path, meta)) { - if (strict) { - std::cerr << "Failed to stat image: " << image_path << "\n"; - return false; - } - return true; - } - ImageSource source; - source.file_path = image_path; - source.path = image_path.string(); - source.meta = meta; - sources.push_back(std::move(source)); - return true; - }; - - if (is_dir) { - for (const auto& entry : fs::directory_iterator(folder)) { - if (!entry.is_regular_file()) { - continue; - } - if (!add_source(entry.path(), false)) { - continue; - } - } - } else { - std::ifstream list_file(folder); - if (!list_file) { - std::cerr << "Failed to open list file: " << folder << "\n"; - return 1; - } - std::string line; - size_t line_number = 0; - while (std::getline(list_file, line)) { - ++line_number; - std::string trimmed = trim_copy(line); - if (trimmed.empty() || trimmed.front() == '#') { - continue; - } - fs::path entry_path(trimmed); - if (entry_path.is_relative()) { - entry_path = folder.parent_path() / entry_path; - } - if (!fs::exists(entry_path) || !fs::is_regular_file(entry_path)) { - std::cerr << "Invalid image path at line " << line_number << ": " << trimmed << "\n"; - return 1; - } - if (!add_source(entry_path, true)) { - return 1; - } - } - } - - if (sources.empty()) { - std::cerr << "Error: no valid images found\n"; - return 1; - } - - const std::string layout_signature = build_layout_signature( - selected_profile_name, mode, optimize_target, max_width_limit, max_height_limit, - padding, max_combinations, scale, trim_transparent, is_file, sources); - const std::string layout_seed_signature = build_layout_seed_signature( - selected_profile_name, mode, optimize_target, max_width_limit, max_height_limit, - max_combinations, scale, trim_transparent, is_file, sources); - const fs::path output_cache_path = build_output_cache_path(cache_path, layout_signature); - const fs::path seed_cache_path = build_seed_cache_path(cache_path, layout_seed_signature); - if (!is_file_older_than_seconds(output_cache_path, k_cache_max_age_seconds)) { - std::string cached_output; - if (load_output_cache(output_cache_path, layout_signature, cached_output)) { - std::cout << cached_output; - return 0; - } - } - - std::unordered_map cache_entries; - load_image_cache(cache_path, cache_entries); - prune_stale_cache_entries(cache_entries, now_unix, k_cache_max_age_seconds); - - std::vector sprites; - for (const auto& source : sources) { - const fs::path& file_path = source.file_path; - const std::string& path = source.path; - const ImageMeta& meta = source.meta; - - const std::string cache_key = path + (trim_transparent ? "|1" : "|0"); - auto cache_it = cache_entries.find(cache_key); - if (cache_it != cache_entries.end()) { - const ImageCacheEntry& cached = cache_it->second; - if (cached.trim_transparent == trim_transparent && - cached.file_size == meta.file_size && - cached.mtime_ticks == meta.mtime_ticks) { - Sprite s; - s.path = path; - s.w = cached.w; - s.h = cached.h; - s.trim_left = cached.trim_left; - s.trim_top = cached.trim_top; - s.trim_right = cached.trim_right; - s.trim_bottom = cached.trim_bottom; - sprites.push_back(std::move(s)); - cache_it->second.cached_at_unix = now_unix; - continue; - } - } - - Sprite loaded_sprite; - loaded_sprite.path = path; - if (!trim_transparent) { - int w, h, channels; - if (!stbi_info(path.c_str(), &w, &h, &channels)) { - continue; - } - loaded_sprite.w = w; - loaded_sprite.h = h; - sprites.push_back(loaded_sprite); - cache_entries[cache_key] = ImageCacheEntry{ - trim_transparent, - meta.file_size, - meta.mtime_ticks, - loaded_sprite.w, - loaded_sprite.h, - loaded_sprite.trim_left, - loaded_sprite.trim_top, - loaded_sprite.trim_right, - loaded_sprite.trim_bottom, - now_unix - }; - continue; - } - - int w = 0; - int h = 0; - int channels = 0; - unsigned char* data = stbi_load(path.c_str(), &w, &h, &channels, 4); - if (!data) { - continue; - } - - int min_x = 0; - int min_y = 0; - int max_x = -1; - int max_y = -1; - if (compute_trim_bounds(data, w, h, min_x, min_y, max_x, max_y)) { - loaded_sprite.trim_left = min_x; - loaded_sprite.trim_top = min_y; - loaded_sprite.trim_right = (w - 1) - max_x; - loaded_sprite.trim_bottom = (h - 1) - max_y; - loaded_sprite.w = max_x - min_x + 1; - loaded_sprite.h = max_y - min_y + 1; - } else { - // Fully transparent image: keep a 1x1 transparent region. - loaded_sprite.trim_left = 0; - loaded_sprite.trim_top = 0; - loaded_sprite.trim_right = std::max(0, w - 1); - loaded_sprite.trim_bottom = std::max(0, h - 1); - loaded_sprite.w = 1; - loaded_sprite.h = 1; - } - - stbi_image_free(data); - sprites.push_back(loaded_sprite); - cache_entries[cache_key] = ImageCacheEntry{ - trim_transparent, - meta.file_size, - meta.mtime_ticks, - loaded_sprite.w, - loaded_sprite.h, - loaded_sprite.trim_left, - loaded_sprite.trim_top, - loaded_sprite.trim_right, - loaded_sprite.trim_bottom, - now_unix - }; - } - - save_image_cache(cache_path, cache_entries); - - if (sprites.empty()) { - std::cerr << "Error: no valid images found\n"; - return 1; - } - - if (scale != 1.0) { - for (auto& s : sprites) { - int scaled_w = 0; - int scaled_h = 0; - if (!scale_dimension(s.w, scale, scaled_w) || !scale_dimension(s.h, scale, scaled_h)) { - std::cerr << "Error: scaled sprite dimensions are invalid\n"; - return 1; - } - s.w = scaled_w; - s.h = scaled_h; - } - } - - int max_width = 0; - int max_height = 0; - int sum_width = 0; - int sum_height = 0; - size_t total_area = 0; - for (auto& s : sprites) { - int padded_w = 0; - int padded_h = 0; - if (!checked_add_int(s.w, padding, padded_w)) { - std::cerr << "Error: dimensions are too large\n"; - return 1; - } - if (!checked_add_int(s.h, padding, padded_h)) { - std::cerr << "Error: dimensions are too large\n"; - return 1; - } - - size_t sprite_area = 0; - if (!checked_mul_size_t(static_cast(padded_w), static_cast(padded_h), sprite_area) || - sprite_area > std::numeric_limits::max() - total_area) { - std::cerr << "Error: total area is too large\n"; - return 1; - } - total_area += sprite_area; - - max_width = std::max(max_width, padded_w); - max_height = std::max(max_height, padded_h); - if (!checked_add_int(sum_width, padded_w, sum_width) || - !checked_add_int(sum_height, padded_h, sum_height)) { - std::cerr << "Error: dimensions are too large\n"; - return 1; - } - } - - int atlas_width = 0, atlas_height = 0; - int width_upper_bound = sum_width; - int height_upper_bound = sum_height; - if (max_width_limit > 0) { - width_upper_bound = std::min(width_upper_bound, max_width_limit); - } - if (max_height_limit > 0) { - height_upper_bound = std::min(height_upper_bound, max_height_limit); - } - if (max_width > width_upper_bound || max_height > height_upper_bound) { - std::cerr << "Error: sprite dimensions exceed provided atlas limits\n"; - return 1; - } - - bool reused_layout_seed = false; - bool have_layout_seed = false; - LayoutSeedCache seed_cache; - std::vector seeded_sprites; - if (!is_file_older_than_seconds(seed_cache_path, k_cache_max_age_seconds) && - load_layout_seed_cache(seed_cache_path, layout_seed_signature, seed_cache)) { - if (seed_cache.padding == padding) { - have_layout_seed = true; - if (try_apply_layout_seed(seed_cache, padding, width_upper_bound, height_upper_bound, - sprites, seeded_sprites, atlas_width, atlas_height)) { - sprites = std::move(seeded_sprites); - reused_layout_seed = true; - } - } - } - - if (!reused_layout_seed) { - std::unique_ptr root; - const std::array sort_modes = { - SortMode::Area, - SortMode::MaxSide, - SortMode::Height, - SortMode::Width, - SortMode::Perimeter - }; - const std::array rect_heuristics = { - RectHeuristic::BestShortSideFit, - RectHeuristic::BestAreaFit, - RectHeuristic::BottomLeft - }; - - if (mode == Mode::POT) { - int min_pot_width = next_power_of_two(max_width); - int min_pot_height = next_power_of_two(max_height); - if (min_pot_width <= 0 || min_pot_height <= 0) { - std::cerr << "Error: dimensions are too large\n"; - return 1; - } - - // First, find an upper bound that can pack, then search all POT - // rectangles up to that area and pick the least wasteful successful fit. - int side = std::max(min_pot_width, min_pot_height); - std::vector best_sprites = sprites; - int best_w = 0; - int best_h = 0; - size_t best_area = 0; - size_t max_candidate_area = 0; - bool have_best = false; - - while (true) { - if (max_width_limit > 0 && side > max_width_limit) { - std::cerr << "Error: no POT layout fits within max width\n"; - return 1; - } - if (max_height_limit > 0 && side > max_height_limit) { - std::cerr << "Error: no POT layout fits within max height\n"; - return 1; - } - for (SortMode sort_mode : sort_modes) { - std::vector trial_sprites = sprites; - sort_sprites_by_mode(trial_sprites, sort_mode); - root = std::make_unique(0, 0, side, side); - if (!try_pack(root, trial_sprites, padding)) { - continue; - } - size_t area = static_cast(side) * static_cast(side); - best_sprites = std::move(trial_sprites); - best_w = side; - best_h = side; - best_area = area; - max_candidate_area = area; - have_best = true; - break; - } - if (have_best) { - break; - } - if (side > std::numeric_limits::max() / 2) { - std::cerr << "Error: atlas dimensions overflow\n"; - return 1; - } - side *= 2; - } - - std::vector pot_widths; - std::vector pot_heights; - for (int w = min_pot_width; w > 0 && static_cast(w) <= best_area; w *= 2) { - pot_widths.push_back(w); - if (w > std::numeric_limits::max() / 2) { - break; - } - } - for (int h = min_pot_height; h > 0 && static_cast(h) <= best_area; h *= 2) { - pot_heights.push_back(h); - if (h > std::numeric_limits::max() / 2) { - break; - } - } - - for (int w : pot_widths) { - for (int h : pot_heights) { - size_t area = static_cast(w) * static_cast(h); - if (area > max_candidate_area) { - continue; - } - if (max_width_limit > 0 && w > max_width_limit) { - continue; - } - if (max_height_limit > 0 && h > max_height_limit) { - continue; - } - if (!pick_better_layout_candidate(area, w, h, have_best, best_area, best_w, best_h, optimize_target)) { - continue; - } - - for (SortMode sort_mode : sort_modes) { - std::vector trial_sprites = sprites; - sort_sprites_by_mode(trial_sprites, sort_mode); - root = std::make_unique(0, 0, w, h); - if (!try_pack(root, trial_sprites, padding)) { - continue; - } - - best_sprites = std::move(trial_sprites); - best_w = w; - best_h = h; - best_area = area; - have_best = true; - break; - } - } - } - - if (!have_best) { - std::cerr << "Error: failed to compute pot layout\n"; - return 1; - } - - sprites = std::move(best_sprites); - atlas_width = best_w; - atlas_height = best_h; - } else if (mode == Mode::COMPACT) { - if (sum_width <= 0 || sum_height <= 0) { - std::cerr << "Error: compact bounds are invalid\n"; - return 1; - } - const size_t combination_budget = max_combinations > 0 - ? static_cast(max_combinations) - : std::numeric_limits::max(); - std::atomic combinations_tested{0}; - auto consume_combination_budget = [&]() -> bool { - if (combination_budget == std::numeric_limits::max()) { - combinations_tested.fetch_add(1, std::memory_order_relaxed); - return true; - } - const size_t previous = combinations_tested.fetch_add(1, std::memory_order_relaxed); - return previous < combination_budget; - }; - unsigned int worker_count = thread_limit > 0 ? thread_limit : std::thread::hardware_concurrency(); - if (worker_count == 0) { - worker_count = 1; - } - - std::array, 5> sorted_sprites_by_mode; - for (size_t i = 0; i < sort_modes.size(); ++i) { - sorted_sprites_by_mode[i] = sprites; - sort_sprites_by_mode(sorted_sprites_by_mode[i], sort_modes[i]); - } - - int seed_width = max_width; - if (total_area > 0) { - long double area_root = std::sqrt(static_cast(total_area)); - if (area_root > static_cast(std::numeric_limits::max())) { - std::cerr << "Error: compact width is too large\n"; - return 1; - } - int root_width = static_cast(std::ceil(area_root)); - if (root_width > seed_width) { - seed_width = root_width; - } - } - if (seed_width > width_upper_bound) { - seed_width = width_upper_bound; - } - if (seed_width < max_width) { - seed_width = max_width; - } - if (have_layout_seed) { - int seed_hint_width = seed_cache.atlas_width; - if (padding > seed_cache.padding) { - int delta = 0; - if (checked_add_int(seed_hint_width, padding - seed_cache.padding, delta)) { - seed_hint_width = delta; - } - } - if (seed_hint_width >= max_width && seed_hint_width <= width_upper_bound) { - seed_width = seed_hint_width; - } - } - - LayoutCandidate best_gpu_candidate; - LayoutCandidate best_space_candidate; - auto consider_candidate = [&](LayoutCandidate&& candidate) { - if (!candidate.valid || candidate.w <= 0 || candidate.h <= 0) { - return; - } - const bool better_gpu = - !best_gpu_candidate.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - best_gpu_candidate.area, best_gpu_candidate.w, best_gpu_candidate.h, - OptimizeTarget::GPU); - const bool better_space = - !best_space_candidate.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - best_space_candidate.area, best_space_candidate.w, best_space_candidate.h, - OptimizeTarget::SPACE); - - if (!better_gpu && !better_space) { - return; - } - - if (better_gpu && better_space) { - best_gpu_candidate = candidate; - best_space_candidate = std::move(candidate); - return; - } - if (better_gpu) { - best_gpu_candidate = std::move(candidate); - return; - } - best_space_candidate = std::move(candidate); - }; - - bool budget_exhausted = false; - for (size_t sort_idx = 0; sort_idx < sort_modes.size() && !budget_exhausted; ++sort_idx) { - for (RectHeuristic rect_heuristic : rect_heuristics) { - if (!consume_combination_budget()) { - budget_exhausted = true; - break; - } - std::vector seed_sprites = sorted_sprites_by_mode[sort_idx]; - int seed_used_w = 0; - int seed_used_h = 0; - if (!pack_compact_maxrects(seed_sprites, seed_width, padding, height_upper_bound, rect_heuristic, seed_used_w, seed_used_h)) { - continue; - } - size_t seed_area = static_cast(seed_used_w) * static_cast(seed_used_h); - LayoutCandidate seed_candidate; - seed_candidate.valid = true; - seed_candidate.area = seed_area; - seed_candidate.w = seed_used_w; - seed_candidate.h = seed_used_h; - seed_candidate.sprites = std::move(seed_sprites); - consider_candidate(std::move(seed_candidate)); - } - } - - if (!best_gpu_candidate.valid && !best_space_candidate.valid) { - std::cerr << "Error: failed to compute compact layout\n"; - return 1; - } - - // Guided compact search (no brute-force width scan): - // start from fast/seed anchors, then probe a small nearby window. - int fast_target_width = max_width; - if (total_area > 0) { - long double area_root = std::sqrt(static_cast(total_area)); - if (area_root <= static_cast(std::numeric_limits::max())) { - int candidate = static_cast(std::ceil(area_root)); - if (candidate > fast_target_width) { - fast_target_width = candidate; - } - } - } - if (fast_target_width > width_upper_bound) { - fast_target_width = width_upper_bound; - } - if (fast_target_width < max_width) { - fast_target_width = max_width; - } - - std::unordered_set seen_widths; - std::vector width_candidates; - auto add_width_candidate = [&](int width) { - if (width < max_width || width > width_upper_bound) { - return; - } - if (seen_widths.insert(width).second) { - width_candidates.push_back(width); - } - }; - - add_width_candidate(seed_width); - add_width_candidate(fast_target_width); - if (have_layout_seed) { - int seed_hint_width = seed_cache.atlas_width; - if (padding > seed_cache.padding) { - int expanded = 0; - if (checked_add_int(seed_hint_width, padding - seed_cache.padding, expanded)) { - seed_hint_width = expanded; - } - } - add_width_candidate(seed_hint_width); - } - - const int range = std::max(1, width_upper_bound - max_width); - const int step = std::max(8, range / 24); - const std::array offsets = { - 0, -1, 1, -2, 2, -4, 4, -8, 8, -12, 12 - }; - const std::array anchor_widths = {seed_width, fast_target_width, max_width}; - for (int anchor : anchor_widths) { - for (int mul : offsets) { - const long long width_ll = - static_cast(anchor) + - static_cast(mul) * static_cast(step); - if (width_ll < static_cast(std::numeric_limits::min()) || - width_ll > static_cast(std::numeric_limits::max())) { - continue; - } - add_width_candidate(static_cast(width_ll)); - } - } - std::sort(width_candidates.begin(), width_candidates.end()); - - const std::array guided_sort_indices = {2, 0, 1}; // Height, Area, MaxSide - const std::array guided_heuristics = { - RectHeuristic::BestShortSideFit, - RectHeuristic::BestAreaFit - }; - - if (!budget_exhausted && !width_candidates.empty()) { - worker_count = std::min(worker_count, static_cast(width_candidates.size())); - std::vector worker_gpu(worker_count); - std::vector worker_space(worker_count); - std::vector workers; - workers.reserve(worker_count); - for (unsigned int worker_index = 0; worker_index < worker_count; ++worker_index) { - workers.emplace_back([&, worker_index]() { - const size_t begin = (width_candidates.size() * worker_index) / worker_count; - const size_t end = (width_candidates.size() * (worker_index + 1)) / worker_count; - - LayoutCandidate local_best_gpu; - LayoutCandidate local_best_space; - auto consider_local = [&](LayoutCandidate&& candidate) { - if (!candidate.valid || candidate.w <= 0 || candidate.h <= 0) { - return; - } - const bool better_gpu = - !local_best_gpu.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - local_best_gpu.area, local_best_gpu.w, local_best_gpu.h, - OptimizeTarget::GPU); - const bool better_space = - !local_best_space.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - local_best_space.area, local_best_space.w, local_best_space.h, - OptimizeTarget::SPACE); - if (!better_gpu && !better_space) { - return; - } - if (better_gpu && better_space) { - local_best_gpu = candidate; - local_best_space = std::move(candidate); - return; - } - if (better_gpu) { - local_best_gpu = std::move(candidate); - return; - } - local_best_space = std::move(candidate); - }; - - bool local_budget_exhausted = false; - for (size_t width_index = begin; width_index < end && !local_budget_exhausted; ++width_index) { - const int width = width_candidates[width_index]; - for (size_t sort_idx : guided_sort_indices) { - if (local_budget_exhausted) { - break; - } - for (RectHeuristic rect_heuristic : guided_heuristics) { - if (!consume_combination_budget()) { - local_budget_exhausted = true; - break; - } - std::vector trial_sprites = sorted_sprites_by_mode[sort_idx]; - int used_w = 0; - int used_h = 0; - if (!pack_compact_maxrects(trial_sprites, width, padding, height_upper_bound, rect_heuristic, used_w, used_h)) { - continue; - } - size_t area = static_cast(used_w) * static_cast(used_h); - LayoutCandidate candidate; - candidate.valid = true; - candidate.area = area; - candidate.w = used_w; - candidate.h = used_h; - candidate.sprites = std::move(trial_sprites); - consider_local(std::move(candidate)); - } - } - } - - worker_gpu[worker_index] = std::move(local_best_gpu); - worker_space[worker_index] = std::move(local_best_space); - }); - } - for (auto& worker : workers) { - worker.join(); - } - for (unsigned int i = 0; i < worker_count; ++i) { - if (worker_gpu[i].valid) { - consider_candidate(std::move(worker_gpu[i])); - } - if (worker_space[i].valid) { - consider_candidate(std::move(worker_space[i])); - } - } - - budget_exhausted = (combination_budget != std::numeric_limits::max()) && - (combinations_tested.load(std::memory_order_relaxed) >= combination_budget); - } - - // Include shelf candidates from same guided widths as a cheap fallback. - if (!budget_exhausted && !width_candidates.empty()) { - worker_count = std::min(worker_count, static_cast(width_candidates.size())); - std::vector worker_gpu(worker_count); - std::vector worker_space(worker_count); - std::vector workers; - workers.reserve(worker_count); - for (unsigned int worker_index = 0; worker_index < worker_count; ++worker_index) { - workers.emplace_back([&, worker_index]() { - const size_t begin = (width_candidates.size() * worker_index) / worker_count; - const size_t end = (width_candidates.size() * (worker_index + 1)) / worker_count; - - LayoutCandidate local_best_gpu; - LayoutCandidate local_best_space; - auto consider_local = [&](LayoutCandidate&& candidate) { - if (!candidate.valid || candidate.w <= 0 || candidate.h <= 0) { - return; - } - const bool better_gpu = - !local_best_gpu.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - local_best_gpu.area, local_best_gpu.w, local_best_gpu.h, - OptimizeTarget::GPU); - const bool better_space = - !local_best_space.valid || - pick_better_layout_candidate( - candidate.area, candidate.w, candidate.h, true, - local_best_space.area, local_best_space.w, local_best_space.h, - OptimizeTarget::SPACE); - if (!better_gpu && !better_space) { - return; - } - if (better_gpu && better_space) { - local_best_gpu = candidate; - local_best_space = std::move(candidate); - return; - } - if (better_gpu) { - local_best_gpu = std::move(candidate); - return; - } - local_best_space = std::move(candidate); - }; - - bool local_budget_exhausted = false; - for (size_t width_index = begin; width_index < end && !local_budget_exhausted; ++width_index) { - const int width = width_candidates[width_index]; - for (size_t sort_idx : guided_sort_indices) { - if (!consume_combination_budget()) { - local_budget_exhausted = true; - break; - } - std::vector shelf_sprites = sorted_sprites_by_mode[sort_idx]; - int shelf_w = 0; - int shelf_h = 0; - if (!pack_fast_shelf(shelf_sprites, width, padding, shelf_w, shelf_h)) { - continue; - } - if (shelf_h > height_upper_bound) { - continue; - } - size_t shelf_area = static_cast(shelf_w) * static_cast(shelf_h); - LayoutCandidate shelf_candidate; - shelf_candidate.valid = true; - shelf_candidate.area = shelf_area; - shelf_candidate.w = shelf_w; - shelf_candidate.h = shelf_h; - shelf_candidate.sprites = std::move(shelf_sprites); - consider_local(std::move(shelf_candidate)); - } - } - - worker_gpu[worker_index] = std::move(local_best_gpu); - worker_space[worker_index] = std::move(local_best_space); - }); - } - for (auto& worker : workers) { - worker.join(); - } - for (unsigned int i = 0; i < worker_count; ++i) { - if (worker_gpu[i].valid) { - consider_candidate(std::move(worker_gpu[i])); - } - if (worker_space[i].valid) { - consider_candidate(std::move(worker_space[i])); - } - } - } - - const LayoutCandidate* selected_candidate = nullptr; - if (optimize_target == OptimizeTarget::GPU) { - selected_candidate = best_gpu_candidate.valid ? &best_gpu_candidate : &best_space_candidate; - } else { - selected_candidate = best_space_candidate.valid ? &best_space_candidate : &best_gpu_candidate; - } - if (!selected_candidate || !selected_candidate->valid) { - std::cerr << "Error: failed to compute compact layout\n"; - return 1; - } - - sprites = selected_candidate->sprites; - atlas_width = selected_candidate->w; - atlas_height = selected_candidate->h; - - if (best_gpu_candidate.valid && best_space_candidate.valid) { - for (const char* profile_name : k_compact_prewarm_profiles) { - auto prewarm_it = profile_map.find(profile_name); - if (prewarm_it == profile_map.end()) { - continue; - } - const ProfileDefinition& compact_profile = prewarm_it->second; - const Mode prewarm_mode = - has_mode_override ? mode_override : compact_profile.mode; - const OptimizeTarget prewarm_optimize_target = - has_optimize_override ? optimize_override : compact_profile.optimize_target; - if (prewarm_mode != Mode::COMPACT) { - continue; - } - const int prewarm_max_width = - has_max_width_limit - ? max_width_limit - : (compact_profile.max_width ? *compact_profile.max_width : 0); - const int prewarm_max_height = - has_max_height_limit - ? max_height_limit - : (compact_profile.max_height ? *compact_profile.max_height : 0); - const int prewarm_padding = - has_padding_override - ? padding - : (compact_profile.padding ? *compact_profile.padding : 0); - const int prewarm_max_combinations = - has_max_combinations_override - ? max_combinations - : (compact_profile.max_combinations ? *compact_profile.max_combinations : 0); - const double prewarm_scale = - has_scale_override - ? scale - : (compact_profile.scale ? *compact_profile.scale : 1.0); - const bool prewarm_trim_transparent = - has_trim_override - ? trim_transparent - : (compact_profile.trim_transparent ? *compact_profile.trim_transparent : false); - const std::string prewarm_signature = build_layout_signature( - compact_profile.name, - prewarm_mode, - prewarm_optimize_target, - prewarm_max_width, - prewarm_max_height, - prewarm_padding, - prewarm_max_combinations, - prewarm_scale, - prewarm_trim_transparent, - is_file, - sources - ); - if (prewarm_signature == layout_signature) { - continue; - } - - const LayoutCandidate& prewarm_candidate = - prewarm_optimize_target == OptimizeTarget::GPU - ? best_gpu_candidate - : best_space_candidate; - const std::string prewarm_output = build_layout_output_text( - prewarm_candidate.w, - prewarm_candidate.h, - prewarm_scale, - prewarm_trim_transparent, - prewarm_candidate.sprites - ); - save_output_cache( - build_output_cache_path(cache_path, prewarm_signature), - prewarm_signature, - prewarm_output - ); - } - } - } else { - int target_width = max_width; - if (total_area > 0) { - long double area_root = std::sqrt(static_cast(total_area)); - if (area_root > static_cast(std::numeric_limits::max())) { - std::cerr << "Error: fast width is too large\n"; - return 1; - } - int candidate = static_cast(std::ceil(area_root)); - if (candidate > target_width) { - target_width = candidate; - } - } - if (target_width > width_upper_bound) { - target_width = width_upper_bound; - } - if (have_layout_seed) { - int seed_hint_width = seed_cache.atlas_width; - if (padding > seed_cache.padding) { - int expanded = 0; - if (checked_add_int(seed_hint_width, padding - seed_cache.padding, expanded)) { - seed_hint_width = expanded; - } - } - if (seed_hint_width > target_width && seed_hint_width <= width_upper_bound) { - target_width = seed_hint_width; - } - } - - std::vector sorted_sprites = sprites; - sort_sprites_by_mode(sorted_sprites, SortMode::Height); - - bool packed = false; - for (int width = target_width; width <= width_upper_bound; ++width) { - std::vector trial_sprites = sorted_sprites; - int packed_width = 0; - int packed_height = 0; - if (!pack_fast_shelf(trial_sprites, width, padding, packed_width, packed_height)) { - continue; - } - if (packed_height > height_upper_bound) { - continue; - } - sprites = std::move(trial_sprites); - atlas_width = packed_width; - atlas_height = packed_height; - packed = true; - break; - } - if (!packed) { - std::cerr << "Error: failed to compute fast layout\n"; - return 1; - } - } - } - - if (padding > 0) { - if (!compute_tight_atlas_bounds(sprites, atlas_width, atlas_height)) { - std::cerr << "Error: failed to compute final atlas bounds\n"; - return 1; - } - } - - LayoutSeedCache next_seed; - next_seed.signature = layout_seed_signature; - next_seed.padding = padding; - next_seed.atlas_width = atlas_width; - next_seed.atlas_height = atlas_height; - next_seed.entries.reserve(sprites.size()); - for (const auto& s : sprites) { - LayoutSeedEntry entry; - entry.path = s.path; - entry.x = s.x; - entry.y = s.y; - entry.w = s.w; - entry.h = s.h; - entry.trim_left = s.trim_left; - entry.trim_top = s.trim_top; - entry.trim_right = s.trim_right; - entry.trim_bottom = s.trim_bottom; - next_seed.entries.push_back(std::move(entry)); - } - save_layout_seed_cache(seed_cache_path, next_seed); - - const std::string output_text = build_layout_output_text( - atlas_width, - atlas_height, - scale, - trim_transparent, - sprites - ); - std::cout << output_text; - save_output_cache(output_cache_path, layout_signature, output_text); - prune_cache_family(cache_path, k_cache_max_age_seconds, k_cache_max_layout_files, k_cache_max_seed_files); - - return 0; -} diff --git a/spratpack.cpp b/spratpack.cpp deleted file mode 100644 index 16295fa..0000000 --- a/spratpack.cpp +++ /dev/null @@ -1,686 +0,0 @@ -// spratpack.cpp -// MIT License (c) 2026 Pedro -// Compile: g++ -std=c++17 -O2 spratpack.cpp -o spratpack - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#define STB_IMAGE_IMPLEMENTATION -#include "stb_image.h" -#define STB_IMAGE_WRITE_IMPLEMENTATION -#include "stb_image_write.h" - -struct Sprite { - std::string path; - int x = 0; - int y = 0; - int w = 0; - int h = 0; - int src_x = 0; - int src_y = 0; - int trim_right = 0; - int trim_bottom = 0; - bool has_trim = false; -}; - -bool checked_mul_size_t(size_t a, size_t b, size_t& out) { - if (a == 0 || b <= std::numeric_limits::max() / a) { - out = a * b; - return true; - } - return false; -} - -bool checked_add_size_t(size_t a, size_t b, size_t& out) { - if (b <= std::numeric_limits::max() - a) { - out = a + b; - return true; - } - return false; -} - -bool parse_int(const std::string& token, int& out) { - if (token.empty()) { - return false; - } - std::istringstream iss(token); - int value = 0; - char extra = '\0'; - if (!(iss >> value)) { - return false; - } - if (iss >> extra) { - return false; - } - out = value; - return true; -} - -bool parse_double(const std::string& token, double& out) { - if (token.empty()) { - return false; - } - std::istringstream iss(token); - double value = 0.0; - char extra = '\0'; - if (!(iss >> value)) { - return false; - } - if (iss >> extra) { - return false; - } - if (!std::isfinite(value)) { - return false; - } - out = value; - return true; -} - -bool parse_pair(const std::string& token, int& a, int& b) { - size_t comma = token.find(','); - if (comma == std::string::npos || comma == 0 || comma + 1 >= token.size()) { - return false; - } - if (token.find(',', comma + 1) != std::string::npos) { - return false; - } - return parse_int(token.substr(0, comma), a) && parse_int(token.substr(comma + 1), b); -} - -bool parse_quoted(std::string_view input, size_t& pos, std::string& out, std::string& error) { - if (pos >= input.size() || input[pos] != '"') { - error = "expected opening quote for sprite path"; - return false; - } - - ++pos; - out.clear(); - - while (pos < input.size()) { - char c = input[pos++]; - if (c == '\\') { - if (pos >= input.size()) { - error = "unterminated escape sequence in sprite path"; - return false; - } - char escaped = input[pos++]; - if (escaped == '"' || escaped == '\\') { - out.push_back(escaped); - } else { - out.push_back('\\'); - out.push_back(escaped); - } - } else if (c == '"') { - return true; - } else { - out.push_back(c); - } - } - - error = "unterminated quoted sprite path"; - return false; -} - -bool parse_sprite_line(const std::string& line, Sprite& out, std::string& error) { - constexpr std::string_view prefix = "sprite"; - if (line.rfind(prefix, 0) != 0) { - error = "line does not start with sprite"; - return false; - } - - size_t pos = prefix.size(); - while (pos < line.size() && std::isspace(static_cast(line[pos])) != 0) { - ++pos; - } - - std::string path; - if (pos < line.size() && line[pos] == '"') { - if (!parse_quoted(line, pos, path, error)) { - return false; - } - } else { - error = "sprite path must be quoted"; - return false; - } - - while (pos < line.size() && std::isspace(static_cast(line[pos])) != 0) { - ++pos; - } - - Sprite parsed; - parsed.path = path; - std::vector tokens; - std::istringstream tail(line.substr(pos)); - std::string token; - while (tail >> token) { - tokens.push_back(token); - } - - if (tokens.empty()) { - error = "sprite line is missing numeric fields"; - return false; - } - - if (tokens[0].find(',') != std::string::npos) { - // New format: x,y w,h [left,top right,bottom] - if (tokens.size() != 2 && tokens.size() != 4) { - error = "sprite line must contain position/size and optional trim offsets"; - return false; - } - - if (!parse_pair(tokens[0], parsed.x, parsed.y) || - !parse_pair(tokens[1], parsed.w, parsed.h)) { - error = "invalid position or size pair"; - return false; - } - - if (tokens.size() == 4) { - if (!parse_pair(tokens[2], parsed.src_x, parsed.src_y) || - !parse_pair(tokens[3], parsed.trim_right, parsed.trim_bottom)) { - error = "invalid trim offset pair"; - return false; - } - parsed.has_trim = true; - } - } else { - // Legacy format: x y w h [src_x src_y] - if (tokens.size() != 4 && tokens.size() != 6) { - error = "legacy sprite line has invalid field count"; - return false; - } - if (!parse_int(tokens[0], parsed.x) || - !parse_int(tokens[1], parsed.y) || - !parse_int(tokens[2], parsed.w) || - !parse_int(tokens[3], parsed.h)) { - error = "legacy sprite line has invalid numeric fields"; - return false; - } - if (tokens.size() == 6) { - if (!parse_int(tokens[4], parsed.src_x) || - !parse_int(tokens[5], parsed.src_y)) { - error = "legacy sprite line has invalid crop offsets"; - return false; - } - parsed.has_trim = true; - } - } - - out = parsed; - return true; -} - -bool parse_atlas_line(const std::string& line, int& width, int& height) { - std::istringstream iss(line); - std::string tag; - std::string size_token; - std::string extra; - - if (!(iss >> tag >> size_token)) { - return false; - } - if (tag != "atlas") { - return false; - } - if (!parse_pair(size_token, width, height)) { - // Backward compatibility: atlas - if (!parse_int(size_token, width) || !(iss >> height)) { - return false; - } - } - if (iss >> extra) { - return false; - } - - return true; -} - -bool parse_scale_line(const std::string& line, double& scale) { - std::istringstream iss(line); - std::string tag; - std::string value_token; - std::string extra; - - if (!(iss >> tag >> value_token)) { - return false; - } - if (tag != "scale") { - return false; - } - if (!parse_double(value_token, scale) || scale <= 0.0) { - return false; - } - if (iss >> extra) { - return false; - } - return true; -} - -bool parse_line_color(const std::string& value, std::array& out) { - std::array parts = {0, 0, 0, 255}; - int part_count = 0; - size_t start = 0; - while (start <= value.size()) { - size_t comma = value.find(',', start); - size_t end = (comma == std::string::npos) ? value.size() : comma; - if (end == start || part_count >= 4) { - return false; - } - - std::string token = value.substr(start, end - start); - int channel = 0; - if (!parse_int(token, channel) || channel < 0 || channel > 255) { - return false; - } - parts[part_count++] = channel; - - if (comma == std::string::npos) { - break; - } - start = comma + 1; - } - - if (part_count != 3 && part_count != 4) { - return false; - } - - out[0] = static_cast(parts[0]); - out[1] = static_cast(parts[1]); - out[2] = static_cast(parts[2]); - out[3] = static_cast(parts[3]); - return true; -} - -void draw_sprite_outline( - std::vector& atlas, - int atlas_width, - int atlas_height, - const Sprite& s, - int line_width, - const std::array& color -) { - if (line_width <= 0) { - return; - } - - auto set_pixel = [&](int px, int py) { - if (px < 0 || py < 0 || px >= atlas_width || py >= atlas_height) { - return; - } - size_t pixel_index = static_cast(py) * static_cast(atlas_width) + static_cast(px); - size_t offset = pixel_index * static_cast(4); - atlas[offset + 0] = color[0]; - atlas[offset + 1] = color[1]; - atlas[offset + 2] = color[2]; - atlas[offset + 3] = color[3]; - }; - - int max_t = std::min(line_width, std::min(s.w, s.h)); - for (int t = 0; t < max_t; ++t) { - int left = s.x + t; - int right = s.x + s.w - 1 - t; - int top = s.y + t; - int bottom = s.y + s.h - 1 - t; - - for (int x = left; x <= right; ++x) { - set_pixel(x, top); - set_pixel(x, bottom); - } - for (int y = top; y <= bottom; ++y) { - set_pixel(left, y); - set_pixel(right, y); - } - } -} - -bool rectangles_overlap(const Sprite& a, const Sprite& b) { - const int a_right = a.x + a.w; - const int a_bottom = a.y + a.h; - const int b_right = b.x + b.w; - const int b_bottom = b.y + b.h; - return !(a_right <= b.x || b_right <= a.x || a_bottom <= b.y || b_bottom <= a.y); -} - -bool sprites_have_overlap(const std::vector& sprites) { - if (sprites.size() < 2) { - return false; - } - std::vector order(sprites.size()); - for (size_t i = 0; i < sprites.size(); ++i) { - order[i] = i; - } - std::sort(order.begin(), order.end(), [&](size_t lhs, size_t rhs) { - if (sprites[lhs].x != sprites[rhs].x) return sprites[lhs].x < sprites[rhs].x; - return sprites[lhs].y < sprites[rhs].y; - }); - - for (size_t i = 0; i < order.size(); ++i) { - const Sprite& a = sprites[order[i]]; - const int a_right = a.x + a.w; - for (size_t j = i + 1; j < order.size(); ++j) { - const Sprite& b = sprites[order[j]]; - if (b.x >= a_right) { - break; - } - if (rectangles_overlap(a, b)) { - return true; - } - } - } - return false; -} - -int main(int argc, char** argv) { - bool draw_frame_lines = false; - int line_width = 1; - std::array line_color = {255, 0, 0, 255}; - unsigned int thread_limit = 0; - - for (int i = 1; i < argc; ++i) { - std::string arg = argv[i]; - if (arg == "--frame-lines") { - draw_frame_lines = true; - } else if (arg == "--line-width" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_int(value, line_width) || line_width <= 0) { - std::cerr << "Invalid line width: " << value << "\n"; - return 1; - } - } else if (arg == "--line-color" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_line_color(value, line_color)) { - std::cerr << "Invalid line color: " << value << "\n"; - std::cerr << "Expected format: R,G,B or R,G,B,A with 0-255 channels\n"; - return 1; - } - } else if (arg == "--threads" && i + 1 < argc) { - std::string value = argv[++i]; - int parsed = 0; - if (!parse_int(value, parsed) || parsed <= 0) { - std::cerr << "Invalid thread count: " << value << "\n"; - return 1; - } - thread_limit = static_cast(parsed); - } else { - std::cerr << "Usage: spratpack [--frame-lines] [--line-width N] [--line-color R,G,B[,A]] [--threads N]\n"; - return 1; - } - } - - std::string line; - int atlas_width = 0; - int atlas_height = 0; - double layout_scale = 1.0; - bool has_scale = false; - std::vector sprites; - - while (std::getline(std::cin, line)) { - if (line.empty()) { - continue; - } - if (line.rfind("atlas", 0) == 0) { - if (!parse_atlas_line(line, atlas_width, atlas_height)) { - std::cerr << "Invalid atlas line: " << line << "\n"; - return 1; - } - } else if (line.rfind("scale", 0) == 0) { - if (has_scale) { - std::cerr << "Duplicate scale line\n"; - return 1; - } - if (!parse_scale_line(line, layout_scale)) { - std::cerr << "Invalid scale line: " << line << "\n"; - return 1; - } - has_scale = true; - } else if (line.rfind("sprite", 0) == 0) { - Sprite s; - std::string error; - if (!parse_sprite_line(line, s, error)) { - std::cerr << "Invalid sprite line: " << error << "\n"; - return 1; - } - sprites.push_back(s); - } else { - std::cerr << "Unknown line: " << line << "\n"; - return 1; - } - } - - if (atlas_width <= 0 || atlas_height <= 0) { - std::cerr << "Invalid atlas size\n"; - return 1; - } - - size_t pixel_count = 0; - size_t byte_count = 0; - if (!checked_mul_size_t(static_cast(atlas_width), static_cast(atlas_height), pixel_count) || - !checked_mul_size_t(pixel_count, static_cast(4), byte_count)) { - std::cerr << "Atlas size is too large\n"; - return 1; - } - - std::vector atlas(byte_count, 0); - - for (const auto& s : sprites) { - if (s.x < 0 || s.y < 0 || s.w <= 0 || s.h <= 0 || s.src_x < 0 || s.src_y < 0 || - s.trim_right < 0 || s.trim_bottom < 0) { - std::cerr << "Invalid sprite bounds: " << s.path << "\n"; - return 1; - } - if (s.w > atlas_width || s.h > atlas_height || - s.x > atlas_width - s.w || s.y > atlas_height - s.h) { - std::cerr << "Sprite out of atlas bounds: " << s.path << "\n"; - return 1; - } - } - - auto blit_sprite = [&](const Sprite& s, std::string& error_out) -> bool { - int w = 0; - int h = 0; - int channels = 0; - unsigned char* data = stbi_load(s.path.c_str(), &w, &h, &channels, 4); - if (!data) { - error_out = "Failed to load: " + s.path; - return false; - } - int source_x = s.has_trim ? s.src_x : 0; - int source_y = s.has_trim ? s.src_y : 0; - int source_w = s.has_trim ? (w - s.src_x - s.trim_right) : w; - int source_h = s.has_trim ? (h - s.src_y - s.trim_bottom) : h; - - if (source_x < 0 || source_y < 0 || source_w <= 0 || source_h <= 0) { - error_out = "Crop out of bounds: " + s.path; - stbi_image_free(data); - return false; - } - if (source_x > w - source_w || source_y > h - source_h) { - error_out = "Trim offsets out of bounds: " + s.path; - stbi_image_free(data); - return false; - } - - size_t scaled_source_w = static_cast(s.w); - size_t scaled_source_h = static_cast(s.h); - if (layout_scale > 0.0 && layout_scale != 1.0) { - // Validate that scaled destination dimensions are plausible for the - // declared source crop rectangle to guard malformed inputs. - if (source_w == 0 || source_h == 0) { - error_out = "Invalid scaled source crop: " + s.path; - stbi_image_free(data); - return false; - } - } - - if (scaled_source_w == 0 || scaled_source_h == 0) { - error_out = "Invalid destination sprite size: " + s.path; - stbi_image_free(data); - return false; - } - - size_t source_pixels = 0; - size_t source_bytes = 0; - if (!checked_mul_size_t(static_cast(w), static_cast(h), source_pixels) || - !checked_mul_size_t(source_pixels, static_cast(4), source_bytes)) { - error_out = "Source image is too large: " + s.path; - stbi_image_free(data); - return false; - } - - const bool copy_rows_direct = (source_w == s.w && source_h == s.h); - if (copy_rows_direct) { - const size_t row_bytes = static_cast(s.w) * static_cast(4); - for (int row = 0; row < s.h; ++row) { - size_t dest_pixels = 0; - size_t dest_offset = 0; - if (!checked_mul_size_t(static_cast(s.y + row), static_cast(atlas_width), dest_pixels) || - !checked_add_size_t(dest_pixels, static_cast(s.x), dest_pixels) || - !checked_mul_size_t(dest_pixels, static_cast(4), dest_offset) || - dest_offset > atlas.size() || - row_bytes > atlas.size() - dest_offset) { - error_out = "Atlas indexing out of bounds: " + s.path; - stbi_image_free(data); - return false; - } - - size_t src_pixels = 0; - size_t src_offset = 0; - if (!checked_mul_size_t(static_cast(source_y + row), static_cast(w), src_pixels) || - !checked_add_size_t(src_pixels, static_cast(source_x), src_pixels) || - !checked_mul_size_t(src_pixels, static_cast(4), src_offset) || - src_offset > source_bytes || - row_bytes > source_bytes - src_offset) { - error_out = "Source indexing overflow: " + s.path; - stbi_image_free(data); - return false; - } - std::memcpy(atlas.data() + dest_offset, data + src_offset, row_bytes); - } - } else { - for (int row = 0; row < s.h; ++row) { - int sample_y = source_y + (row * source_h) / s.h; - for (int col = 0; col < s.w; ++col) { - int sample_x = source_x + (col * source_w) / s.w; - - size_t dest_pixels = 0; - size_t dest_offset = 0; - if (!checked_mul_size_t(static_cast(s.y + row), static_cast(atlas_width), dest_pixels)) { - error_out = "Atlas indexing overflow: " + s.path; - stbi_image_free(data); - return false; - } - dest_pixels += static_cast(s.x + col); - if (!checked_mul_size_t(dest_pixels, static_cast(4), dest_offset) || - dest_offset > atlas.size() || - static_cast(4) > atlas.size() - dest_offset) { - error_out = "Atlas indexing out of bounds: " + s.path; - stbi_image_free(data); - return false; - } - - size_t src_pixels = 0; - size_t src_offset = 0; - if (!checked_mul_size_t(static_cast(sample_y), static_cast(w), src_pixels) || - !checked_add_size_t(src_pixels, static_cast(sample_x), src_pixels) || - !checked_mul_size_t(src_pixels, static_cast(4), src_offset) || - src_offset > source_bytes || - static_cast(4) > source_bytes - src_offset) { - error_out = "Source indexing overflow: " + s.path; - stbi_image_free(data); - return false; - } - - atlas[dest_offset + 0] = data[src_offset + 0]; - atlas[dest_offset + 1] = data[src_offset + 1]; - atlas[dest_offset + 2] = data[src_offset + 2]; - atlas[dest_offset + 3] = data[src_offset + 3]; - } - } - } - - stbi_image_free(data); - return true; - }; - - unsigned int worker_count = thread_limit > 0 ? thread_limit : std::thread::hardware_concurrency(); - if (worker_count == 0) { - worker_count = 1; - } - worker_count = std::min(worker_count, static_cast(std::max(1, sprites.size()))); - - const bool can_parallel_blit = (worker_count > 1) && !sprites_have_overlap(sprites); - if (!can_parallel_blit) { - for (const auto& s : sprites) { - std::string error; - if (!blit_sprite(s, error)) { - std::cerr << error << "\n"; - return 1; - } - } - } else { - std::atomic next_index{0}; - std::atomic failed{false}; - std::mutex error_mutex; - std::string first_error; - std::vector workers; - workers.reserve(worker_count); - for (unsigned int i = 0; i < worker_count; ++i) { - workers.emplace_back([&]() { - while (!failed.load(std::memory_order_relaxed)) { - size_t idx = next_index.fetch_add(1, std::memory_order_relaxed); - if (idx >= sprites.size()) { - break; - } - std::string error; - if (!blit_sprite(sprites[idx], error)) { - { - std::lock_guard lock(error_mutex); - if (first_error.empty()) { - first_error = std::move(error); - } - } - failed.store(true, std::memory_order_relaxed); - break; - } - } - }); - } - for (auto& worker : workers) { - worker.join(); - } - if (failed.load(std::memory_order_relaxed)) { - std::cerr << (first_error.empty() ? "Failed to process sprite" : first_error) << "\n"; - return 1; - } - } - - if (draw_frame_lines) { - for (const auto& s : sprites) { - draw_sprite_outline(atlas, atlas_width, atlas_height, s, line_width, line_color); - } - } - - auto write_callback = [](void* context, void* data, int size) { - std::ostream* out = static_cast(context); - out->write(static_cast(data), size); - }; - - if (!stbi_write_png_to_func(write_callback, &std::cout, - atlas_width, atlas_height, 4, - atlas.data(), atlas_width * 4)) { - std::cerr << "Failed to write PNG\n"; - return 1; - } - - return 0; -} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt deleted file mode 100644 index 625f3ab..0000000 --- a/tests/CMakeLists.txt +++ /dev/null @@ -1,12 +0,0 @@ -add_test( - NAME pipeline - COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/pipeline_test.sh - $ - $ -) - -add_test( - NAME convert - COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/convert_test.sh - $ -) diff --git a/tests/convert_test.sh b/tests/convert_test.sh deleted file mode 100755 index 2e8770c..0000000 --- a/tests/convert_test.sh +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [ "$#" -ne 1 ]; then - echo "Usage: convert_test.sh " >&2 - exit 1 -fi - -convert_bin="$1" -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT - -layout_file="$tmp_dir/layout.txt" -cat > "$layout_file" <<'LAYOUT' -atlas 64,32 -scale 1 -sprite "./frames/a.png" 0,0 16,16 -sprite "./frames/b.png" 16,0 8,8 1,2 3,4 -LAYOUT - -"$convert_bin" --list-transforms > "$tmp_dir/list.txt" -for fmt in json csv xml css; do - if ! grep -q "^${fmt}\b" "$tmp_dir/list.txt"; then - echo "Missing transform in list: $fmt" >&2 - exit 1 - fi -done - -"$convert_bin" --transform json < "$layout_file" > "$tmp_dir/out.json" -grep -q '"atlas": {"width": 64, "height": 32}' "$tmp_dir/out.json" -grep -q '"path": "./frames/b.png"' "$tmp_dir/out.json" - -"$convert_bin" --transform csv < "$layout_file" > "$tmp_dir/out.csv" -grep -q '^index,name,path,x,y,w,h,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json$' "$tmp_dir/out.csv" -grep -q '^1,b,./frames/b.png,16,0,8,8,1,2,3,4,0,\[\]$' "$tmp_dir/out.csv" - -"$convert_bin" --transform xml < "$layout_file" > "$tmp_dir/out.xml" -grep -q '^$' "$tmp_dir/out.xml" -grep -q 'trim_left="1" trim_top="2" trim_right="3" trim_bottom="4"' "$tmp_dir/out.xml" - -"$convert_bin" --transform css < "$layout_file" > "$tmp_dir/out.css" -grep -Fq '.sprite-1 {' "$tmp_dir/out.css" -grep -q '^ background-position: -16px -0px;$' "$tmp_dir/out.css" - -custom_transform="$tmp_dir/custom.transform" -cat > "$custom_transform" <<'CUSTOM' -[meta] -name=custom -[/meta] - -[header] -BEGIN {{atlas_width}}x{{atlas_height}} count={{sprite_count}} -[/header] - -[sprites] - [sprite] -{{index}}|{{path}}|{{x}},{{y}} {{w}}x{{h}} - [/sprite] -[/sprites] - -[separator] -; -[/separator] - -[footer] - -END -[/footer] -CUSTOM - -"$convert_bin" --transform "$custom_transform" < "$layout_file" > "$tmp_dir/out.custom" -grep -q '^BEGIN 64x32 count=2' "$tmp_dir/out.custom" -grep -q '1|./frames/b.png|16,0 8x8' "$tmp_dir/out.custom" - -markers_file="$tmp_dir/markers.json" -cat > "$markers_file" <<'MARKERS' -{ - "sprites": { - "./frames/a.png": {"markers": [{"name": "hit", "type": "point", "x": 3, "y": 5}, {"name": "hurt", "type": "circle", "x": 6, "y": 7, "radius": 4}]}, - "b": {"markers": [{"name": "foot", "type": "rectangle", "x": 1, "y": 2, "w": 3, "h": 4}]} - } -} -MARKERS - -animations_file="$tmp_dir/animations.json" -cat > "$animations_file" <<'ANIMS' -{ - "timelines": [ - {"name": "run", "frames": ["./frames/a.png", "b"]}, - {"name": "idle", "sprite_indexes": [1]} - ] -} -ANIMS - -extras_transform="$tmp_dir/extras.transform" -cat > "$extras_transform" <<'EXTRAS' -[meta] -name=extras -[/meta] - -[header] -markers={{has_markers}} animations={{has_animations}} -markers_path={{markers_path}} -animations_path={{animations_path}} -marker_count={{marker_count}} -animation_count={{animation_count}} - -[/header] - -[sprites] - [sprite] -{{index}}|{{name}}|{{path}}|{{sprite_markers_count}}|{{sprite_markers_json}} - [/sprite] -[/sprites] -EXTRAS - -"$convert_bin" --transform "$extras_transform" --markers "$markers_file" --animations "$animations_file" < "$layout_file" > "$tmp_dir/out.extras" -grep -q '^markers=true animations=true$' "$tmp_dir/out.extras" -grep -q "^markers_path=$markers_file$" "$tmp_dir/out.extras" -grep -q "^animations_path=$animations_file$" "$tmp_dir/out.extras" -grep -q '^marker_count=3$' "$tmp_dir/out.extras" -grep -q '^animation_count=2$' "$tmp_dir/out.extras" -grep -Fq '0|a|./frames/a.png|2|[{"name":"hit","type":"point","x":3,"y":5},{"name":"hurt","type":"circle","x":6,"y":7,"radius":4}]' "$tmp_dir/out.extras" -grep -Fq '1|b|./frames/b.png|1|[{"name":"foot","type":"rectangle","x":1,"y":2,"w":3,"h":4}]' "$tmp_dir/out.extras" - -iter_transform="$tmp_dir/iter.transform" -cat > "$iter_transform" <<'ITER' -[meta] -name=iter -[/meta] - -[header] -BEGIN - -[/header] - -[if_markers] -M_ON - -[/if_markers] - -[markers_header] -M_BEGIN - -[/markers_header] - -[markers] - [marker] -M{{marker_index}}={{marker_name}}@{{marker_sprite_index}}:{{marker_sprite_name}} - - [/marker] -[/markers] - -[markers_separator] -| -[/markers_separator] - -[markers_footer] -M_END - -[/markers_footer] - -[if_no_markers] -M_EMPTY - -[/if_no_markers] - -[sprites] - [sprite] -S{{index}}={{path}} - - [/sprite] -[/sprites] - -[separator] -; - -[/separator] - -[if_animations] -A_ON - -[/if_animations] - -[animations_header] -A_BEGIN - -[/animations_header] - -[animations] - [animation] -A{{animation_index}}={{animation_name}}:[{{animation_sprite_indexes}}] - - [/animation] -[/animations] - -[animations_separator] -| -[/animations_separator] - -[animations_footer] -A_END - -[/animations_footer] - -[if_no_animations] -A_EMPTY - -[/if_no_animations] - -[footer] -END -[/footer] -ITER - -"$convert_bin" --transform "$iter_transform" --markers "$markers_file" --animations "$animations_file" < "$layout_file" > "$tmp_dir/out.iter.full" -grep -q '^M_ON$' "$tmp_dir/out.iter.full" -grep -q '^M_BEGIN$' "$tmp_dir/out.iter.full" -grep -q '^M0=hit@0:a$' "$tmp_dir/out.iter.full" -grep -q '^\|M1=hurt@0:a$' "$tmp_dir/out.iter.full" -grep -q '^\|M2=foot@1:b$' "$tmp_dir/out.iter.full" -grep -q '^A_ON$' "$tmp_dir/out.iter.full" -grep -q '^A_BEGIN$' "$tmp_dir/out.iter.full" -grep -q '^A0=run:\[0,1\]$' "$tmp_dir/out.iter.full" -grep -q '^\|A1=idle:\[1\]$' "$tmp_dir/out.iter.full" -if grep -q '^M_EMPTY$' "$tmp_dir/out.iter.full"; then - echo "unexpected marker-empty branch in full iteration output" >&2 - exit 1 -fi -if grep -q '^A_EMPTY$' "$tmp_dir/out.iter.full"; then - echo "unexpected animation-empty branch in full iteration output" >&2 - exit 1 -fi - -"$convert_bin" --transform "$iter_transform" < "$layout_file" > "$tmp_dir/out.iter.empty" -grep -q '^M_EMPTY$' "$tmp_dir/out.iter.empty" -grep -q '^A_EMPTY$' "$tmp_dir/out.iter.empty" -if grep -q '^M_ON$' "$tmp_dir/out.iter.empty"; then - echo "unexpected marker-present branch in empty iteration output" >&2 - exit 1 -fi -if grep -q '^A_ON$' "$tmp_dir/out.iter.empty"; then - echo "unexpected animation-present branch in empty iteration output" >&2 - exit 1 -fi - -"$convert_bin" --transform json --markers "$markers_file" --animations "$animations_file" < "$layout_file" > "$tmp_dir/out.builtin.json" -python3 -m json.tool "$tmp_dir/out.builtin.json" > /dev/null -grep -q '"animations": \[' "$tmp_dir/out.builtin.json" -grep -q '"sprites": \[' "$tmp_dir/out.builtin.json" -grep -q '"name": "a"' "$tmp_dir/out.builtin.json" -grep -q '"markers": \[{"name":"hit","type":"point","x":3,"y":5},{"name":"hurt","type":"circle","x":6,"y":7,"radius":4}\]' "$tmp_dir/out.builtin.json" -grep -q '"name": "run"' "$tmp_dir/out.builtin.json" -grep -q '"fps": 8' "$tmp_dir/out.builtin.json" -grep -q '"sprite_indexes": \[0,1\]' "$tmp_dir/out.builtin.json" -if grep -q '"index":' "$tmp_dir/out.builtin.json"; then - echo "builtin json transform should not include index fields in sprite/animation objects" >&2 - exit 1 -fi -sprites_line="$(grep -n '"sprites": \[' "$tmp_dir/out.builtin.json" | head -n1 | cut -d: -f1)" -animations_line="$(grep -n '"animations": \[' "$tmp_dir/out.builtin.json" | head -n1 | cut -d: -f1)" -if [ "$animations_line" -le "$sprites_line" ]; then - echo "animations section must be outside and after sprites in json transform" >&2 - exit 1 -fi - -echo "convert_test.sh: ok" diff --git a/tests/pipeline_test.sh b/tests/pipeline_test.sh deleted file mode 100755 index d4bc91e..0000000 --- a/tests/pipeline_test.sh +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [ "$#" -ne 2 ]; then - echo "Usage: pipeline_test.sh " >&2 - exit 1 -fi - -spratlayout_bin="$1" -spratpack_bin="$2" - -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT - -frames_dir="$tmp_dir/frames" -mkdir -p "$frames_dir" - -cat > "$tmp_dir/pixel.b64" <<'EOF' -iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7ZxaoAAAAASUVORK5CYII= -EOF - -base64 -d "$tmp_dir/pixel.b64" > "$frames_dir/frame_a.png" -cp "$frames_dir/frame_a.png" "$frames_dir/frame_b.png" - -# Isolate test from user configuration and provide required profiles -mkdir -p "$tmp_dir/.config/sprat" -cat > "$tmp_dir/.config/sprat/spratprofiles.cfg" < "$layout_file" -"$spratlayout_bin" "$frames_dir" --padding 1 > "$default_layout_file" -"$spratlayout_bin" "$frames_dir" --profiles-config "$tmp_dir/missing.cfg" --padding 1 > "$default_layout_file.with_missing_cfg" -"$spratlayout_bin" "$frames_dir" --profile fast --padding 1 > "$fast_layout_file" -"$spratlayout_bin" "$frames_dir" --profile css --padding 1 > "$css_layout_file" - -if ! cmp -s "$layout_file" "$default_layout_file"; then - echo "Default profile output differs from --profile fast" >&2 - exit 1 -fi - -if ! cmp -s "$default_layout_file" "$default_layout_file.with_missing_cfg"; then - echo "No-profile defaults should not depend on profile config path" >&2 - exit 1 -fi - -if ! grep -q '^atlas [0-9][0-9]*,[0-9][0-9]*$' "$layout_file"; then - echo "Missing or invalid atlas line in layout output" >&2 - exit 1 -fi - -if ! grep -Eq '^scale 1(\.0+)?$' "$layout_file"; then - echo "Missing or invalid default scale line in layout output" >&2 - exit 1 -fi - -if ! grep -q '^atlas [0-9][0-9]*,[0-9][0-9]*$' "$fast_layout_file"; then - echo "Missing or invalid atlas line in fast layout output" >&2 - exit 1 -fi - -if ! grep -Eq '^scale 1(\.0+)?$' "$fast_layout_file"; then - echo "Missing or invalid default scale line in fast layout output" >&2 - exit 1 -fi - -if ! grep -q '^atlas [0-9][0-9]*,[0-9][0-9]*$' "$css_layout_file"; then - echo "Missing or invalid atlas line in css layout output" >&2 - exit 1 -fi - -if ! grep -Eq '^scale 1(\.0+)?$' "$css_layout_file"; then - echo "Missing or invalid default scale line in css layout output" >&2 - exit 1 -fi - -sprite_count="$(grep -c '^sprite "' "$layout_file" || true)" -if [ "$sprite_count" -ne 2 ]; then - echo "Expected 2 sprite lines, got $sprite_count" >&2 - exit 1 -fi - -atlas_dims="$(grep '^atlas ' "$layout_file" | head -n1 | sed -E 's/^atlas ([0-9]+),([0-9]+)$/\1 \2/')" -read -r atlas_w atlas_h <<< "$atlas_dims" -max_xw="$(awk '/^sprite "/ { split($3, p, ","); split($4, s, ","); v = p[1] + s[1]; if (v > m) m = v } END { print m + 0 }' "$layout_file")" -max_yh="$(awk '/^sprite "/ { split($3, p, ","); split($4, s, ","); v = p[2] + s[2]; if (v > m) m = v } END { print m + 0 }' "$layout_file")" -if [ "$max_xw" -ne "$atlas_w" ] || [ "$max_yh" -ne "$atlas_h" ]; then - echo "Atlas dimensions include trailing padding-only gap: atlas=${atlas_w}x${atlas_h} used=${max_xw}x${max_yh}" >&2 - exit 1 -fi - -# POT mode should avoid unnecessary square growth when a smaller POT rectangle fits. -pot_dir="$tmp_dir/pot_frames" -mkdir -p "$pot_dir" -for i in $(seq 1 17); do - cp "$frames_dir/frame_a.png" "$pot_dir/frame_$i.png" -done - -pot_layout="$tmp_dir/pot_layout.txt" -"$spratlayout_bin" "$pot_dir" --profile legacy > "$pot_layout" - -pot_atlas="$(grep '^atlas ' "$pot_layout" | head -n 1 | sed -E 's/^atlas ([0-9]+),([0-9]+)$/\1 \2/')" -read -r pot_w pot_h <<< "$pot_atlas" - -if [ -z "${pot_w:-}" ] || [ -z "${pot_h:-}" ]; then - echo "POT mode did not emit a valid atlas line" >&2 - exit 1 -fi - -if [ "$((pot_w & (pot_w - 1)))" -ne 0 ] || [ "$((pot_h & (pot_h - 1)))" -ne 0 ]; then - echo "POT mode atlas dimensions are not powers of two: ${pot_w}x${pot_h}" >&2 - exit 1 -fi - -pot_area=$((pot_w * pot_h)) -if [ "$pot_area" -gt 32 ]; then - echo "POT mode wasted space for 17 pixels: atlas ${pot_w}x${pot_h} (area ${pot_area})" >&2 - exit 1 -fi - -pot_sprite_count="$(grep -c '^sprite "' "$pot_layout" || true)" -if [ "$pot_sprite_count" -ne 17 ]; then - echo "Expected 17 sprite lines in POT test, got $pot_sprite_count" >&2 - exit 1 -fi - -# In compact mode, GPU optimization should not produce a worse max-side than space optimization. -compact_gpu_layout="$tmp_dir/compact_gpu_layout.txt" -compact_space_layout="$tmp_dir/compact_space_layout.txt" -"$spratlayout_bin" "$pot_dir" --profile desktop > "$compact_gpu_layout" -"$spratlayout_bin" "$pot_dir" --profile space > "$compact_space_layout" - -gpu_dims="$(grep '^atlas ' "$compact_gpu_layout" | head -n 1 | sed -E 's/^atlas ([0-9]+),([0-9]+)$/\1 \2/')" -space_dims="$(grep '^atlas ' "$compact_space_layout" | head -n 1 | sed -E 's/^atlas ([0-9]+),([0-9]+)$/\1 \2/')" -read -r gpu_w gpu_h <<< "$gpu_dims" -read -r space_w space_h <<< "$space_dims" - -gpu_max_side="$gpu_w" -if [ "$gpu_h" -gt "$gpu_max_side" ]; then - gpu_max_side="$gpu_h" -fi -space_max_side="$space_w" -if [ "$space_h" -gt "$space_max_side" ]; then - space_max_side="$space_h" -fi - -if [ "$gpu_max_side" -gt "$space_max_side" ]; then - echo "GPU optimization produced a worse max-side (${gpu_w}x${gpu_h}) than space optimization (${space_w}x${space_h})" >&2 - exit 1 -fi - -# Desktop profile (GPU-oriented) should not be worse than fast profile on max-side. -compact_fast_layout="$tmp_dir/compact_fast_layout.txt" -"$spratlayout_bin" "$pot_dir" --profile fast > "$compact_fast_layout" -fast_dims="$(grep '^atlas ' "$compact_fast_layout" | head -n 1 | sed -E 's/^atlas ([0-9]+),([0-9]+)$/\1 \2/')" -read -r fast_w fast_h <<< "$fast_dims" -fast_max_side="$fast_w" -if [ "$fast_h" -gt "$fast_max_side" ]; then - fast_max_side="$fast_h" -fi - -if [ "$gpu_max_side" -gt "$fast_max_side" ]; then - echo "Desktop profile max-side (${gpu_w}x${gpu_h}) is worse than fast profile (${fast_w}x${fast_h})" >&2 - exit 1 -fi - -# Respect explicit atlas limits in compact mode. -bounded_layout="$tmp_dir/compact_bounded_layout.txt" -"$spratlayout_bin" "$pot_dir" --profile desktop --max-height 4 > "$bounded_layout" -bounded_dims="$(grep '^atlas ' "$bounded_layout" | head -n 1 | sed -E 's/^atlas ([0-9]+),([0-9]+)$/\1 \2/')" -read -r bounded_w bounded_h <<< "$bounded_dims" -if [ "$bounded_h" -gt 4 ]; then - echo "Compact mode exceeded max height limit: ${bounded_w}x${bounded_h}" >&2 - exit 1 -fi - -"$spratpack_bin" < "$layout_file" > "$sheet_file" - -if [ ! -s "$sheet_file" ]; then - echo "Spritesheet output is empty" >&2 - exit 1 -fi - -signature="$(head -c 8 "$sheet_file" | od -An -t x1 | tr -d ' \n')" -if [ "$signature" != "89504e470d0a1a0a" ]; then - echo "Output is not a PNG file" >&2 - exit 1 -fi - -line_sheet="$tmp_dir/spritesheet_lines.png" -"$spratpack_bin" --frame-lines --line-width 1 --line-color 255,0,0 < "$layout_file" > "$line_sheet" -line_signature="$(head -c 8 "$line_sheet" | od -An -t x1 | tr -d ' \n')" -if [ "$line_signature" != "89504e470d0a1a0a" ]; then - echo "Output with frame lines is not a PNG file" >&2 - exit 1 -fi - -# Create a padded image with transparent borders and verify trim output. -trim_dir="$tmp_dir/trim_frames" -mkdir -p "$trim_dir" -trim_source="$trim_dir/frame_trim.png" - -cat > "$tmp_dir/seed_layout.txt" < "$trim_source" - -trim_layout="$tmp_dir/trim_layout.txt" -trim_sheet="$tmp_dir/trim_sheet.png" -"$spratlayout_bin" "$trim_dir" --profile desktop --trim-transparent > "$trim_layout" - -if ! grep -q '^atlas 1,1$' "$trim_layout"; then - echo "Trim mode did not reduce padded image atlas to 1x1" >&2 - exit 1 -fi - -if ! grep -E -q '^sprite ".*frame_trim\.png" [0-9]+,[0-9]+ 1,1 1,1 1,1$' "$trim_layout"; then - echo "Trim mode did not emit expected crop offsets" >&2 - exit 1 -fi - -"$spratpack_bin" < "$trim_layout" > "$trim_sheet" -trim_signature="$(head -c 8 "$trim_sheet" | od -An -t x1 | tr -d ' \n')" -if [ "$trim_signature" != "89504e470d0a1a0a" ]; then - echo "Trim mode output is not a PNG file" >&2 - exit 1 -fi - -# Regression: cache must not freeze padding changes across trim toggles. -layout_trim_p2="$tmp_dir/layout_trim_p2.txt" -layout_notrim_p2="$tmp_dir/layout_notrim_p2.txt" -layout_notrim_p6="$tmp_dir/layout_notrim_p6.txt" -layout_trim_p2_again="$tmp_dir/layout_trim_p2_again.txt" -layout_trim_p6="$tmp_dir/layout_trim_p6.txt" -"$spratlayout_bin" "$frames_dir" --profile desktop --trim-transparent --padding 2 > "$layout_trim_p2" -"$spratlayout_bin" "$frames_dir" --profile desktop --padding 2 > "$layout_notrim_p2" -"$spratlayout_bin" "$frames_dir" --profile desktop --padding 6 > "$layout_notrim_p6" -"$spratlayout_bin" "$frames_dir" --profile desktop --trim-transparent --padding 2 > "$layout_trim_p2_again" -"$spratlayout_bin" "$frames_dir" --profile desktop --trim-transparent --padding 6 > "$layout_trim_p6" - -atlas_notrim_p2="$(grep '^atlas ' "$layout_notrim_p2" | head -n1)" -atlas_notrim_p6="$(grep '^atlas ' "$layout_notrim_p6" | head -n1)" -atlas_trim_p2_again="$(grep '^atlas ' "$layout_trim_p2_again" | head -n1)" -atlas_trim_p6="$(grep '^atlas ' "$layout_trim_p6" | head -n1)" -if [ "$atlas_notrim_p2" = "$atlas_notrim_p6" ]; then - echo "Padding change had no effect after trim toggle in non-trim mode" >&2 - exit 1 -fi -if [ "$atlas_trim_p2_again" = "$atlas_trim_p6" ]; then - echo "Padding change had no effect after trim toggle in trim mode" >&2 - exit 1 -fi - -# Verify explicit layout scale is emitted and spratpack honors scaled dimensions. -scaled_dir="$tmp_dir/scaled_frames" -mkdir -p "$scaled_dir" -scaled_source="$scaled_dir/frame_large.png" -cat > "$tmp_dir/seed_large_layout.txt" < "$scaled_source" - -scaled_layout="$tmp_dir/scaled_layout.txt" -scaled_sheet="$tmp_dir/scaled_sheet.png" -"$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --scale 0.5 > "$scaled_layout" - -if ! grep -Eq '^scale 0\.5[0-9]*$' "$scaled_layout"; then - echo "Scaled layout did not emit expected scale line" >&2 - exit 1 -fi - -if ! grep -q '^atlas 2,2$' "$scaled_layout"; then - echo "Scaled layout did not reduce atlas to 2x2" >&2 - exit 1 -fi - -"$spratpack_bin" < "$scaled_layout" > "$scaled_sheet" -scaled_signature="$(head -c 8 "$scaled_sheet" | od -An -t x1 | tr -d ' \n')" -if [ "$scaled_signature" != "89504e470d0a1a0a" ]; then - echo "Scaled layout output is not a PNG file" >&2 - exit 1 -fi - -# Resolution mapping should combine with scale (effective = scale * target/source). -scaled_resolution_layout="$tmp_dir/scaled_resolution_layout.txt" -"$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4x4 --target-resolution 2x2 > "$scaled_resolution_layout" - -if ! grep -Eq '^scale 0\.5[0-9]*$' "$scaled_resolution_layout"; then - echo "Resolution mapping did not emit expected scale line" >&2 - exit 1 -fi - -if ! grep -q '^atlas 2,2$' "$scaled_resolution_layout"; then - echo "Resolution mapping did not reduce atlas to 2x2" >&2 - exit 1 -fi - -scaled_combined_layout="$tmp_dir/scaled_combined_layout.txt" -"$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4x4 --target-resolution 2x2 --scale 0.5 > "$scaled_combined_layout" - -if ! grep -Eq '^scale 0\.25[0-9]*$' "$scaled_combined_layout"; then - echo "Combined source/target and scale did not emit expected scale line" >&2 - exit 1 -fi - -if ! grep -q '^atlas 1,1$' "$scaled_combined_layout"; then - echo "Combined source/target and scale did not reduce atlas to 1x1" >&2 - exit 1 -fi - -# Validation: source/target must be provided together, format is WxH, and scale must be <= 1. -if "$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4x4 > "$tmp_dir/invalid_source_only.out" 2>&1; then - echo "Expected source-resolution without target-resolution to fail" >&2 - exit 1 -fi - -# Mismatched proportions should use selected reference axis for scaling. -scaled_largest_layout="$tmp_dir/scaled_largest_layout.txt" -"$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4x4 --target-resolution 3x2 > "$scaled_largest_layout" -if ! grep -Eq '^scale 0\.75[0-9]*$' "$scaled_largest_layout"; then - echo "Mismatched proportions did not use expected largest-ratio scale" >&2 - exit 1 -fi - -scaled_smallest_layout="$tmp_dir/scaled_smallest_layout.txt" -"$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4x4 --target-resolution 3x2 --resolution-reference smallest > "$scaled_smallest_layout" -if ! grep -Eq '^scale 0\.5[0-9]*$' "$scaled_smallest_layout"; then - echo "Mismatched proportions did not use expected smallest-ratio scale" >&2 - exit 1 -fi - -if "$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4X4 --target-resolution 2x2 > "$tmp_dir/invalid_resolution_format.out" 2>&1; then - echo "Expected invalid resolution format (must be WxH with lowercase x) to fail" >&2 - exit 1 -fi - -if "$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --source-resolution 4x4 --target-resolution 3x2 --resolution-reference largest --resolution-reference smallest > "$tmp_dir/invalid_resolution_reference_repeat.out" 2>&1; then - echo "Expected repeated --resolution-reference values to fail" >&2 - exit 1 -fi - -if "$spratlayout_bin" "$scaled_dir" --profile desktop --no-trim-transparent --scale 1.1 > "$tmp_dir/invalid_scale.out" 2>&1; then - echo "Expected scale greater than 1 to fail" >&2 - exit 1 -fi diff --git a/transforms/css.transform b/transforms/css.transform deleted file mode 100644 index 394169c..0000000 --- a/transforms/css.transform +++ /dev/null @@ -1,36 +0,0 @@ -[meta] -name=css -description=CSS classes for web sprite rendering -extension=.css -[/meta] - -[header] -:root { - --atlas-width: {{atlas_width}}px; - --atlas-height: {{atlas_height}}px; - --atlas-scale: {{scale}}; -} - -.sprat-sprite { - background-repeat: no-repeat; - display: inline-block; -} -[/header] - -[sprites] - [sprite] -.sprite-{{index}} { - background-position: -{{x}}px -{{y}}px; - width: {{w}}px; - height: {{h}}px; - /* source: {{path_css}} */ - /* name: {{name_css}} */ -} - [/sprite] -[/sprites] - -[separator] -[/separator] - -[footer] -[/footer] diff --git a/transforms/csv.transform b/transforms/csv.transform deleted file mode 100644 index 2ce1b36..0000000 --- a/transforms/csv.transform +++ /dev/null @@ -1,56 +0,0 @@ -[meta] -name=csv -description=CSV rows for spreadsheets and data tools -extension=.csv -[/meta] - -[header] -index,name,path,x,y,w,h,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json - -[/header] - -[sprites] - [sprite] -{{index}},{{name_csv}},{{path_csv}},{{x}},{{y}},{{w}},{{h}},{{trim_left}},{{trim_top}},{{trim_right}},{{trim_bottom}},{{sprite_markers_count}},{{sprite_markers_csv}} - - [/sprite] - -[/sprites] - -[separator] -[/separator] - -[if_markers] -[/if_markers] - -# markers - -[markers] - [marker] -marker,{{marker_index}},{{marker_name_csv}},{{marker_type_csv}},{{marker_x}},{{marker_y}},{{marker_radius}},{{marker_w}},{{marker_h}},{{marker_vertices_csv}},{{marker_sprite_index}},{{marker_sprite_name_csv}},{{marker_sprite_path_csv}} - - [/marker] - -[/markers] - -[markers_separator] -[/markers_separator] - -[if_animations] -[/if_animations] - -# animations - -[animations] - [animation] -animation,{{animation_index}},{{animation_name_csv}},{{fps}},{{animation_sprite_indexes_csv}} - - [/animation] - -[/animations] - -[animations_separator] -[/animations_separator] - -[footer] -[/footer] diff --git a/transforms/json.transform b/transforms/json.transform deleted file mode 100644 index 0b028ff..0000000 --- a/transforms/json.transform +++ /dev/null @@ -1,49 +0,0 @@ -[meta] -name=json -description=JSON metadata for scripting and runtime loading -extension=.json -[/meta] - -[header] -{ - "atlas": {"width": {{atlas_width}}, "height": {{atlas_height}}}, - "scale": {{scale}}, - "sprites": [ -[/header] - -[sprites] - [sprite] - {"name": "{{name_json}}", "path": "{{path_json}}", "x": {{x}}, "y": {{y}}, "w": {{w}}, "h": {{h}}, "trim_left": {{trim_left}}, "trim_top": {{trim_top}}, "trim_right": {{trim_right}}, "trim_bottom": {{trim_bottom}}, "markers": {{sprite_markers_json}}} - [/sprite] -[/sprites] - -[separator] -, -[/separator] - -[if_animations] - ], - "animations": [ -[/if_animations] - -[animations] - [animation] - {"name": "{{animation_name_json}}", "fps": {{fps}}, "sprite_indexes": {{animation_sprite_indexes_json}}} - [/animation] -[/animations] - -[animations_separator] -, -[/animations_separator] - -[animations_footer] - ] -[/animations_footer] - -[if_no_animations] - ] -[/if_no_animations] - -[footer] -} -[/footer] diff --git a/transforms/xml.transform b/transforms/xml.transform deleted file mode 100644 index 6cd28a9..0000000 --- a/transforms/xml.transform +++ /dev/null @@ -1,46 +0,0 @@ -[meta] -name=xml -description=XML layout format for engine import pipelines -extension=.xml -[/meta] - -[header] - - - -[/header] - -[sprites] - [sprite] - - [/sprite] -[/sprites] - -[separator] -[/separator] - -[if_animations] - - -[/if_animations] - -[animations] - [animation] - - [/animation] -[/animations] - -[animations_separator] -[/animations_separator] - -[animations_footer] - -[/animations_footer] - -[if_no_animations] - -[/if_no_animations] - -[footer] - -[/footer]