diff --git a/CMakeLists.txt b/CMakeLists.txt index 0819b14..f49d52c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -245,6 +245,87 @@ if(NOT SQUISH_FOUND) endif() endif() +# Jsonnet dependency +include(FetchContent) +FetchContent_Declare( + jsonnet + GIT_REPOSITORY https://github.com/google/jsonnet.git + GIT_TAG f45e01d632b29e4c0757ec7ba188ce759298e6d3 # v0.20.0 +) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_STATIC_LIBS ON CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +# Jsonnet's CMakeLists.txt uses cmake_minimum_required < 3.5; suppress the error +set(CMAKE_POLICY_VERSION_MINIMUM "3.5" CACHE INTERNAL "") +FetchContent_GetProperties(jsonnet) +if(NOT jsonnet_POPULATED) + # Silence FetchContent_Populate() deprecation warning on CMake >= 3.30. + if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(jsonnet) + if(MSVC) + # jsonnet v0.20.0 rejects non-GCC/Clang compilers with a FATAL_ERROR in + # the else() branch of its compiler-flags check (CMakeLists.txt:43). + # Downgrade it to STATUS so MSVC can configure and build with defaults. + file(READ "${jsonnet_SOURCE_DIR}/CMakeLists.txt" _jcml) + string(REGEX REPLACE + "message[(]FATAL_ERROR \"Compiler [^\"]+not supported\"[)]" + "message(STATUS \"MSVC detected; using default compiler flags\")" + _jcml "${_jcml}") + # jsonnet hardcodes CMAKE_CXX_STANDARD 11 unconditionally, which maps to + # no /std:c++ flag on MSVC (MSVC has no /std:c++11). Its headers use + # nested namespaces (C++17), so raise the standard to 17. + string(REPLACE + "set(CMAKE_CXX_STANDARD 11)" + "set(CMAKE_CXX_STANDARD 17)" + _jcml "${_jcml}") + file(WRITE "${jsonnet_SOURCE_DIR}/CMakeLists.txt" "${_jcml}") + + # jsonnet's stdlib/CMakeLists.txt invokes to_c_array via + # ${GLOBAL_OUTPUT_PATH}/to_c_array, which is wrong for MSVC multi-config + # generators: those place the executable in a per-config subdirectory + # (e.g. Release/) that the plain path doesn't include. + # Replace it with $, a generator expression that + # resolves to the correct full path (config subdir + .exe) at build time. + file(READ "${jsonnet_SOURCE_DIR}/stdlib/CMakeLists.txt" _jstdlib) + string(REGEX REPLACE + "\\$\\{GLOBAL_OUTPUT_PATH\\}/to_c_array" + "$" + _jstdlib "${_jstdlib}") + file(WRITE "${jsonnet_SOURCE_DIR}/stdlib/CMakeLists.txt" "${_jstdlib}") + endif() + add_subdirectory("${jsonnet_SOURCE_DIR}" "${jsonnet_BINARY_DIR}") +endif() + +# jsonnet v0.20.0 builds libjsonnet++ (shared) without declaring its dependency +# on libjsonnet, causing undefined-symbol errors on macOS where dylibs must have +# all symbols resolved at link time. Patch the target here when it is shared. +if(TARGET libjsonnet++ AND TARGET libjsonnet) + get_target_property(_ljnpp_type libjsonnet++ TYPE) + if(_ljnpp_type STREQUAL "SHARED_LIBRARY") + target_link_libraries(libjsonnet++ PRIVATE libjsonnet) + endif() +endif() + +# Suppress common MSVC warnings in jsonnet targets (written for GCC/Clang). +if(MSVC) + foreach(_jt libjsonnet_static libjsonnet++_static) + if(TARGET ${_jt}) + target_compile_options(${_jt} PRIVATE + /wd4100 # unreferenced formal parameter + /wd4127 # conditional expression is constant + /wd4244 # conversion, possible loss of data + /wd4267 # conversion from size_t + /wd4702 # unreachable code + /wd4706 # assignment within conditional expression + /wd4996 # deprecated CRT function + ) + target_compile_definitions(${_jt} PRIVATE _CRT_SECURE_NO_WARNINGS) + endif() + endforeach() +endif() + # Target definitions add_library(spratcore STATIC src/core/cli_parse.cpp @@ -263,6 +344,7 @@ target_include_directories(spratcore PUBLIC src) target_include_directories(spratcore SYSTEM PRIVATE ${STB_DIR}) target_include_directories(spratcore PRIVATE ${LIBARCHIVE_INCLUDE_DIRS}) target_link_libraries(spratcore PRIVATE ${LIBARCHIVE_LIBRARIES}) +target_link_libraries(spratcore PRIVATE libjsonnet++_static libjsonnet_static) if(SPRAT_GETTEXT_AVAILABLE) target_compile_definitions(spratcore PUBLIC SPRAT_ENABLE_GETTEXT) target_link_libraries(spratcore PUBLIC ${SPRAT_INTL_LIBRARIES}) @@ -311,6 +393,28 @@ target_compile_definitions(spratlayout PRIVATE # Keep a default profile config beside built binaries (e.g. build-win/) so # spratlayout can resolve it via exe-directory lookup without extra setup. +# Use a single custom target for the transforms copy to prevent parallel copy races +# when multiple executables complete simultaneously in a parallel build. +# NOTE: Use a static path instead of $ to avoid a +# cyclic dependency (sprat_copy_transforms -> spratlayout -> sprat_copy_transforms). +# On multi-config generators (MSVC, Xcode) binaries land in a per-config subdir +# (e.g. build/Release/), so append /$ to match $. +if(CMAKE_RUNTIME_OUTPUT_DIRECTORY) + set(_sprat_bin_dir ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) +else() + set(_sprat_bin_dir ${CMAKE_CURRENT_BINARY_DIR}) +endif() +get_property(_sprat_multi_config GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(_sprat_multi_config) + string(APPEND _sprat_bin_dir "/$") +endif() +add_custom_target(sprat_copy_transforms ALL + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/transforms + ${_sprat_bin_dir}/transforms + COMMENT "Copying transforms/ to ${_sprat_bin_dir}" + VERBATIM) + foreach(target spratlayout spratpack spratconvert spratframes spratunpack) add_custom_command(TARGET ${target} POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different @@ -318,12 +422,7 @@ foreach(target spratlayout spratpack spratconvert spratframes spratunpack) $/spratprofiles.cfg COMMENT "Copying spratprofiles.cfg to $" VERBATIM) - add_custom_command(TARGET ${target} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory - ${CMAKE_CURRENT_SOURCE_DIR}/transforms - $/transforms - COMMENT "Copying transforms/ to $" - VERBATIM) + add_dependencies(${target} sprat_copy_transforms) if(WIN32) if(SPRAT_STATIC) diff --git a/COMPACT_PERFORMANCE.md b/COMPACT_PERFORMANCE.md new file mode 100644 index 0000000..a2dd14c --- /dev/null +++ b/COMPACT_PERFORMANCE.md @@ -0,0 +1,53 @@ +● Based on the analysis, here's what the situation looks like and where there's room to improve: + + Already parallelized: + The guided and shelf passes both fan out across threads, dividing width candidates into ranges. So the outer search loop + is already threaded. + + The real bottlenecks: + + 1. Static range division → load imbalance + The current pattern divides [0, width_candidates.size()) evenly across threads. But different widths take different + amounts of time (a narrow atlas packs quickly; a near-square one generates many free-rect fragments and runs longer). A + slow thread stalls the join. Replacing the static split with an atomic work index (each thread atomically claims the + next unclaimed width) would eliminate this with minimal code change. + + 2. Two separate thread fan-out/join cycles + Guided and shelf are sequentially phased — spawn threads, join, spawn again, join. Merging them into one pass (each + worker does both MaxRects and shelf for its assigned widths) halves the thread lifecycle overhead and improves cache + locality since the same width's sprite list is touched once. + + 3. O(n²) free-rect pruning inside pack_compact_maxrects + Every time a sprite is placed, prune_free_rects does an O(n²) containment scan to discard dominated rectangles. For + large sprite sets the free-rect list can grow significantly. This is the core per-packing cost and runs entirely on a + single thread — no parallelism is possible inside it without a fundamentally different data structure (e.g. a spatial + index). This is the hardest win but potentially the biggest one. + + 4. No pruning across (sort × heuristic) combos within a width + For a given width, all 12 combinations (4 sorts × 3 heuristics) are always attempted. If the first combo already + produces a result that can't beat the global best, the remaining 11 could be skipped. The candidate-level pruning exists + before entering the width, but not within it. + + 5. Candidate count + ~20–60 candidates × 16 packing attempts each. The offset step grid ({0, ±1, ±2, ±4, ±8, ±12, ±16, ±20} × 4 anchors) is + generous. With a warm seed cache the search could be narrowed to a smaller window around the known-good width rather + than the full grid. + + Summary by effort vs. impact: + + ┌────────────────────────────────────────────────────┬────────┬───────────────────────────────────────┐ + │ Change │ Effort │ Impact │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Atomic work index (dynamic scheduling) │ Low │ Medium — eliminates load imbalance │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Merge guided + shelf into one pass │ Low │ Small — saves two thread cycles │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Per-width early exit across (sort × heuristic) │ Medium │ Small-medium │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Narrower candidate window when seed is warm │ Medium │ Medium — fewer packing runs │ + ├────────────────────────────────────────────────────┼────────┼───────────────────────────────────────┤ + │ Replace O(n²) free-rect pruning with spatial index │ High │ Potentially large for big sprite sets │ + └────────────────────────────────────────────────────┴────────┴───────────────────────────────────────┘ + + The atomic work index is the most straightforward improvement given the existing code structure — want me to implement + it? diff --git a/README.md b/README.md index 7d2acb2..fb47ff6 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,11 @@ Profiles are named rule sets that group packing options (mode, padding, scale, e Profile definitions are searched in: 1. `--profiles-config PATH` (CLI override) -2. User config: - - Linux/macOS: `~/.config/sprat/spratprofiles.cfg` +2. `{exe_dir}/spratprofiles.cfg` (beside the executable, portable install) +3. User config: + - Linux: `$XDG_CONFIG_HOME/sprat/spratprofiles.cfg` (default `~/.config/sprat/`) + - macOS: `~/Library/Application Support/sprat/spratprofiles.cfg` - Windows: `%APPDATA%\sprat\spratprofiles.cfg` -3. `./spratprofiles.cfg` (current working directory) 4. `/usr/local/share/sprat/spratprofiles.cfg` (Global) ### Spratlayout Options @@ -228,6 +229,7 @@ Profile definitions are searched in: - `--scale F`: Pre-scale images (0.0 to 1.0). - `--threads N`: Parallelize the packing search. - `--debug`: Enable detailed error reporting and debug visualization. +- Directory inputs honor `.spratlayoutignore`; list files may include `exclude "path"` entries. ### Layout Caching `spratlayout` automatically caches image metadata in the system temp directory. If your source images haven't changed, subsequent runs will be nearly instantaneous. Entries older than one hour are pruned automatically. @@ -268,7 +270,7 @@ The layout file will contain multiple `atlas` lines. `spratpack` can then genera ```sh # Output atlas_0.png, atlas_1.png, etc. -./build/spratpack -o atlas_%d.png < layout.txt +./build/spratpack -a atlas_%d.png < layout.txt ``` You can also extract a specific atlas index: @@ -455,7 +457,7 @@ Example layout line: ## 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. +Transforms are [Jsonnet](https://jsonnet.org/) files that receive the full layout data and produce any text format for your game engine or pipeline. List built-in transforms: @@ -469,10 +471,10 @@ Use a built-in transform: ./build/spratconvert --transform json < layout.txt > layout.json ``` -If your template uses `{{atlas_path}}`/`{{atlas_index}}`, provide `--output` so paths are deterministic: +Provide `--atlas` so atlas paths are deterministic in multi-atlas layouts: ```sh -./build/spratconvert --transform json --output atlas_%d.png < layout.txt > layout.json +./build/spratconvert --transform json --atlas atlas_%d.png < layout.txt > layout.json ``` ### Automatic Animations @@ -482,10 +484,11 @@ Automatically group sprites into animations based on their filenames (e.g., `her ``` ### Pivot Points -You can define pivot points (anchors) for your sprites using markers. +Define pivot points (anchors) for sprites using markers. 1. **Per-sprite pivot**: Add a marker named `pivot` of type `point` to a specific sprite. -2. **Global pivot**: Add a marker named `pivot` of type `point` without a `path` (or at the top level). -`spratconvert` will automatically populate `{{pivot_x}}` and `{{pivot_y}}` placeholders using these markers. +2. **Global pivot**: Add a marker named `pivot` of type `point` without a `path`. + +`spratconvert` resolves pivot positions and exposes them as `pivot_x`, `pivot_y`, `pivot_x_norm`, `pivot_y_norm`, and `pivot_y_norm_raw` on each sprite object. Example `markers.txt`: ```txt @@ -503,58 +506,159 @@ Optional extra data files: ./build/spratconvert --transform json --markers markers.txt --animations animations.txt < 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. You can use explicit open/close tags (e.g., `[meta]` ... `[/meta]`) or the modern line-based DSL (e.g., `meta`, `header`, `sprites`, `- sprite`). - -- `[meta]` / `meta`: metadata like `name`, `description`, `extension` -- `[header]` / `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]` / `sprites`: container with `[sprite]` / `- sprite` item template repeated for each sprite (required) -- `[separator]` / `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]` / `footer`: printed once after sprites - -Common placeholders: - -- `{{atlas_width}}`, `{{atlas_height}}`, `{{scale}}`, `{{sprite_count}}` -- `{{index}}`, `{{name}}`, `{{path}}`, `{{x}}`, `{{y}}`, `{{w}}`, `{{h}}` -- `{{pivot_x}}`, `{{pivot_y}}` (resolved from "pivot" markers) -- `{{src_x}}`, `{{src_y}}`, `{{trim_left}}`, `{{trim_top}}`, `{{trim_right}}`, `{{trim_bottom}}` -- `{{rotation}}` (numeric degrees; `0` when unrotated, `90` when rotated clockwise; built-in transforms use this field) -- `[rotated]...[/rotated]` sections inside sprite templates emit their contents only for rotated sprites; non-rotated sprites have the block removed automatically. -- `{{rotated}}` (`true` when the sprite was packed with 90-degree rotation, otherwise `false`; available for custom templates) -- You can also guard sections by `type` attributes (for example `[markers type="json"]` or `[marker type="circle"]`) to emit format-specific or marker-type-specific content. Non-matching blocks are dropped automatically. -- 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}}` - -Typed placeholders (`*_json`, `*_xml`, `*_csv`, `*_css`) are the explicit format-safe form and should be preferred. -Unsuffixed placeholders (for example `{{name}}`, `{{marker_name}}`, `{{marker_vertices}}`) are auto-encoded using `meta.extension` (fallback: transform name/argument) when the output format is JSON/XML/CSV/CSS. - -Sprite names default to the source file basename without extension (for example `./frames/run_01.png` becomes `run_01`). +### Transform search paths + +Transform files are searched in: +1. `{exe_dir}/transforms/` (beside the executable, portable install) +2. User data dir: + - Linux: `$XDG_DATA_HOME/sprat/transforms/` (default `~/.local/share/sprat/transforms/`) + - macOS: `~/Library/Application Support/sprat/transforms/` + - Windows: `%APPDATA%\sprat\transforms\` +3. `/usr/local/share/sprat/transforms/` (Global) + +### Built-in transforms + +| Name | Output | Notes | +|------|--------|-------| +| `json` | JSON | Generic metadata: sprites, atlases, animations, markers | +| `csv` | CSV | Flat spreadsheet-friendly list | +| `xml` | XML | Generic XML | +| `css` | CSS | CSS sprite sheet | +| `aseprite` | JSON | Aseprite JSON Array format; frameTags built from animations (non-contiguous animations become multiple tags) | +| `libgdx` | Text | LibGDX Atlas format; handles multipack | +| `godot` | JSON | Godot SpriteFrames resource | +| `phaser-hash` | JSON | Phaser 3 hash-keyed sprite sheet | +| `phaser-array` | JSON | Phaser 3 array-keyed sprite sheet | +| `phaser-anims` | JSON | Phaser 3 animation config | +| `plist` | plist | Apple / TexturePacker plist | +| `unity` | Group | `unity.json` + `unity.meta` + one `unity.anim` per animation; requires `--output-dir` | + +### Transform format + +Each transform is a Jsonnet file that evaluates to a JSON object: + +- `name` — display name +- `description` — shown by `--list-transforms` +- `extension` — output file extension (e.g. `".json"`) +- `content` — string output for a single file +- `files` — array of `{filename, content}` for multi-file output; mutually exclusive with `content`, requires `--output-dir` + +The layout data is available as `std.extVar("sprat")`: + +```jsonnet +local sprat = std.extVar("sprat"); +``` + +**Global fields:** + +| Field | Type | Description | +|---|---|---| +| `sprites` | array | All sprites across all atlases | +| `atlases` | array | Each entry has `index`, `width`, `height`, `path`, `sprites` | +| `animations` | array | Animation definitions | +| `markers` | array | All markers across all sprites | +| `atlas_width`, `atlas_height` | number | First atlas dimensions | +| `atlas_path`, `atlas_stem` | string | First atlas path and stem | +| `atlas_count` | number | Total atlas count | +| `multipack` | boolean | `true` when layout declares `multipack true` | +| `scale`, `extrude` | number | Layout-level values | +| `has_animations`, `has_markers` | boolean | Whether extra files were loaded | +| `animation_count`, `marker_count`, `sprite_count` | number | Counts | +| `output_stem`, `output_stem_hash_hex` | string | Output stem and its FNV-1a hex hash | +| `animations_path`, `markers_path` | string | Paths to the extra files | + +**Per sprite (`sprites[]`):** + +| Field | Description | +|---|---| +| `index`, `name`, `path`, `atlas_index` | Identity | +| `x`, `y`, `w`, `h` | Packed rectangle in the atlas | +| `content_w`, `content_h` | Dimensions accounting for rotation | +| `source_w`, `source_h` | Original size including trim margins | +| `trim_left`, `trim_top`, `trim_right`, `trim_bottom`, `has_trim` | Trim margins | +| `rotated` | `true` when packed rotated 90° clockwise | +| `unity_y` | `atlas_height - y - h` (Y-up coordinate for Unity) | +| `pivot_x`, `pivot_y` | Pivot in pixels from marker lookup (0 if not set) | +| `pivot_x_norm`, `pivot_y_norm` | Normalized; `pivot_y_norm` is Y-up (Unity convention) | +| `pivot_y_norm_raw` | Normalized Y-down | +| `name_hash_hex` | 16-char FNV-1a hex string | +| `name_hash_decimal` | FNV-1a as a decimal string (serialized as JSON string to avoid float precision loss) | +| `name_css` | CSS-safe identifier | +| `markers` | Array of marker objects attached to this sprite | + +**Per animation (`animations[]`):** + +| Field | Description | +|---|---| +| `index`, `name`, `fps`, `duration` | Identity and timing | +| `frame_indices` | Global sprite index sequence (may be non-contiguous) | +| `frames` | `[{index, name, name_hash_decimal, name_hash_hex}]` resolved per frame | +| `is_alias`, `alias_source`, `flip` | Alias support | + +**Per marker (`markers[]` and `sprite.markers[]`):** + +| Field | Description | +|---|---| +| `index`, `name`, `type` | Identity; `type` is `point`, `circle`, `rectangle`, or `polygon` | +| `x`, `y`, `radius`, `w`, `h`, `vertices` | Geometry (fields present depend on type) | +| `sprite_index`, `sprite_name`, `sprite_path` | Owning sprite | + +### Shared helpers (`sprat.libsonnet`) + +All transforms in the transforms directory can import shared helpers: + +```jsonnet +local lib = import "sprat.libsonnet"; +``` + +- `lib.format_double(v)` — formats a float like C's `%.8g` (works around a known Jsonnet v0.20 `%g` bug) +- `lib.consecutive_runs(indices)` — splits an index array into contiguous ranges `[{from, to}]`; used by the Aseprite transform to build frameTags from non-contiguous animations + +### Custom transforms + +A transform is any `.jsonnet` file. Pass a path directly to `--transform`: + +```jsonnet +local sprat = std.extVar("sprat"); +{ + name: "compact-log", + description: "One line per sprite", + extension: ".txt", + content: + "atlas=%dx%d sprites=%d\n" % [sprat.atlas_width, sprat.atlas_height, sprat.sprite_count] + + std.join("\n", [ + "%d %s @ %d,%d %dx%d" % [s.index, s.path, s.x, s.y, s.w, s.h] + for s in sprat.sprites + ]) + "\n", +} +``` + +```sh +./build/spratconvert --transform ./my.jsonnet < layout.txt > output.txt +``` + +Multi-file output — return `files` instead of `content` and use `--output-dir`: + +```jsonnet +local sprat = std.extVar("sprat"); +{ + name: "one-per-anim", + extension: ".txt", + files: [ + { filename: anim.name + ".txt", content: anim.name + ": " + anim.fps + "fps\n" } + for anim in sprat.animations + ], +} +``` + +```sh +./build/spratconvert --transform ./per-anim.jsonnet --output-dir ./out < layout.txt +``` + +Sprite names default to the source file basename without extension (e.g. `./frames/run_01.png` becomes `run_01`). `--markers` expects a plaintext file using the `path` and `- marker` DSL. +An optional `root` directive sets a base directory; `path` values that are relative are resolved against it. Supported marker types: - `point`: `x,y` - `circle`: `x,y radius` @@ -563,7 +667,8 @@ Supported marker types: Example `markers.txt`: ```txt -path "./frames/a.png" +root "./frames" +path "a.png" - marker "hit" point 3,5 - marker "hurt" circle 6,7 4 path "b" @@ -571,50 +676,19 @@ path "b" ``` `--animations` expects a plaintext file using the `animation` and `- frame` DSL. Frame entries are resolved to sprite indexes by path, name, or index. +An optional `root` directive sets a base directory; quoted frame paths that are relative are resolved against it. Example `animations.txt`: ```txt +root "./frames" fps 12 animation "run" 8 -- frame "./frames/a.png" +- frame "a.png" - frame "b" animation "idle" - frame 1 ``` -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 -./build/spratconvert --transform ./my.transform < layout.txt > layout.custom.txt -``` - Column meanings for the `sprite` line in trim mode: - `""`: source image path. diff --git a/VERSION b/VERSION index 600e6fd..bf057db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.3.3 +v0.10.0 diff --git a/build_test/spratprofiles.cfg b/build_test/spratprofiles.cfg new file mode 100644 index 0000000..abcb2fb --- /dev/null +++ b/build_test/spratprofiles.cfg @@ -0,0 +1,53 @@ +# Default spratlayout profiles. + +[profile desktop] +label=Desktop +mode=compact +optimize=gpu +max_width=8192 +max_height=8192 +padding=2 +extrude=0 +trim_transparent=true + +[profile mobile] +label=Mobile +mode=compact +optimize=gpu +max_width=2048 +max_height=2048 +padding=2 +extrude=0 +trim_transparent=true + +[profile legacy] +label=Legacy (POT) +mode=pot +optimize=gpu +max_width=2048 +max_height=2048 +padding=1 +extrude=0 +trim_transparent=true + +[profile space] +label=Space Efficient +mode=compact +optimize=space +padding=0 +rotate=true +trim_transparent=true + +[profile fast] +label=Fast +mode=fast +optimize=gpu +padding=0 +trim_transparent=false + +[profile css] +label=CSS Sprites +mode=compact +optimize=space +padding=0 +trim_transparent=false diff --git a/build_test/tests/core_test b/build_test/tests/core_test new file mode 100755 index 0000000..01e4825 Binary files /dev/null and b/build_test/tests/core_test differ diff --git a/build_test/tests/layout_test b/build_test/tests/layout_test new file mode 100755 index 0000000..2c8132e Binary files /dev/null and b/build_test/tests/layout_test differ diff --git a/transforms/css.transform b/build_test/transforms/css.transform similarity index 88% rename from transforms/css.transform rename to build_test/transforms/css.transform index 9275f9f..a78305f 100644 --- a/transforms/css.transform +++ b/build_test/transforms/css.transform @@ -1,5 +1,5 @@ [meta] -name=css +name=CSS description=CSS classes for web sprite rendering extension=.css [/meta] @@ -19,10 +19,10 @@ extension=.css [sprites] [sprite] -.sprite-{{index}} { - [atlas_path] +.sprite-{{name_css}} { + [if atlas_path!=""] background-image: url('{{atlas_path}}'); - [/atlas_path] + [/if] background-position: -{{x}}px -{{y}}px; width: {{w}}px; height: {{h}}px; @@ -30,10 +30,10 @@ extension=.css /* source: {{path}} */ /* name: {{name}} */ /* atlas_index: {{atlas_index}} */ - [rotated] + [if rotated="true"] transform: rotate(90deg); transform-origin: top left; - [/rotated] + [/if] } [/sprite] [/sprites] diff --git a/transforms/csv.transform b/build_test/transforms/csv.transform similarity index 80% rename from transforms/csv.transform rename to build_test/transforms/csv.transform index 3456d9d..44e26a0 100644 --- a/transforms/csv.transform +++ b/build_test/transforms/csv.transform @@ -1,5 +1,5 @@ [meta] -name=csv +name=CSV description=CSV rows for spreadsheets and data tools extension=.csv [/meta] @@ -14,40 +14,24 @@ index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_to {{index}},{{name}},{{path}},{{atlas_index}},{{atlas_path}},{{x}},{{y}},{{w}},{{h}},{{pivot_x}},{{pivot_y}},{{trim_left}},{{trim_top}},{{trim_right}},{{trim_bottom}},{{sprite_markers_count}},{{markers_json}},{{rotation}} [/sprite] - [/sprites] [separator] [/separator] -[if_markers] -[/if_markers] - -# markers - [markers] [marker] marker,{{marker_index}},{{marker_name}},{{marker_type}},{{marker_x}},{{marker_y}},{{marker_radius}},{{marker_w}},{{marker_h}},{{marker_vertices}},{{marker_sprite_index}},{{marker_sprite_name}},{{marker_sprite_path}} - [/marker] - [/markers] [markers_separator] [/markers_separator] -[if_animations] -[/if_animations] - -# animations - [animations] [animation] -animation,{{animation_index}},{{animation_name}},{{fps}},{{sprite_indexes}} - +[if is_alias="false"]animation,{{animation_index}},{{animation_name}},{{fps}},{{sprite_indexes}}[/if][if is_alias="true"]animation,{{animation_index}},{{animation_name}},alias,{{animation_alias}}[/if][if flip!=""],{{flip}}[/if] [/animation] - - [/animations] [animations_separator] diff --git a/transforms/json.transform b/build_test/transforms/json.transform similarity index 67% rename from transforms/json.transform rename to build_test/transforms/json.transform index 1cfc48c..09e202a 100644 --- a/transforms/json.transform +++ b/build_test/transforms/json.transform @@ -1,5 +1,5 @@ [meta] -name=json +name=JSON description=JSON metadata for scripting and runtime loading extension=.json [/meta] @@ -22,10 +22,7 @@ extension=.json { "width": {{atlas_width}}, "height": {{atlas_height}}, - "path": "{{atlas_path}}", - "sprites": [ - {{sprites}} - ] + "path": "{{atlas_path}}" } [/atlas] @@ -36,13 +33,16 @@ extension=.json [atlas_footer] + ], + "sprites": [ + {{sprites}} ] [/atlas_footer] [/atlases] [sprites] [sprite] -{"name": "{{name}}", "path": "{{path}}", "atlas_index": {{atlas_index}}, "x": {{x}}, "y": {{y}}, "w": {{w}}, "h": {{h}}, "pivot_x": {{pivot_x}}, "pivot_y": {{pivot_y}}, "trim_left": {{trim_left}}, "trim_top": {{trim_top}}, "trim_right": {{trim_right}}, "trim_bottom": {{trim_bottom}}, "markers": [{{sprite_markers}}], "rotation": {{rotation}}} +{"name": "{{name}}", "path": "{{path}}", "atlas_index": {{atlas_index}}, "rect": {"x": {{x}}, "y": {{y}}, "w": {{w}}, "h": {{h}}}, "pivot": {"x": {{pivot_x}}, "y": {{pivot_y}}}, "trim": {"left": {{trim_left}}, "top": {{trim_top}}, "right": {{trim_right}}, "bottom": {{trim_bottom}}}, "markers": [{{sprite_markers}}], "rotation": {{rotation}}} [/sprite] [/sprites] @@ -62,14 +62,14 @@ extension=.json [if_markers] [/if_markers] -[if_animations] +[if has_animations="true"] , "animations": [ -[/if_animations] +[/if] [animations] [animation] - {"name": "{{animation_name}}", "fps": {{fps}}, "sprite_indexes": {{sprite_indexes}}} + {"name": "{{animation_name}}"[if is_alias="false"], "fps": {{fps}}, "sprite_indexes": {{sprite_indexes}}, "sprite_names": {{sprite_names}}[/if][if is_alias="true"], "alias": "{{animation_alias}}"[/if][if flip!=""], "flip": "{{flip}}"[/if]} [/animation] [/animations] @@ -81,8 +81,8 @@ extension=.json ] [/animations_footer] -[if_no_animations] -[/if_no_animations] +[if has_animations="false"] +[/if] [footer] } diff --git a/transforms/xml.transform b/build_test/transforms/xml.transform similarity index 81% rename from transforms/xml.transform rename to build_test/transforms/xml.transform index 8222768..4d7262e 100644 --- a/transforms/xml.transform +++ b/build_test/transforms/xml.transform @@ -1,5 +1,5 @@ [meta] -name=xml +name=XML description=XML layout format for engine import pipelines extension=.xml [/meta] @@ -36,10 +36,11 @@ extension=.xml [sprites] [sprite] - +[if sprite_markers_count!="0"] {{sprite_markers}} +[/if] [/sprite] @@ -60,13 +61,13 @@ extension=.xml [if_markers] [/if_markers] -[if_animations] +[if has_animations="true"] -[/if_animations] +[/if] [animations] [animation] - + [if is_alias="false"][/if][if is_alias="true"][/if] [/animation] [/animations] diff --git a/man/sprat-cli.1 b/man/sprat-cli.1 index 27f32bd..562b56f 100644 --- a/man/sprat-cli.1 +++ b/man/sprat-cli.1 @@ -1,18 +1,17 @@ -.TH SPRAT-CLI 1 "March 2026" "sprat-cli" "User Commands" +.TH SPRAT-CLI 1 "May 2026" "sprat-cli" "User Commands" .SH NAME sprat-cli \- sprite atlas layout and packing pipeline .SH SYNOPSIS .B spratlayout -.I folder +.I folder|file|- [\fB\-\-profile\fR \fINAME\fR] [\fB\-\-profiles\-config\fR \fIPATH\fR] -[\fB\-\-mode\fR compact|pot|fast] +[\fB\-\-mode\fR compact|pot|fast|grid] [\fB\-\-optimize\fR gpu|space] [\fB\-\-max\-width\fR \fIN\fR] [\fB\-\-max\-height\fR \fIN\fR] [\fB\-\-padding\fR \fIN\fR] [\fB\-\-extrude\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] @@ -25,7 +24,7 @@ sprat-cli \- sprite atlas layout and packing pipeline [\fB\-\-debug\fR] .PP .B spratpack -[\fB\-o\fR \fIPATTERN\fR] +[\fB\-a\fR \fIPATTERN\fR] [\fB\-\-atlas\-index\fR \fIN\fR] [\fB\-\-extrude\fR \fIN\fR] [\fB\-\-protect\fR] @@ -38,7 +37,8 @@ sprat-cli \- sprite atlas layout and packing pipeline .PP .B spratconvert [\fB\-\-transform\fR \fINAME|PATH\fR] -[\fB\-\-output\fR \fIPATTERN\fR] +[\fB\-\-atlas\fR \fIPATTERN\fR] +[\fB\-\-output\-dir\fR \fIPATH\fR] [\fB\-\-list\-transforms\fR] [\fB\-\-markers\fR \fIPATH\fR] [\fB\-\-animations\fR \fIPATH\fR] @@ -62,20 +62,22 @@ sprat-cli \- sprite atlas layout and packing pipeline .SH DESCRIPTION \fBsprat\-cli\fR is a UNIX pipeline for generating sprite sheets and transforming layout metadata. .PP -\fBspratlayout\fR scans an image folder/list/tar input and writes a text layout to standard output. +\fBspratlayout\fR scans an image folder, list file, or TAR stream from stdin and writes a text layout to standard output. .PP -\fBspratpack\fR reads the layout from standard input and writes one or more PNG atlases. +\fBspratpack\fR reads the layout from standard input and writes one or more PNG atlases to standard output (or to files when \fB\-a\fR is used). .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. +\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, writing the result to standard output. .PP \fBspratframes\fR detects sprite rectangles in a sprite sheet and writes them to standard output in the spratframes format. .PP -\fBspratunpack\fR extracts sprites from an atlas using a frames definition file. If no output directory is specified, it writes a TAR stream to stdout. +\fBspratunpack\fR extracts sprites from an atlas using a frames definition file. +Atlas input is a file path, \fB\-\fR for an explicit stdin read, or standard input when no atlas path is given. +If \fB\-\-output\fR is not specified, extracted sprites are written as a TAR stream to standard output. .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: +Reads image metadata and prints layout lines to stdout: .PP atlas \fIwidth,height\fR .br @@ -87,7 +89,13 @@ If \fB\-\-trim\-transparent\fR is enabled, trim offsets are included per sprite. .PP If rotation is used for a sprite, the sprite line includes the \fBrotated\fR token. .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. +Input modes: +.IP "\(bu" 2 +\fBfolder\fR \(em scans all images in the directory. +.IP "\(bu" 2 +\fBfile\fR \(em treated as a newline-delimited list of image paths when the argument points to a text file (comments starting with \fB#\fR and blank lines are ignored). Relative paths are resolved relative to the list file; each path must point to an existing image or the command fails. +.IP "\(bu" 2 +\fB\-\fR \(em reads a TAR archive from standard input, extracts it to a temporary directory, and processes the contained images. Useful for piping the output of \fBspratunpack\fR (which writes a TAR stream) directly back into \fBspratlayout\fR. .PP Multipack: .IP "\(bu" 2 @@ -97,9 +105,11 @@ 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 (Linux/macOS) or \fB%APPDATA%\\sprat\\spratprofiles.cfg\fR (Windows) +\fBspratprofiles.cfg\fR beside the executable (portable install) .IP "\(bu" 2 -\fB./spratprofiles.cfg\fR in the current working directory +\fB$XDG_CONFIG_HOME/sprat/spratprofiles.cfg\fR (Linux, default \fB~/.config/sprat/\fR), +\fB~/Library/Application Support/sprat/spratprofiles.cfg\fR (macOS), or +\fB%APPDATA%\\sprat\\spratprofiles.cfg\fR (Windows) .IP "\(bu" 2 Global installed config (typically \fB/usr/local/share/sprat/spratprofiles.cfg\fR) .PP @@ -131,7 +141,7 @@ By default, writes a single PNG to stdout. .IP "\(bu" 2 If multiple atlases are produced and output is stdout, result is written as a TAR stream. .IP "\(bu" 2 -If \fB\-o\fR (or \fB\-\-output\fR) is used with a pattern like \fBatlas_%d.png\fR, multiple atlases are written to files. +If \fB\-a\fR (or \fB\-\-atlas\fR) is used with a pattern like \fBatlas_%d.png\fR, multiple atlases are written to files. .IP "\(bu" 2 If \fB\-\-atlas\-index\fR is used, only the specified atlas index is processed and written to stdout. .SS spratconvert @@ -139,7 +149,8 @@ Reads layout text from stdin and writes transformed text to stdout. .PP Unsuffixed placeholders are auto-encoded based on output type inferred from transform metadata (\fBmeta.extension\fR) or transform name/argument. .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. +Transforms can be selected by name (for example \fBjson\fR, \fBcsv\fR, \fBxml\fR, \fBcss\fR) or by path to a custom \fB.transform\fR file. +Transform files are searched in the directory beside the executable, the user data directory, and the global install directory. .PP Optional \fB\-\-markers\fR and \fB\-\-animations\fR files can be loaded and referenced by transform placeholders. .PP @@ -157,24 +168,64 @@ The core structure is defined by iteration blocks: .PP 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. .PP +A general conditional syntax \fB[if ATTR="VALUE"]...[/if]\fR (or \fB[if ATTR!="VALUE"]...[/if]\fR) is also supported. At the section level it maps \fBhas_markers\fR and \fBhas_animations\fR to the corresponding conditional blocks. Within section content it can test any current rendering variable (for example \fB[if marker_type="point"]...[/if]\fR). +.PP Placeholders: .IP "\(bu" 2 \fBSprite fields\fR: \fB{{name}}\fR (basename without extension), \fB{{path}}\fR, \fB{{x}}\fR, \fB{{y}}\fR, \fB{{w}}\fR, \fB{{h}}\fR. .IP "\(bu" 2 -\fBPivot points\fR: \fB{{pivot_x}}\fR, \fB{{pivot_y}}\fR. These are resolved from markers named \fBpivot\fR of type \fBpoint\fR (either per-sprite or global). +\fBUnity Y-coordinate\fR: \fB{{unity_y}}\fR \(em Y-coordinate flipped for Unity's bottom-left origin (\fBatlas_height\fR - \fBy\fR - \fBh\fR). +.IP "\(bu" 2 +\fBSource size\fR: \fB{{source_w}}\fR, \fB{{source_h}}\fR \(em original sprite dimensions before trimming (\fBw\fR + \fBtrim_left\fR + \fBtrim_right\fR, and \fBh\fR + \fBtrim_top\fR + \fBtrim_bottom\fR). Required by formats such as Phaser Atlas JSON and Unity TexturePacker JSON. +.IP "\(bu" 2 +\fBTrim fields\fR: \fB{{trim_left}}\fR, \fB{{trim_top}}\fR, \fB{{trim_right}}\fR, \fB{{trim_bottom}}\fR (pixels removed from each edge), \fB{{src_x}}\fR/\fB{{src_y}}\fR (aliases for \fBtrim_left\fR/\fBtrim_top\fR), \fB{{has_trim}}\fR (\fBtrue\fR when any trim value is non-zero). Pre-computed plist helpers: \fB{{plist_frame}}\fR (\fB{x,y},{w,h}\fR), \fB{{plist_offset}}\fR (Cocos2d center offset), \fB{{plist_source_color_rect}}\fR (\fB{trim_left,trim_top},{w,h}\fR), \fB{{plist_source_size}}\fR (\fB{source_w,source_h}\fR). \fB{{rotated_plist}}\fR outputs \fB\fR or \fB\fR for use in XML/plist boolean elements. \fB{{plist_atlas_size}}\fR is a global field (\fB{atlas_width,atlas_height}\fR). +.IP "\(bu" 2 +\fBPivot points\fR: \fB{{pivot_x}}\fR, \fB{{pivot_y}}\fR. These are resolved from markers named \fBpivot\fR of type \fBpoint\fR (either per-sprite or global). \fB{{pivot_x_norm}}\fR, \fB{{pivot_y_norm}}\fR provide normalized coordinates (0.0 to 1.0). \fB{{pivot_y_norm}}\fR is flipped for Unity's coordinate system (1.0 - py/source_h). \fB{{pivot_y_norm_raw}}\fR provides normalized Y without flipping. +.IP "\(bu" 2 +\fBStable IDs\fR: \fB{{name_hash}}\fR, \fB{{name_hash_hex}}\fR \(em FNV-1a hash of the sprite name, useful for engine-specific internal IDs (such as Unity's spriteID). .IP "\(bu" 2 \fBSprite rotation\fR: \fB{{rotated}}\fR (\fBtrue\fR when the sprite was packed rotated, else \fBfalse\fR). .IP "\(bu" 2 \fBMarker fields\fR: \fB{{marker_name}}\fR, \fB{{marker_type}}\fR, \fB{{marker_sprite_index}}\fR, \fB{{marker_sprite_name}}\fR. .IP "\(bu" 2 -\fBAnimation fields\fR: \fB{{animation_name}}\fR, \fB{{animation_sprite_indexes_json}}\fR. +\fBAnimation fields\fR: \fB{{animation_name}}\fR, \fB{{animation_sprite_indexes_json}}\fR, \fB{{sprite_names}}\fR/\fB{{sprite_names_json}}\fR/\fB{{sprite_names_csv}}\fR (display names of frame sprites), \fB{{animation_from}}\fR/\fB{{animation_to}}\fR (first and last sprite index of the animation, useful for range-based formats such as Aseprite frameTags). .IP "\(bu" 2 Typed placeholders (for example \fB{{name_json}}\fR, \fB{{marker_vertices_xml}}\fR) are explicit and recommended. .PP -Built-in JSON transform output omits \fBindex\fR fields from \fBsprites[]\fR and \fBanimations[]\fR. +The built-in JSON transform produces a top-level \fBatlases\fR array (each entry has \fBwidth\fR, \fBheight\fR, \fBpath\fR) and a flat top-level \fBsprites\fR array. Sprite spatial data is grouped into nested objects: \fBrect\fR (\fBx\fR, \fBy\fR, \fBw\fR, \fBh\fR), \fBpivot\fR (\fBx\fR, \fBy\fR), and \fBtrim\fR (\fBleft\fR, \fBtop\fR, \fBright\fR, \fBbottom\fR). Index fields are omitted from \fBsprites[]\fR and \fBanimations[]\fR entries. +.PP +The \fBAseprite\fR transform produces an Aseprite JSON Array sprite sheet. When animations are present the \fBframeTags\fR array is populated using \fB{{animation_from}}\fR/\fB{{animation_to}}\fR sprite indices. +.PP +The \fBGodot\fR transform produces a JSON file suitable for runtime loading in Godot 4 via GDScript: +.PP +.nf +func make_sprite_frames(json_path: String) -> SpriteFrames: + var data = JSON.parse_string(FileAccess.get_file_as_string(json_path)) + var atlas = load("res://" + data.image) + var sf = SpriteFrames.new() + sf.remove_animation("default") + for anim in data.animations: + sf.add_animation(anim.name) + sf.set_animation_speed(anim.name, anim.speed) + for i in range(anim.from, anim.to + 1): + var f = data.frames[i] + var tex = AtlasTexture.new() + tex.atlas = atlas + tex.region = Rect2(f.region.x, f.region.y, f.region.w, f.region.h) + tex.margin = Rect2(f.margin.left, f.margin.top, + f.margin.right, f.margin.bottom) + sf.add_frame(anim.name, tex) + return sf +.fi +.PP +Each frame entry contains \fBregion\fR (atlas coordinates), \fBmargin\fR (trim offsets: left/top/right/bottom), \fBsource_size\fR, and \fBrotated\fR. Animation entries contain \fBfrom\fR/\fBto\fR frame indices, \fBspeed\fR (fps), and \fBloop: true\fR. +.PP +The \fBLibGDX\fR transform produces a LibGDX \fBTextureAtlas\fR \fB.atlas\fR file. The \fBoffset\fR field uses LibGDX's y-up convention: \fBoffset: {{trim_left}}, {{trim_bottom}}\fR. Animations are not encoded in the \fB.atlas\fR format; use sprite name suffixes (e.g. \fBwalk_0\fR, \fBwalk_1\fR) and \fBAnimation\fR in code instead. +.PP +The \fBplist\fR transform produces a Cocos2d-x TextureAtlas property list (format 2). Per-sprite values use Cocos2d's \fB{x,y}\fR and \fB{x,y},{w,h}\fR notation inside \fB\fR elements. The \fBoffset\fR is the center offset of the trimmed region from the original sprite center (y-up): \fB{(trim_left\-trim_right)/2, (trim_bottom\-trim_top)/2}\fR. These values are available as \fB{{plist_frame}}\fR, \fB{{plist_offset}}\fR, \fB{{plist_source_color_rect}}\fR, \fB{{plist_source_size}}\fR, and \fB{{plist_atlas_size}}\fR when writing custom transforms targeting this format. .SS spratframes Scans \fIinput_image\fR and detects sprite boundaries by finding non-transparent connected components or by detecting rectangles of a specific color. -Prints layout-compatible lines: +Prints layout-compatible lines to stdout: .PP path \fIinput_image\fR .br @@ -183,8 +234,19 @@ path \fIinput_image\fR sprite \fIx,y\fR \fIw,h\fR .SS spratunpack Extracts individual sprites from an \fIatlas.png\fR using a frames definition (\fB.json\fR or \fB.spratframes\fR). -Atlas input can be a file path, \fB\-\fR, or standard input (when no atlas path is given). -If \fB\-\-frames\fR is not specified, it looks for \fB.json\fR or \fB.spratframes\fR (path input only). +.PP +Atlas input: +.IP "\(bu" 2 +A file path reads the atlas from disk. If \fB\-\-frames\fR is not specified, it looks for \fB.json\fR or \fB.spratframes\fR alongside the atlas. +.IP "\(bu" 2 +\fB\-\fR or omitting the atlas argument reads the atlas PNG from standard input. \fB\-\-frames\fR is required in this case. +.PP +Output: +.IP "\(bu" 2 +If \fB\-\-output\fR is specified, extracted sprites are written as individual PNG files to that directory. +.IP "\(bu" 2 +If \fB\-\-output\fR is omitted, sprites are written as a TAR stream to standard output, which can be piped to \fBspratlayout \-\fR for further processing. +.PP Supports de-obfuscation of protected atlases (starting with "SPRAT!" signature). .SH OPTIONS .SS spratlayout @@ -195,8 +257,9 @@ Profile name loaded from profile config. Default: \fBfast\fR. \fB\-\-profiles\-config\fR \fIPATH\fR Use an explicit profile configuration file. .TP -\fB\-\-mode\fR compact|pot|fast +\fB\-\-mode\fR compact|pot|fast|grid Override packing mode from selected profile. +\fBgrid\fR places every sprite in a uniform cell (sized to the largest frame) arranged left-to-right, top-to-bottom, so any frame can be addressed by column and row index. .TP \fB\-\-optimize\fR gpu|space Override optimization target from selected profile. @@ -219,9 +282,6 @@ Extra pixels between packed sprites. Overrides profile value. \fB\-\-extrude\fR \fIN\fR Repeat edge pixels N times (padding should be >= extrude * 2). .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 @@ -244,28 +304,31 @@ Enable 90-degree sprite rotation during packing (overrides profile value). Split into multiple atlases if they don't fit in the specified max dimensions. .TP \fB\-\-sort\fR name|none -Order of sprites in layout. Default: \fBname\fR for folders, \fBnone\fR for list/stdin. +Order of sprites in layout. \fBname\fR sorts by filename using natural ordering and enforces that order in the output regardless of packing optimizations. \fBnone\fR allows the packer to reorder sprites for better packing efficiency. Default: \fBnone\fR. .TP \fB\-\-threads\fR \fIN\fR -Worker thread count for packing. Default: auto. +Number of worker threads used during compact-mode packing (\fB\-\-preset quality\fR or \fB\-\-preset small\fR). +Has no effect when using \fBfast\fR or \fBpot\fR presets. +Defaults to the number of logical CPU cores reported by the OS. +Set to \fB1\fR to force single-threaded packing, which is useful for reproducible benchmarks or when memory is constrained. .TP \fB\-\-debug\fR Enable detailed error reporting and debug visualization. .PP Config keys available per profile section: .IP "\(bu" 2 -\fBmode=compact|pot|fast\fR +\fBmode=compact|pot|fast|grid\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 +\fBpadding\fR .IP "\(bu" 2 \fBscale\fR (\fB0 < scale <= 1\fR), \fBtrim_transparent\fR, \fBthreads\fR, \fBmultipack\fR .SS spratpack .TP -\fB\-o\fR, \fB\-\-output\fR \fIPATTERN\fR +\fB\-a\fR, \fB\-\-atlas\fR \fIPATTERN\fR Output filename pattern (e.g. \fBatlas_%d.png\fR). Required for multipack layouts when not writing to stdout. .TP \fB\-\-atlas\-index\fR \fIN\fR @@ -297,20 +360,37 @@ Enable detailed error reporting and debug visualization. .SS spratconvert .TP \fB\-\-transform\fR \fINAME|PATH\fR -Transform name from \fBtransforms/\fR or path to a custom transform file. Default: \fBjson\fR. +Transform name or path to a custom transform file. Default: \fBjson\fR. .TP -\fB\-o\fR, \fB\-\-output\fR \fIPATTERN\fR +\fB\-a\fR, \fB\-\-atlas\fR \fIPATTERN\fR Atlas path pattern used by \fB{{atlas_path}}\fR/\fB{{atlas_*}}\fR placeholders. Example: \fBatlas_%d.png\fR. .TP +\fB\-\-output\-dir\fR \fIPATH\fR +Write transform output to \fIPATH/{variant}{extension}\fR instead of stdout. +When \fB\-\-transform\fR is a plain name without a dot (for example \fBphaser\fR), \fBspratconvert\fR scans the transforms directory for files named \fBphaser.*.transform\fR and renders each one, using the part after the dot as the variant (for example \fBhash\fR becomes \fIPATH/hash.json\fR). +When a single named or path transform is given, the output stem is derived from the transform filename (for example \fBphaser.hash.transform\fR writes \fIPATH/hash.json\fR). +The placeholder \fB{{output_stem}}\fR is available inside transform templates and resolves to the variant name. +.TP \fB\-\-list\-transforms\fR Print available transforms and exit. .TP \fB\-\-markers\fR \fIPATH\fR Load external markers plaintext file using the \fBpath\fR and \fB- marker\fR DSL. +An optional \fBroot\fR directive sets a base directory; relative \fBpath\fR values are resolved against it. Supported marker types: \fBpoint\fR (\fBx,y\fR), \fBcircle\fR (\fBx,y radius\fR), \fBrectangle\fR (\fBx,y w,h\fR), \fBpolygon\fR (\fBx,y x,y ...\fR). .TP \fB\-\-animations\fR \fIPATH\fR Load external animations plaintext file using the \fBanimation\fR and \fB- frame\fR DSL. +An optional \fBroot\fR directive sets a base directory; relative quoted frame paths are resolved against it. +.PP +An animation can be declared as an alias of another animation using the \fBalias\fR keyword: +.PP +animation \fI"name"\fR alias \fI"source"\fR [\fBh-flip\fR] [\fBv-flip\fR] +.PP +Alias declarations carry no frame list; instead they reference another animation by name and attach optional rendering hints. +\fBh-flip\fR and \fBv-flip\fR are independent and order-insensitive. +The source name is stored as metadata; validation is the runtime's responsibility. +In the output, alias entries carry an \fBalias\fR field in place of \fBfps\fR and \fBsprite_indexes\fR. .TP \fB\-\-auto\-animations\fR Automatically group frames into animations by name pattern. @@ -348,50 +428,65 @@ Number of worker threads for sprite extraction. Default: auto. Enable detailed error reporting. .SH EXAMPLES .TP -Generate layout text: +Generate a layout from a folder: .B spratlayout ./frames > layout.txt .TP -Pack PNG from layout: -.B spratpack < layout.txt > spritesheet.png +Generate a layout from a list file: +.B spratlayout frames.txt > layout.txt .TP -Multipack to multiple files: -.B spratlayout ./frames --multipack --max-width 1024 --max-height 1024 | spratpack -o atlas_%d.png +Exclude files from sync/watch regeneration: +.B printf 'exclude "enemy.png"\n' > frames/.spratlayoutignore .TP -Transform layout to JSON with auto-animations: -.B spratconvert --transform json --auto-animations < layout.txt > layout.json +Pack a PNG atlas from a layout file: +.B spratpack < layout.txt > spritesheet.png .TP -Protect output atlas: -.B spratpack --protect < layout.txt > protected.png +Full pipeline (layout and pack in one step): +.B spratlayout ./frames | spratpack > spritesheet.png .TP -Unpack protected atlas: -.B spratunpack protected.png --frames atlas.json --output ./extracted +Multipack to multiple files: +.B spratlayout ./frames --multipack --max-width 1024 --max-height 1024 | spratpack -a atlas_%d.png .TP -Transform layout with custom template: -.B spratconvert --transform ./my.transform < layout.txt > layout.custom.txt +Convert layout to JSON: +.B spratconvert --transform json < layout.txt > layout.json +.TP +Convert layout to JSON with auto-animations: +.B spratconvert --transform json --auto-animations < layout.txt > layout.json .TP -Transform layout with extra files: +Convert layout with markers and animations: .B spratconvert --transform json --markers markers.txt --animations animations.txt < layout.txt > layout.json .TP -Run as one pipeline: -.B spratlayout ./frames --trim-transparent --padding 2 | spratpack > spritesheet.png +Convert with a custom transform template: +.B spratconvert --transform ./my.transform < layout.txt > layout.custom.txt .TP -Debug frame bounds: -.B spratpack --frame-lines --line-width 2 --line-color 0,255,0 < layout.txt > spritesheet_lines.png +Full pipeline including conversion: +.B spratlayout ./frames | tee layout.txt | spratconvert --transform json > layout.json && spratpack < layout.txt > spritesheet.png .TP -Limit worker threads: -.B spratlayout ./frames --threads 4 > layout.txt ; spratpack --threads 4 < layout.txt > spritesheet.png +Pack and convert in parallel from the same layout: +.B spratlayout ./frames > layout.txt && spratpack < layout.txt > spritesheet.png && spratconvert < layout.txt > layout.json .TP -Detect sprites in a sheet: -.B spratframes sheet.png > frames.spratframes +Protect output atlas: +.B spratpack --protect < layout.txt > protected.png .TP Unpack an atlas to a directory: .B spratunpack atlas.png --frames atlas.json --output ./extracted .TP -Unpack atlas to TAR (stdout): +Unpack atlas to TAR stream (stdout): .B spratunpack atlas.png > sprites.tar .TP -Unpack atlas from stdin to TAR (stdout): +Unpack atlas from stdin to TAR stream: .B cat atlas.png | spratunpack --frames atlas.json > sprites.tar +.TP +Re-pack an unpacked atlas (round-trip via TAR stdin): +.B spratunpack atlas.png | spratlayout - | spratpack > repacked.png +.TP +Pack frames from a TAR archive: +.B tar cf - ./frames | spratlayout - | 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 +Detect sprites in a sheet: +.B spratframes sheet.png > frames.spratframes .SH EXIT STATUS All commands return: .TP diff --git a/spratprofiles.cfg b/spratprofiles.cfg index ff11719..8becca4 100644 --- a/spratprofiles.cfg +++ b/spratprofiles.cfg @@ -1,55 +1,59 @@ # Default spratlayout profiles. + +[profile fast] +label=Fast +mode=fast +optimize=gpu +padding=0 +trim_transparent=false + [profile desktop] +label=Desktop mode=compact optimize=gpu -padding=0 +max_width=8192 +max_height=8192 +padding=2 extrude=0 -max_combinations=0 -scale=1 trim_transparent=true -# multipack=false [profile mobile] +label=Mobile mode=compact optimize=gpu max_width=2048 max_height=2048 padding=2 extrude=0 -max_combinations=0 -scale=1 trim_transparent=true [profile legacy] +label=Legacy (POT) mode=pot -optimize=space -max_width=1024 -max_height=1024 -padding=0 -max_combinations=0 -scale=1 +optimize=gpu +max_width=2048 +max_height=2048 +padding=1 +extrude=0 trim_transparent=true [profile space] +label=Space Efficient mode=compact optimize=space padding=0 -max_combinations=0 -scale=1 +rotate=true trim_transparent=true -[profile fast] -mode=fast -optimize=gpu +[profile css] +label=CSS Sprites +mode=compact +optimize=space padding=0 -max_combinations=0 -scale=1 trim_transparent=false -[profile css] -mode=fast -optimize=space +[profile grid] +label=Grid +mode=grid padding=0 -max_combinations=0 -scale=1 trim_transparent=false diff --git a/src/commands/spratconvert_command.cpp b/src/commands/spratconvert_command.cpp index 7645626..c4fa72d 100644 --- a/src/commands/spratconvert_command.cpp +++ b/src/commands/spratconvert_command.cpp @@ -16,9 +16,10 @@ #endif #endif #include +#include #include -#include #include +#include #include namespace fs = std::filesystem; #include @@ -26,7 +27,6 @@ namespace fs = std::filesystem; #include #include #include -#include #include #include #include @@ -35,37 +35,10 @@ namespace fs = std::filesystem; #include "core/cli_parse.h" #include "core/i18n.h" #include "core/output_pattern.h" +#include "core/fnv1a.h" +#include namespace { -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 sprite_markers_header; - std::string sprite_marker; - std::string sprite_markers_separator; - std::string sprite_markers_footer; - 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 atlas_header; - std::string atlas; - std::string atlas_separator; - std::string atlas_footer; - std::string footer; -}; struct MarkerItem { size_t index = 0; @@ -86,7 +59,6 @@ constexpr int DEFAULT_ANIMATION_FPS = 8; constexpr int k_default_precision = 8; constexpr size_t k_string_growth_padding = 8; constexpr unsigned char k_json_control_char_limit = 0x20; -constexpr size_t k_token_replacement_reserve_extra = 64; constexpr std::uint8_t HEX_NIBBLE_MASK = 0x0f; constexpr int BITS_PER_NIBBLE = 4; constexpr const char* HEX_DIGITS = "0123456789abcdef"; @@ -96,6 +68,8 @@ struct AnimationItem { std::string name; std::vector sprite_indexes; int fps = DEFAULT_ANIMATION_FPS; + std::string alias_source; + std::string flip; }; using Sprite = sprat::core::Sprite; @@ -123,18 +97,23 @@ std::string trim_copy(const std::string& s) { } bool read_text_file(const fs::path& path, std::string& out, std::string& error) { - std::ifstream in(path, std::ios::binary); + std::ifstream in(path, std::ios::binary | std::ios::ate); if (!in) { error = "Failed to open file: " + path.string(); return false; } - std::ostringstream buffer; - buffer << in.rdbuf(); + const auto size = in.tellg(); + if (size < 0) { + error = "Failed to read file: " + path.string(); + return false; + } + in.seekg(0); + out.resize(static_cast(size)); + in.read(out.data(), size); if (!in.good() && !in.eof()) { error = "Failed to read file: " + path.string(); return false; } - out = buffer.str(); return true; } @@ -152,11 +131,10 @@ std::string escape_json(const std::string& s) { case '\t': out += "\\t"; break; default: if (static_cast(c) < k_json_control_char_limit) { - std::ostringstream hex; - hex << "\\u"; auto uc = static_cast(c); - hex << '0' << '0' << HEX_DIGITS[(uc >> BITS_PER_NIBBLE) & HEX_NIBBLE_MASK] << HEX_DIGITS[uc & HEX_NIBBLE_MASK]; - out += hex.str(); + out += "\\u00"; + out += HEX_DIGITS[(uc >> BITS_PER_NIBBLE) & HEX_NIBBLE_MASK]; + out += HEX_DIGITS[uc & HEX_NIBBLE_MASK]; } else { out.push_back(c); } @@ -166,392 +144,10 @@ std::string escape_json(const std::string& s) { return out; } -std::string escape_xml(const std::string& s) { - std::string out; - out.reserve(s.size() + k_string_growth_padding); - 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() + k_string_growth_padding); - for (char c : s) { - if (c == '\\' || c == '"') { - out.push_back('\\'); - } - if (c == '\n') { - out += "\\a "; - continue; - } - out.push_back(c); - } - return out; -} - -enum class PlaceholderEncoding { - none, - json, - xml, - csv, - css -}; - -std::string escape_value(const std::string& value, PlaceholderEncoding encoding) { - switch (encoding) { - case PlaceholderEncoding::json: return escape_json(value); - case PlaceholderEncoding::xml: return escape_xml(value); - case PlaceholderEncoding::csv: return escape_csv(value); - case PlaceholderEncoding::css: return escape_css_string(value); - default: return value; - } -} - -std::string filter_sections_by_attr(const std::string& input, - const std::map& vars, - PlaceholderEncoding encoding) { - std::string output; - size_t pos = 0; - auto encoding_to_string = [](PlaceholderEncoding enc) { - switch (enc) { - case PlaceholderEncoding::json: return std::string("json"); - case PlaceholderEncoding::xml: return std::string("xml"); - case PlaceholderEncoding::csv: return std::string("csv"); - case PlaceholderEncoding::css: return std::string("css"); - default: return std::string(); - } - }; - const std::string encoding_name = encoding_to_string(encoding); - - while (pos < input.size()) { - size_t start = input.find('[', pos); - if (start == std::string::npos) { - output.append(input.substr(pos)); - break; - } - output.append(input.substr(pos, start - pos)); - if (start + 1 >= input.size() || input[start + 1] == '/') { - output.push_back(input[start]); - pos = start + 1; - continue; - } - size_t header_end = input.find(']', start + 1); - if (header_end == std::string::npos) { - output.append(input.substr(start)); - break; - } - std::string header = input.substr(start + 1, header_end - start - 1); - std::string tag; - std::string attr; - std::string value; - size_t i = 0; - while (i < header.size() && !std::isspace(static_cast(header[i]))) { - tag.push_back(header[i]); - ++i; - } - while (i < header.size()) { - while (i < header.size() && std::isspace(static_cast(header[i]))) { - ++i; - } - size_t name_start = i; - while (i < header.size() && header[i] != '=' && !std::isspace(static_cast(header[i]))) { - ++i; - } - std::string attr_name = header.substr(name_start, i - name_start); - while (i < header.size() && header[i] != '=') { - ++i; - } - if (i >= header.size() || header[i] != '=') { - break; - } - ++i; - while (i < header.size() && std::isspace(static_cast(header[i]))) { - ++i; - } - if (i >= header.size() || header[i] != '"') { - break; - } - ++i; - size_t value_start = i; - while (i < header.size() && header[i] != '"') { - ++i; - } - std::string attr_value = header.substr(value_start, i - value_start); - ++i; - if (attr_name == "type" || attr_name == "marker_type") { - attr = attr_name; - value = attr_value; - break; - } - } - size_t close = input.find("[/" + tag + "]", header_end + 1); - if (close == std::string::npos) { - output.append(input.substr(start)); - break; - } - bool keep = true; - if (!attr.empty()) { - if (attr == "type") { - keep = (value == encoding_name); - } else { - auto it = vars.find(attr); - keep = (it != vars.end() && it->second == value); - } - } - if (!keep) { - pos = close + tag.size() + 3; - continue; - } - output.append(input.substr(header_end + 1, close - header_end - 1)); - pos = close + tag.size() + 3; - } - - return output; -} - -std::string filter_rotated_sections(const std::string& input, bool rotated) { - std::string output; - size_t pos = 0; - while (pos < input.size()) { - size_t start = input.find("[rotated]", pos); - if (start == std::string::npos) { - output.append(input.substr(pos)); - break; - } - output.append(input.substr(pos, start - pos)); - size_t end = input.find("[/rotated]", start + 9); - if (end == std::string::npos) { - break; - } - if (rotated) { - output.append(input.substr(start + 9, end - start - 9)); - } - pos = end + 10; - } - return output; -} - -std::string to_lower_copy(std::string value) { - std::ranges::transform(value, value.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return value; -} - -bool has_suffix(const std::string& value, const std::string& suffix) { - if (value.size() < suffix.size()) { - return false; - } - return value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; -} - -PlaceholderEncoding detect_placeholder_encoding(const Transform& transform, - const std::string& transform_arg) { - const auto from_token = [](const std::string& token) -> PlaceholderEncoding { - std::string normalized = to_lower_copy(token); - if (!normalized.empty() && normalized.front() == '.') { - normalized.erase(normalized.begin()); - } - if (normalized == "json") { - return PlaceholderEncoding::json; - } - if (normalized == "xml") { - return PlaceholderEncoding::xml; - } - if (normalized == "csv") { - return PlaceholderEncoding::csv; - } - if (normalized == "css") { - return PlaceholderEncoding::css; - } - return PlaceholderEncoding::none; - }; - - if (PlaceholderEncoding from_meta = from_token(transform.extension); - from_meta != PlaceholderEncoding::none) { - return from_meta; - } - if (PlaceholderEncoding from_name = from_token(transform.name); - from_name != PlaceholderEncoding::none) { - return from_name; - } - return from_token(transform_arg); -} - -std::string replace_tokens(const std::string& input, - const std::map& vars, - PlaceholderEncoding encoding) { - bool rotated = false; - auto rotated_it = vars.find("rotated"); - if (rotated_it != vars.end()) { - rotated = (rotated_it->second == "true"); - } - std::string filtered = filter_rotated_sections(input, rotated); - filtered = filter_sections_by_attr(filtered, vars, encoding); - std::string out; - out.reserve(filtered.size() + k_token_replacement_reserve_extra); - - auto is_composite_variable = [](std::string_view key) { - return key == "sprites" || key == "markers" || key == "animations" || - key == "sprite_markers" || key == "atlases" || key == "sprite_indexes" || - key == "vertices"; - }; - - size_t i = 0; - while (i < filtered.size()) { - size_t open = filtered.find("{{", i); - if (open == std::string::npos) { - out.append(filtered.substr(i)); - break; - } - - out.append(filtered.substr(i, open - i)); - - bool is_raw = false; - size_t close; - std::string key; - - if (open + 2 < filtered.size() && filtered[open + 2] == '{') { - is_raw = true; - close = filtered.find("}}}", open + 3); - if (close == std::string::npos) { - out.append(filtered.substr(open)); - break; - } - key = trim_copy(filtered.substr(open + 3, close - (open + 3))); - i = close + 3; - } else { - close = filtered.find("}}", open + 2); - if (close == std::string::npos) { - out.append(filtered.substr(open)); - break; - } - key = trim_copy(filtered.substr(open + 2, close - (open + 2))); - i = close + 2; - } - - auto it = vars.find(key); - if (it != vars.end()) { - PlaceholderEncoding entry_encoding = (is_raw || is_composite_variable(key)) ? PlaceholderEncoding::none : encoding; - out.append(escape_value(it->second, entry_encoding)); - } - } - - return out; -} - -std::string format_sprite_indexes(const std::vector& values, PlaceholderEncoding encoding) { - if (values.empty()) { - return (encoding == PlaceholderEncoding::json) ? "[]" : ""; - } - std::ostringstream oss; - if (encoding == PlaceholderEncoding::json) { - oss << "["; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) oss << ","; - oss << values[i]; - } - oss << "]"; - } else if (encoding == PlaceholderEncoding::csv) { - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) oss << "|"; - oss << values[i]; - } - } else { - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) oss << ","; - oss << values[i]; - } - } - return oss.str(); -} - -std::string format_markers_json(const std::vector& markers) { - std::ostringstream oss; - oss << "["; - bool first_marker = true; - for (const auto& marker : markers) { - if (!first_marker) { - oss << ","; - } - oss << R"({"name":")" << escape_json(marker.name) << R"(","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\":["; - bool first_vertex = true; - for (const auto& vertex : marker.vertices) { - if (!first_vertex) { - oss << ","; - } - oss << "{\"x\":" << vertex.first << ",\"y\":" << vertex.second << "}"; - first_vertex = false; - } - oss << "]"; - } - oss << "}"; - first_marker = false; - } - oss << "]"; - return oss.str(); -} - -std::string format_vertices(const std::vector>& vertices, PlaceholderEncoding encoding) { - if (vertices.empty()) { - return (encoding == PlaceholderEncoding::json) ? "[]" : ""; - } - std::ostringstream oss; - if (encoding == PlaceholderEncoding::json) { - oss << "["; - for (size_t i = 0; i < vertices.size(); ++i) { - if (i > 0) oss << ","; - oss << "{\"x\":" << vertices[i].first << ",\"y\":" << vertices[i].second << "}"; - } - oss << "]"; - } else { - 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 format_double(double value) { + std::array buf{}; + int n = std::snprintf(buf.data(), buf.size(), "%.*g", k_default_precision, value); + return std::string(buf.data(), n > 0 ? static_cast(n) : 0); } std::string sprite_name_from_path(const std::string& path) { @@ -580,6 +176,15 @@ void collect_sprite_name_indexes(const Layout& layout, by_path[s.path] = idx; fs::path p(s.path); by_path[p.filename().string()] = idx; + size_t sep = s.path.find('/'); + while (sep != std::string::npos) { + ++sep; + std::string suffix = s.path.substr(sep); + if (!suffix.empty()) { + by_path.emplace(suffix, idx); + } + sep = s.path.find('/', sep); + } std::string name = sprite_name_from_path(s.path); sprite_names.push_back(name); by_name[name] = idx; @@ -594,6 +199,17 @@ int resolve_sprite_index(const std::string& key, if (by_path_it != by_path.end()) { return by_path_it->second; } + size_t sep = key.find('/'); + while (sep != std::string::npos) { + ++sep; + if (sep < key.size()) { + auto it = by_path.find(key.substr(sep)); + if (it != by_path.end()) { + return it->second; + } + } + sep = key.find('/', sep); + } auto by_name_it = by_name.find(key); if (by_name_it != by_name.end()) { return by_name_it->second; @@ -612,6 +228,7 @@ std::vector parse_markers_data(const std::string& markers_text, std::istringstream iss(markers_text); std::string line; int current_sprite_index = -1; + std::string raw_root; while (std::getline(iss, line)) { std::string trimmed = trim_copy(line); @@ -625,7 +242,16 @@ std::vector parse_markers_data(const std::string& markers_text, continue; } - if (cmd == "path") { + if (cmd == "root") { + size_t pos = 4; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { + ++pos; + } + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + parse_quoted(trimmed, pos, raw_root, error); + } + } else if (cmd == "path") { std::string path; size_t pos = trimmed.find("path") + 4; while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { @@ -634,10 +260,16 @@ std::vector parse_markers_data(const std::string& markers_text, if (pos < trimmed.size() && trimmed[pos] == '"') { std::string error; if (parse_quoted(trimmed, pos, path, error)) { + if (!raw_root.empty() && fs::path(path).is_relative()) { + path = (fs::path(raw_root) / path).string(); + } current_sprite_index = resolve_sprite_index(path, by_path, by_name); } } else { if (liss >> path) { + if (!raw_root.empty() && fs::path(path).is_relative()) { + path = (fs::path(raw_root) / path).string(); + } current_sprite_index = resolve_sprite_index(path, by_path, by_name); } } @@ -741,6 +373,7 @@ std::vector parse_animations_data( std::istringstream iss(animations_text); std::string line; AnimationItem* current_anim = nullptr; + std::string raw_root; while (std::getline(iss, line)) { std::string trimmed = trim_copy(line); @@ -754,7 +387,16 @@ std::vector parse_animations_data( continue; } - if (cmd == "fps") { + if (cmd == "root") { + size_t pos = 4; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { + ++pos; + } + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + parse_quoted(trimmed, pos, raw_root, error); + } + } else if (cmd == "fps") { int fps = 0; if (liss >> fps) { animation_fps_out = fps; @@ -777,19 +419,49 @@ std::vector parse_animations_data( pos = trimmed.find(name, pos) + name.length(); } - int fps = animation_fps_out > 0 ? animation_fps_out : DEFAULT_ANIMATION_FPS; - int custom_fps = 0; - std::istringstream rest(trimmed.substr(pos)); - if (rest >> custom_fps) { - fps = custom_fps; - } - AnimationItem item; item.index = animations.size(); item.name = name; - item.fps = fps; - animations.push_back(std::move(item)); - current_anim = &animations.back(); + item.fps = animation_fps_out > 0 ? animation_fps_out : DEFAULT_ANIMATION_FPS; + + std::string next_token; + { + std::istringstream rest(trimmed.substr(pos)); + rest >> next_token; + } + + if (next_token == "alias") { + size_t alias_kw_pos = trimmed.find("alias", pos); + size_t alias_src_pos = alias_kw_pos + 5; + while (alias_src_pos < trimmed.size() && std::isspace(static_cast(trimmed[alias_src_pos]))) { + alias_src_pos++; + } + if (alias_src_pos < trimmed.size() && trimmed[alias_src_pos] == '"') { + std::string error; + std::string alias_source; + if (parse_quoted(trimmed, alias_src_pos, alias_source, error)) { + item.alias_source = alias_source; + } + } + std::string tok; + std::istringstream flip_rest(trimmed.substr(alias_src_pos)); + while (flip_rest >> tok) { + if (tok == "flip") { + std::string val; + if (flip_rest >> val) item.flip = val; + } + } + animations.push_back(std::move(item)); + current_anim = nullptr; + } else { + int custom_fps = 0; + std::istringstream fps_iss(next_token); + if (fps_iss >> custom_fps) { + item.fps = custom_fps; + } + animations.push_back(std::move(item)); + current_anim = &animations.back(); + } } else if (cmd == "-") { std::string subcmd; if (!(liss >> subcmd) || subcmd != "frame") { @@ -799,7 +471,6 @@ std::vector parse_animations_data( continue; } - std::string frame_token; size_t pos = trimmed.find("frame") + 5; while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) { pos++; @@ -808,17 +479,24 @@ std::vector parse_animations_data( std::string path; std::string error; if (parse_quoted(trimmed, pos, path, error)) { + if (!raw_root.empty() && fs::path(path).is_relative()) { + path = (fs::path(raw_root) / path).string(); + } int idx = resolve_sprite_index(path, by_path, by_name); if (idx >= 0) { current_anim->sprite_indexes.push_back(idx); } } } else { + std::string frame_token; if (liss >> frame_token) { int idx = -1; if (parse_int(frame_token, idx)) { current_anim->sprite_indexes.push_back(idx); } else { + if (!raw_root.empty() && fs::path(frame_token).is_relative()) { + frame_token = (fs::path(raw_root) / frame_token).string(); + } idx = resolve_sprite_index(frame_token, by_path, by_name); if (idx >= 0) { current_anim->sprite_indexes.push_back(idx); @@ -831,366 +509,6 @@ std::vector parse_animations_data( 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 == "sprite_markers_header" - || s == "sprite_marker" - || s == "sprite_markers_separator" - || s == "sprite_markers_footer" - || s == "separator" - || s == "if_animations" - || s == "if_no_animations" - || s == "animations_header" - || s == "animations" - || s == "animation" - || s == "animations_separator" - || s == "animations_footer" - || s == "atlases" - || s == "atlas_header" - || s == "atlas" - || s == "atlas_separator" - || s == "atlas_footer" - || s == "footer"; - }; - - bool dsl_mode = false; - while (std::getline(in, line)) { - std::string trimmed = trim_copy(line); - if (trimmed.empty() && section_stack.empty()) { - continue; - } - - if (!trimmed.empty() && trimmed.front() == '#') { - continue; - } - - bool section_tag = false; - if (trimmed.size() >= 3 && trimmed.front() == '[' && trimmed.back() == ']') { - std::string full_tag = trim_copy(trimmed.substr(1, trimmed.size() - 2)); - if (!full_tag.empty() && full_tag.front() == '/') { - std::string tag = trim_copy(full_tag.substr(1)); - if (is_known_section(tag) && !section_stack.empty() && tag == section_stack.back()) { - section_stack.pop_back(); - section_tag = true; - dsl_mode = false; - } - } else { - std::string tag; - size_t space_pos = full_tag.find_first_of(" \t\r\n"); - if (space_pos != std::string::npos) { - tag = full_tag.substr(0, space_pos); - } else { - tag = full_tag; - } - - if (is_known_section(tag)) { - // Only treat as a section if there are no attributes - if (space_pos == std::string::npos) { - if (tag == "sprite") { - if (section_stack.empty() || (section_stack.back() != "sprites" && section_stack.back() != "atlas")) { - // Auto-open sprites if sprite is used without it - section_stack.push_back("sprites"); - } - saw_sprite_item = true; - } else if (tag == "marker") { - if (section_stack.empty() || section_stack.back() != "markers") { - section_stack.push_back("markers"); - } - saw_marker_item = true; - } else if (tag == "animation") { - if (section_stack.empty() || section_stack.back() != "animations") { - section_stack.push_back("animations"); - } - saw_animation_item = true; - } else if (tag == "atlas") { - if (section_stack.empty() || section_stack.back() != "atlases") { - section_stack.push_back("atlases"); - } - } - section_stack.push_back(tag); - section_tag = true; - dsl_mode = false; - } - } - } - } - - if (section_tag) { - continue; - } - - if (section_stack.empty()) { - std::istringstream liss(trimmed); - std::string cmd; - if (liss >> cmd) { - if (cmd == "-") { - std::string subcmd; - if (liss >> subcmd) { - if (is_known_section(subcmd)) { - dsl_mode = true; - if (subcmd == "sprite") { - if (section_stack.empty() || section_stack.back() != "sprites") { - section_stack.push_back("sprites"); - } - saw_sprite_item = true; - } else if (subcmd == "marker") { - if (section_stack.empty() || section_stack.back() != "markers") { - section_stack.push_back("markers"); - } - saw_marker_item = true; - } else if (subcmd == "animation") { - if (section_stack.empty() || section_stack.back() != "animations") { - section_stack.push_back("animations"); - } - saw_animation_item = true; - } - section_stack.push_back(subcmd); - continue; - } - } - } else if (is_known_section(cmd)) { - // Start section - dsl_mode = true; - section_stack.push_back(cmd); - - // If it's meta, we might have arguments on the same line - if (cmd == "meta") { - std::string rest; - if (std::getline(liss, rest)) { - std::string trimmed_rest = trim_copy(rest); - if (!trimmed_rest.empty()) { - size_t eq = trimmed_rest.find('='); - if (eq != std::string::npos) { - std::string key = trim_copy(trimmed_rest.substr(0, eq)); - std::string value = trim_copy(trimmed_rest.substr(eq + 1)); - if (key == "name") parsed.name = value; - else if (key == "description") parsed.description = value; - else if (key == "extension") parsed.extension = value; - } - } - } - } - continue; - } - } - } else if (dsl_mode) { - // Check for new section starting without [tag], auto-closing previous - std::istringstream liss(trimmed); - std::string cmd; - if (liss >> cmd) { - if (cmd == "-") { - std::string subcmd; - if (liss >> subcmd && is_known_section(subcmd)) { - // Pop until we find where it belongs or just pop current if it's a sibling/new level - if (subcmd == "sprite" || subcmd == "marker" || subcmd == "animation" || subcmd == "atlas" || subcmd == "atlases" || subcmd == "sprite_marker" || - subcmd == "sprite_markers_header" || subcmd == "sprite_markers_separator" || subcmd == "sprite_markers_footer") { - // These can be nested. If we are in the parent, stay. If we are in another sibling, pop. - std::string parent; - if (subcmd == "sprite") { - if (!section_stack.empty() && section_stack.back() == "atlas") parent = "atlas"; - else parent = "sprites"; - } - else if (subcmd == "marker") parent = "markers"; - else if (subcmd == "animation") parent = "animations"; - else if (subcmd == "atlas") parent = "atlases"; - else if (subcmd == "sprite_marker") parent = "sprite"; - else if (subcmd == "sprite_markers_header") parent = "sprite"; - else if (subcmd == "sprite_markers_separator") parent = "sprite"; - else if (subcmd == "sprite_markers_footer") parent = "sprite"; - - while (!section_stack.empty() && section_stack.back() != parent && !parent.empty()) { - section_stack.pop_back(); - } - if (section_stack.empty() && !parent.empty()) section_stack.push_back(parent); - if (subcmd == "sprite") saw_sprite_item = true; - else if (subcmd == "marker") saw_marker_item = true; - else if (subcmd == "animation") saw_animation_item = true; - section_stack.push_back(subcmd); - continue; - } else { - while (!section_stack.empty()) section_stack.pop_back(); - section_stack.push_back(subcmd); - continue; - } - } - } else if (is_known_section(cmd)) { - while (!section_stack.empty()) section_stack.pop_back(); - section_stack.push_back(cmd); - if (cmd == "meta") { - std::string rest; - if (std::getline(liss, rest)) { - std::string trimmed_rest = trim_copy(rest); - if (!trimmed_rest.empty()) { - size_t eq = trimmed_rest.find('='); - if (eq != std::string::npos) { - std::string key = trim_copy(trimmed_rest.substr(0, eq)); - std::string value = trim_copy(trimmed_rest.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_stack.empty()) { - continue; - } - - const std::string section = 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 == "sprite_markers_header") { - append_line(parsed.sprite_markers_header, line); - } else if (section == "sprite_marker") { - append_line(parsed.sprite_marker, line); - } else if (section == "sprite_markers_separator") { - append_line(parsed.sprite_markers_separator, line); - } else if (section == "sprite_markers_footer") { - append_line(parsed.sprite_markers_footer, 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 == "atlas_header") { - append_line(parsed.atlas_header, line); - } else if (section == "atlas") { - append_line(parsed.atlas, line); - } else if (section == "atlas_separator") { - append_line(parsed.atlas_separator, line); - } else if (section == "atlas_footer") { - append_line(parsed.atlas_footer, line); - } else if (section == "footer") { - append_line(parsed.footer, line); - } - } - - if (dsl_mode) { - section_stack.clear(); - } - - 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.unsetf(std::ios::floatfield); - oss.precision(k_default_precision); - oss << value; - return oss.str(); -} - #ifndef SPRAT_GLOBAL_TRANSFORMS_DIR #define SPRAT_GLOBAL_TRANSFORMS_DIR "/usr/local/share/sprat/transforms" #endif @@ -1212,38 +530,43 @@ std::optional resolve_user_transforms_dir() { } } } -#endif - + return std::nullopt; +#elif defined(__APPLE__) const char* home = std::getenv("HOME"); if (home == nullptr || home[0] == '\0') { return std::nullopt; } - -#ifdef __APPLE__ - const fs::path mac_dir = fs::path(home) / "Library" / "Preferences" / "sprat" / "transforms"; + const fs::path mac_dir = fs::path(home) / "Library" / "Application Support" / "sprat" / "transforms"; std::error_code ec_mac; if (fs::exists(mac_dir, ec_mac) && fs::is_directory(mac_dir, ec_mac)) { return mac_dir; } -#endif - - const fs::path home_dir = fs::path(home) / ".config" / "sprat" / "transforms"; + return std::nullopt; +#else + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') { + return std::nullopt; + } + const char* xdg_data_home = std::getenv("XDG_DATA_HOME"); + const fs::path data_dir = (xdg_data_home != nullptr && xdg_data_home[0] != '\0') + ? fs::path(xdg_data_home) / "sprat" / "transforms" + : fs::path(home) / ".local" / "share" / "sprat" / "transforms"; std::error_code ec; - if (fs::exists(home_dir, ec) && fs::is_directory(home_dir, ec)) { - return home_dir; + if (fs::exists(data_dir, ec) && fs::is_directory(data_dir, ec)) { + return data_dir; } return std::nullopt; +#endif } fs::path find_transforms_dir() { std::vector candidates; - candidates.emplace_back("transforms"); - if (std::optional user_dir = resolve_user_transforms_dir()) { - candidates.push_back(*user_dir); - } if (!g_exec_dir.empty()) { candidates.push_back(g_exec_dir / "transforms"); } + if (std::optional user_dir = resolve_user_transforms_dir()) { + candidates.push_back(*user_dir); + } #ifdef SPRAT_SOURCE_DIR candidates.push_back(fs::path(SPRAT_SOURCE_DIR) / "transforms"); #endif @@ -1256,20 +579,570 @@ fs::path find_transforms_dir() { } } - return fs::path("transforms"); + return fs::path(SPRAT_GLOBAL_TRANSFORMS_DIR); +} + +std::string format_atlas_path(const std::string& pattern, int index) { + if (pattern.empty()) { + return ""; + } + std::string out; + std::string error; + if (!format_index_pattern(pattern, index, out, error)) { + return ""; + } + return out; +} + +bool is_digit(char c) { return c >= '0' && c <= '9'; } + +std::string get_animation_name(const std::string& name) { + std::string anim_name = name; + while (!anim_name.empty()) { + char back = anim_name.back(); + if (is_digit(back) || back == '_' || back == '-' || back == ' ' || back == '.' || back == '(' || back == ')') { + anim_name.pop_back(); + } else { + break; + } + } + return anim_name; +} + +struct GroupMember { + std::string variant; + fs::path path; +}; + +std::string extract_variant(const std::string& stem) { + const auto dot_pos = stem.find('.'); + if (dot_pos == std::string::npos) return ""; + return stem.substr(dot_pos + 1); +} + +// ─── Jsonnet helpers ────────────────────────────────────────────────────────── + +// Build the JSON data string passed as std.extVar("sprat") to all transforms. +std::string build_sprat_json( + const Layout& layout, + const std::vector& sprite_names, + const std::vector& marker_items, + const std::vector& normalized_animations, + const std::vector>& sprite_markers, + int global_pivot_x, + int global_pivot_y, + bool has_global_pivot, + const std::string& output_pattern_arg, + const std::string& output_stem, + const std::string& markers_path_arg, + const std::string& animations_path_arg, + int animation_fps) +{ + // Helper: format uint64 as 16-char hex string + auto to_hex16 = [](uint64_t v) -> std::string { + char buf[17]; + std::snprintf(buf, sizeof(buf), "%016llx", static_cast(v)); + return std::string(buf); + }; + + // Helper: build CSS-safe identifier + auto to_css_name = [](const std::string& name) -> std::string { + std::string out; + for (char c : name) { + if (std::isalnum(static_cast(c)) || c == '-' || c == '_') { + out.push_back(c); + } else { + out.push_back('-'); + } + } + if (!out.empty() && std::isdigit(static_cast(out[0]))) { + out.insert(0, 1, '_'); + } + return out; + }; + + // Helper: build JSON array of marker objects + auto marker_to_json = [&](const MarkerItem& m) -> std::string { + std::string o = "{\"name\":\"" + escape_json(m.name) + "\""; + o += ",\"type\":\"" + escape_json(m.type) + "\""; + o += ",\"x\":" + std::to_string(m.x); + o += ",\"y\":" + std::to_string(m.y); + if (m.type == "circle") { + o += ",\"radius\":" + std::to_string(m.radius); + } else if (m.type == "rectangle") { + o += ",\"w\":" + std::to_string(m.w); + o += ",\"h\":" + std::to_string(m.h); + } else if (m.type == "polygon") { + o += ",\"vertices\":["; + for (size_t vi = 0; vi < m.vertices.size(); ++vi) { + if (vi > 0) o += ','; + o += "{\"x\":" + std::to_string(m.vertices[vi].first); + o += ",\"y\":" + std::to_string(m.vertices[vi].second) + "}"; + } + o += "]"; + } + o += ",\"sprite_index\":" + std::to_string(m.sprite_index); + o += ",\"sprite_name\":\"" + escape_json(m.sprite_name) + "\""; + o += ",\"sprite_path\":\"" + escape_json(m.sprite_path) + "\""; + o += ",\"index\":" + std::to_string(m.index); + o += "}"; + return o; + }; + + // Helper: build full sprite JSON object + auto sprite_to_json = [&](size_t i) -> std::string { + const Sprite& s = layout.sprites[i]; + const std::string& sname = sprite_names[i]; + + const int content_w = s.rotated ? s.h : s.w; + const int content_h = s.rotated ? s.w : s.h; + const int source_w = content_w + s.src_x + s.trim_right; + const int source_h = content_h + s.src_y + s.trim_bottom; + const bool has_trim = (s.src_x != 0) || (s.src_y != 0) || + (s.trim_right != 0) || (s.trim_bottom != 0); + + int unity_y = 0; + if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { + unity_y = layout.atlases[static_cast(s.atlas_index)].height - s.y - s.h; + } + + int px = has_global_pivot ? global_pivot_x : 0; + int py = has_global_pivot ? global_pivot_y : 0; + for (const auto& marker : sprite_markers[i]) { + if (marker.name == "pivot" && marker.type == "point") { + px = marker.x; + py = marker.y; + break; + } + } + + double pivot_x_norm = (source_w > 0) ? (static_cast(px) / source_w) : 0.0; + double pivot_y_norm = (source_h > 0) ? (1.0 - static_cast(py) / source_h) : 0.0; + double pivot_y_norm_raw = (source_h > 0) ? (static_cast(py) / source_h) : 0.0; + + const uint64_t nh = sprat::core::fnv1a_hash( + reinterpret_cast(sname.c_str()), sname.size()); + const std::string nh_hex = to_hex16(nh); + const std::string nh_dec = std::to_string(nh); + + std::string a_path = format_atlas_path(output_pattern_arg, s.atlas_index); + + std::string o = "{"; + o += "\"index\":" + std::to_string(i); + o += ",\"name\":\"" + escape_json(sname) + "\""; + o += ",\"path\":\"" + escape_json(s.path) + "\""; + o += ",\"atlas_index\":" + std::to_string(s.atlas_index); + o += ",\"atlas_path\":\"" + escape_json(a_path) + "\""; + o += ",\"x\":" + std::to_string(s.x); + o += ",\"y\":" + std::to_string(s.y); + o += ",\"w\":" + std::to_string(s.w); + o += ",\"h\":" + std::to_string(s.h); + o += ",\"trim_left\":" + std::to_string(s.src_x); + o += ",\"trim_top\":" + std::to_string(s.src_y); + o += ",\"trim_right\":" + std::to_string(s.trim_right); + o += ",\"trim_bottom\":" + std::to_string(s.trim_bottom); + o += ",\"has_trim\":" + std::string(has_trim ? "true" : "false"); + o += ",\"rotated\":" + std::string(s.rotated ? "true" : "false"); + o += ",\"content_w\":" + std::to_string(content_w); + o += ",\"content_h\":" + std::to_string(content_h); + o += ",\"source_w\":" + std::to_string(source_w); + o += ",\"source_h\":" + std::to_string(source_h); + o += ",\"unity_y\":" + std::to_string(unity_y); + o += ",\"pivot_x\":" + std::to_string(px); + o += ",\"pivot_y\":" + std::to_string(py); + o += ",\"pivot_x_norm\":" + format_double(pivot_x_norm); + o += ",\"pivot_y_norm\":" + format_double(pivot_y_norm); + o += ",\"pivot_y_norm_raw\":" + format_double(pivot_y_norm_raw); + o += ",\"name_hash_hex\":\"" + nh_hex + "\""; + o += ",\"name_hash_decimal\":\"" + nh_dec + "\""; + o += ",\"name_css\":\"" + escape_json(to_css_name(sname)) + "\""; + + // sprite_markers array + o += ",\"markers\":["; + const auto& sm = sprite_markers[i]; + for (size_t j = 0; j < sm.size(); ++j) { + if (j > 0) o += ','; + o += marker_to_json(sm[j]); + } + o += "]"; + + // atlas dimensions (for per-sprite access) + if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { + o += ",\"atlas_width\":" + + std::to_string(layout.atlases[static_cast(s.atlas_index)].width); + o += ",\"atlas_height\":" + + std::to_string(layout.atlases[static_cast(s.atlas_index)].height); + } else { + o += ",\"atlas_width\":0,\"atlas_height\":0"; + } + + o += "}"; + return o; + }; + + // Global hash for output_stem + const std::string& hash_source = output_pattern_arg.empty() ? output_stem : output_pattern_arg; + const uint64_t stem_hash = sprat::core::fnv1a_hash( + reinterpret_cast(hash_source.c_str()), hash_source.size()); + const std::string stem_hash_hex = to_hex16(stem_hash); + + const std::string atlas_path_0 = format_atlas_path(output_pattern_arg, 0); + const std::string atlas_stem_0 = fs::path(atlas_path_0).stem().string(); + + const int eff_fps = animation_fps > 0 ? animation_fps : DEFAULT_ANIMATION_FPS; + const int atlas_w0 = layout.atlases.empty() ? 0 : layout.atlases[0].width; + const int atlas_h0 = layout.atlases.empty() ? 0 : layout.atlases[0].height; + + std::string j = "{"; + + // Global scalars + j += "\"atlas_path\":\"" + escape_json(atlas_path_0) + "\""; + j += ",\"atlas_stem\":\"" + escape_json(atlas_stem_0) + "\""; + j += ",\"atlas_width\":" + std::to_string(atlas_w0); + j += ",\"atlas_height\":" + std::to_string(atlas_h0); + j += ",\"atlas_count\":" + std::to_string(layout.atlases.size()); + j += ",\"multipack\":" + std::string(layout.multipack ? "true" : "false"); + j += ",\"scale\":" + format_double(layout.scale); + j += ",\"extrude\":" + std::to_string(layout.extrude); + j += ",\"sprite_count\":" + std::to_string(layout.sprites.size()); + j += ",\"animation_count\":" + std::to_string(normalized_animations.size()); + j += ",\"marker_count\":" + std::to_string(marker_items.size()); + j += ",\"output_pattern\":\"" + escape_json(output_pattern_arg) + "\""; + j += ",\"output_stem\":\"" + escape_json(output_stem) + "\""; + j += ",\"output_stem_hash_hex\":\"" + stem_hash_hex + "\""; + j += ",\"has_animations\":" + std::string(normalized_animations.empty() ? "false" : "true"); + j += ",\"has_markers\":" + std::string(marker_items.empty() ? "false" : "true"); + j += ",\"animations_path\":\"" + escape_json(animations_path_arg) + "\""; + j += ",\"markers_path\":\"" + escape_json(markers_path_arg) + "\""; + j += ",\"fps\":" + std::to_string(eff_fps); + + // sprites array (all sprites flat) + j += ",\"sprites\":["; + for (size_t i = 0; i < layout.sprites.size(); ++i) { + if (i > 0) j += ','; + j += sprite_to_json(i); + } + j += "]"; + + // atlases array + j += ",\"atlases\":["; + for (size_t ai = 0; ai < layout.atlases.size(); ++ai) { + if (ai > 0) j += ','; + const auto& atlas = layout.atlases[ai]; + const std::string a_path = format_atlas_path(output_pattern_arg, static_cast(ai)); + j += "{\"index\":" + std::to_string(ai); + j += ",\"width\":" + std::to_string(atlas.width); + j += ",\"height\":" + std::to_string(atlas.height); + j += ",\"path\":\"" + escape_json(a_path) + "\""; + // sprites in this atlas + j += ",\"sprites\":["; + bool first_as = true; + for (size_t si = 0; si < layout.sprites.size(); ++si) { + if (layout.sprites[si].atlas_index == static_cast(ai)) { + if (!first_as) j += ','; + first_as = false; + j += sprite_to_json(si); + } + } + j += "]}"; + } + j += "]"; + + // animations array + j += ",\"animations\":["; + for (size_t ai = 0; ai < normalized_animations.size(); ++ai) { + if (ai > 0) j += ','; + const AnimationItem& anim = normalized_animations[ai]; + const bool is_alias = !anim.alias_source.empty(); + const int eff_anim_fps = anim.fps > 0 ? anim.fps : DEFAULT_ANIMATION_FPS; + + j += "{\"index\":" + std::to_string(ai); + j += ",\"name\":\"" + escape_json(anim.name) + "\""; + j += ",\"fps\":" + std::to_string(eff_anim_fps); + j += ",\"is_alias\":" + std::string(is_alias ? "true" : "false"); + j += ",\"alias_source\":\"" + escape_json(anim.alias_source) + "\""; + j += ",\"flip\":\"" + escape_json(anim.flip) + "\""; + + // frame_indices + j += ",\"frame_indices\":["; + for (size_t fi = 0; fi < anim.sprite_indexes.size(); ++fi) { + if (fi > 0) j += ','; + j += std::to_string(anim.sprite_indexes[fi]); + } + j += "]"; + + // duration + double dur = anim.sprite_indexes.empty() ? 0.0 + : static_cast(anim.sprite_indexes.size()) / static_cast(eff_anim_fps); + j += ",\"duration\":" + format_double(dur); + + // frames: resolved sprite info per frame + j += ",\"frames\":["; + for (size_t fi = 0; fi < anim.sprite_indexes.size(); ++fi) { + if (fi > 0) j += ','; + const int sidx = anim.sprite_indexes[fi]; + const std::string& fname = sprite_names[static_cast(sidx)]; + const uint64_t fnh = sprat::core::fnv1a_hash( + reinterpret_cast(fname.c_str()), fname.size()); + j += "{\"index\":" + std::to_string(sidx); + j += ",\"name\":\"" + escape_json(fname) + "\""; + j += ",\"name_hash_hex\":\"" + to_hex16(fnh) + "\""; + j += ",\"name_hash_decimal\":\"" + std::to_string(fnh) + "\""; + j += "}"; + } + j += "]"; + + j += "}"; + } + j += "]"; + + // global markers array + j += ",\"markers\":["; + for (size_t mi = 0; mi < marker_items.size(); ++mi) { + if (mi > 0) j += ','; + j += marker_to_json(marker_items[mi]); + } + j += "]"; + + j += "}"; + return j; +} + +// Evaluate a Jsonnet file with the given sprat JSON data. +// Returns the evaluated output string, or empty string on error (sets error). +std::string evaluate_transform( + const fs::path& transform_path, + const std::string& sprat_json, + std::string& error) +{ + jsonnet::Jsonnet vm; + if (!vm.init()) { + error = "Failed to initialize Jsonnet VM"; + return ""; + } + vm.bindExtCodeVar("sprat", sprat_json); + // Always add the built-in transforms directory to the import path so that + // `import "sprat.libsonnet"` resolves from custom transforms outside that dir. + const fs::path transforms_dir = find_transforms_dir(); + if (!transforms_dir.empty()) + vm.addImportPath(transforms_dir.string()); + std::string output; + bool ok = vm.evaluateFile(transform_path.string(), &output); + if (!ok) { + error = vm.lastError(); + return ""; + } + return output; +} + +// Minimal result returned by a Jsonnet transform. +struct TransformResult { + std::string name; + std::string description; + std::string extension; + // Exactly one of content or files is populated: + std::string content; // single-file mode + struct FileEntry { std::string filename; std::string content; }; + std::vector files; // multi-file mode +}; + +// Unescape a JSON string (assumes well-formed JSON produced by Jsonnet). +static std::string json_unescape_string(const std::string& src, size_t start, size_t end) { + std::string out; + out.reserve(end - start); + for (size_t i = start; i < end; ) { + if (src[i] == '\\' && i + 1 < end) { + char next = src[i + 1]; + switch (next) { + case '"': out += '"'; i += 2; break; + case '\\': out += '\\'; i += 2; break; + case '/': out += '/'; i += 2; break; + case 'b': out += '\b'; i += 2; break; + case 'f': out += '\f'; i += 2; break; + case 'n': out += '\n'; i += 2; break; + case 'r': out += '\r'; i += 2; break; + case 't': out += '\t'; i += 2; break; + case 'u': { + if (i + 5 < end) { + unsigned int cp = 0; + for (int k = 0; k < 4; ++k) { + char h = src[i + 2 + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + } + // Encode code point as UTF-8 + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + i += 6; + } else { + out += src[i]; + ++i; + } + break; + } + default: + out += src[i]; + ++i; + break; + } + } else { + out += src[i]; + ++i; + } + } + return out; +} + +// Find and extract the raw content of a JSON string value for a given key. +// Returns true and sets value_start/value_end (the range inside the quotes) if found. +static bool find_json_string_value(const std::string& json, const std::string& key, + size_t& value_start, size_t& value_end) { + const std::string needle = "\"" + key + "\""; + size_t pos = 0; + while (pos < json.size()) { + size_t kp = json.find(needle, pos); + if (kp == std::string::npos) return false; + pos = kp + needle.size(); + // skip whitespace and colon + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t' || + json[pos] == '\n' || json[pos] == '\r')) ++pos; + if (pos >= json.size() || json[pos] != ':') continue; + ++pos; + while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t' || + json[pos] == '\n' || json[pos] == '\r')) ++pos; + if (pos >= json.size() || json[pos] != '"') continue; + ++pos; + value_start = pos; + // scan to end of string value + while (pos < json.size()) { + if (json[pos] == '\\') { + pos += 2; + } else if (json[pos] == '"') { + value_end = pos; + return true; + } else { + ++pos; + } + } + } + return false; +} + +// Parse the JSON object output from a Jsonnet transform evaluation. +bool parse_transform_result(const std::string& json, TransformResult& out, std::string& error) { + // Extract name + size_t vs = 0, ve = 0; + if (find_json_string_value(json, "name", vs, ve)) { + out.name = json_unescape_string(json, vs, ve); + } + if (find_json_string_value(json, "description", vs, ve)) { + out.description = json_unescape_string(json, vs, ve); + } + if (find_json_string_value(json, "extension", vs, ve)) { + out.extension = json_unescape_string(json, vs, ve); + } else { + error = "Transform result missing required field: extension"; + return false; + } + + // Check for "files" array first — if present we're in multi-file mode and must + // NOT try to extract "content" from the top level, because "content" also appears + // as a key inside each files[] entry and find_json_string_value would match that. + const std::string files_key = "\"files\""; + size_t fp = json.find(files_key); + if (fp != std::string::npos) { + fp += files_key.size(); + // skip to '[' + while (fp < json.size() && json[fp] != '[') ++fp; + if (fp >= json.size()) { + error = "Malformed files array in transform result"; + return false; + } + ++fp; // skip '[' + while (fp < json.size()) { + // skip whitespace + while (fp < json.size() && (json[fp] == ' ' || json[fp] == '\t' || + json[fp] == '\n' || json[fp] == '\r' || json[fp] == ',')) ++fp; + if (fp >= json.size() || json[fp] == ']') break; + if (json[fp] != '{') { ++fp; continue; } + // find filename and content within this object + size_t obj_end = fp; + int depth = 0; + while (obj_end < json.size()) { + if (json[obj_end] == '{') ++depth; + else if (json[obj_end] == '}') { --depth; if (depth == 0) { ++obj_end; break; } } + else if (json[obj_end] == '"') { + ++obj_end; + while (obj_end < json.size() && json[obj_end] != '"') { + if (json[obj_end] == '\\') ++obj_end; + ++obj_end; + } + } + ++obj_end; + } + std::string obj_str = json.substr(fp, obj_end - fp); + TransformResult::FileEntry entry; + size_t fvs = 0, fve = 0; + if (find_json_string_value(obj_str, "filename", fvs, fve)) { + entry.filename = json_unescape_string(obj_str, fvs, fve); + } + if (find_json_string_value(obj_str, "content", fvs, fve)) { + entry.content = json_unescape_string(obj_str, fvs, fve); + } + if (!entry.filename.empty()) { + out.files.push_back(std::move(entry)); + } + fp = obj_end; + } + return true; + } + + // No "files" key — check for top-level "content" (single-file mode). + if (find_json_string_value(json, "content", vs, ve)) { + out.content = json_unescape_string(json, vs, ve); + return true; + } + + error = "Transform result must have either 'content' or 'files' field"; + return false; } fs::path resolve_transform_path(const std::string& transform_arg) { fs::path candidate(transform_arg); - if (candidate.has_parent_path() || candidate.extension() == ".transform") { + if (candidate.has_parent_path() || candidate.extension() == ".jsonnet") { return candidate; } - return find_transforms_dir() / (transform_arg + ".transform"); + return find_transforms_dir() / (transform_arg + ".jsonnet"); } -bool load_transform_by_name(const std::string& transform_arg, Transform& out, std::string& error) { - fs::path transform_path = resolve_transform_path(transform_arg); - return parse_transform_file(transform_path, out, error); +std::vector discover_group_transforms(const std::string& group_name, + const fs::path& transforms_dir) { + std::vector members; + const std::string prefix = group_name + "."; + std::error_code ec; + for (const auto& entry : fs::directory_iterator(transforms_dir, ec)) { + if (ec) break; + if (entry.path().extension() != ".jsonnet") continue; + const std::string stem = entry.path().stem().string(); + if (stem.size() <= prefix.size()) continue; + if (stem.substr(0, prefix.size()) != prefix) continue; + members.push_back({stem.substr(prefix.size()), entry.path()}); + } + std::sort(members.begin(), members.end(), + [](const GroupMember& a, const GroupMember& b) { + return a.variant < b.variant; + }); + return members; } void list_transforms() { @@ -1280,73 +1153,84 @@ void list_transforms() { std::vector paths; for (const auto& entry : fs::directory_iterator(dir)) { - if (!entry.is_regular_file()) { - continue; - } - if (entry.path().extension() == ".transform") { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() == ".jsonnet") { + // Skip group members (name.variant.jsonnet); only list top-level names + const std::string stem = entry.path().stem().string(); + // A group member has the form "group.variant"; skip it + // We list it only if it has no dot, or if no group prefix exists. + // Simple heuristic: skip stems containing a dot. + if (stem.find('.') != std::string::npos) continue; paths.push_back(entry.path()); } } + // Also include group names (unique group prefixes from group.variant.jsonnet files) + std::vector group_names_seen; + for (const auto& entry : fs::directory_iterator(dir)) { + if (!entry.is_regular_file()) continue; + if (entry.path().extension() != ".jsonnet") continue; + const std::string stem = entry.path().stem().string(); + const auto dot_pos = stem.find('.'); + if (dot_pos == std::string::npos) continue; + const std::string grp = stem.substr(0, dot_pos); + if (std::find(group_names_seen.begin(), group_names_seen.end(), grp) + == group_names_seen.end()) { + group_names_seen.push_back(grp); + } + } + std::ranges::sort(paths); + // Empty mock data for listing + const std::string mock_json = R"({"sprites":[],"animations":[],"atlases":[],"markers":[],)" + R"("atlas_path":"","atlas_stem":"","atlas_width":0,"atlas_height":0,"atlas_count":0,)" + R"("multipack":false,"scale":1,"extrude":0,"sprite_count":0,"animation_count":0,)" + R"("marker_count":0,"output_pattern":"","output_stem":"","output_stem_hash_hex":"0000000000000000",)" + R"("has_animations":false,"has_markers":false,"animations_path":"","markers_path":"","fps":8})"; + for (const auto& path : paths) { - Transform t; - std::string error; - if (!parse_transform_file(path, t, error)) { - std::cerr << tr("Warning: ") << error << "\n"; + std::string eval_error; + std::string output = evaluate_transform(path, mock_json, eval_error); + if (output.empty()) { + std::cerr << tr("Warning: ") << eval_error << "\n"; continue; } - std::cout << t.name; - if (!t.description.empty()) { - std::cout << " - " << t.description; + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << tr("Warning: ") << parse_error << "\n"; + continue; } - std::cout << "\n"; - } -} - -bool is_digit(char c) { return c >= '0' && c <= '9'; } - -std::string get_animation_name(const std::string& name) { - std::string anim_name = name; - while (!anim_name.empty()) { - char back = anim_name.back(); - if (is_digit(back) || back == '_' || back == '-' || back == ' ' || back == '.' || back == '(' || back == ')') { - anim_name.pop_back(); - } else { - break; + std::cout << result.name; + if (!result.description.empty()) { + std::cout << " - " << result.description; } + std::cout << "\n"; } - return anim_name; -} - -std::string format_atlas_path(const std::string& pattern, int index) { - if (pattern.empty()) { - return ""; - } - std::string out; - std::string error; - if (!format_index_pattern(pattern, index, out, error)) { - return ""; + // Print group names + for (const auto& grp : group_names_seen) { + std::cout << grp << " (group)\n"; } - return out; } void print_usage() { std::cout << tr("Usage: spratconvert [OPTIONS]\n") << tr("\n") << tr("Read layout text from stdin and transform it into other formats.\n") - << tr("Unsuffixed placeholders are auto-encoded based on transform output type.\n") << tr("\n") << tr("Options:\n") << tr(" --transform NAME|PATH Transform name or path (default: json)\n") - << tr(" --output, -o PATTERN Atlas path pattern for atlas_* placeholders\n") + << tr(" --atlas, -a PATTERN Atlas path pattern for atlas_* placeholders\n") + << tr(" --output-dir PATH Write output to PATH/{variant}{extension} instead of stdout\n") << tr(" --list-transforms Print available transforms and exit\n") + << tr(" --transforms-dir Print the transforms directory and exit\n") << tr(" --markers PATH Load external markers file\n") << tr(" --animations PATH Load external animations file\n") << tr(" --auto-animations Group frames into animations by name pattern\n") << tr(" --help, -h Show this help message\n") << tr(" --version, -v Show version\n"); } + } // namespace int run_spratconvert(int argc, char** argv) { @@ -1360,15 +1244,19 @@ int run_spratconvert(int argc, char** argv) { std::string markers_path_arg; std::string animations_path_arg; std::string output_pattern_arg; + std::string output_dir_arg; bool list_only = false; + bool show_transforms_dir = false; bool auto_animations = 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 == "--output" || arg == "-o") && i + 1 < argc) { + } else if ((arg == "--atlas" || arg == "-a" || arg == "--output" || arg == "-o") && i + 1 < argc) { output_pattern_arg = argv[++i]; + } else if (arg == "--output-dir" && i + 1 < argc) { + output_dir_arg = argv[++i]; } else if (arg == "--markers" && i + 1 < argc) { markers_path_arg = argv[++i]; } else if (arg == "--animations" && i + 1 < argc) { @@ -1377,6 +1265,8 @@ int run_spratconvert(int argc, char** argv) { auto_animations = true; } else if (arg == "--list-transforms") { list_only = true; + } else if (arg == "--transforms-dir") { + show_transforms_dir = true; } else if (arg == "--help" || arg == "-h") { print_usage(); return 0; @@ -1389,25 +1279,21 @@ int run_spratconvert(int argc, char** argv) { } } - if (list_only) { - list_transforms(); + if (show_transforms_dir) { + std::cout << find_transforms_dir().string() << "\n"; return 0; } - Transform transform; - std::string transform_error; - if (!load_transform_by_name(transform_arg, transform, transform_error)) { - std::cerr << transform_error << "\n"; - return 1; + if (list_only) { + list_transforms(); + return 0; } - const PlaceholderEncoding placeholder_encoding = - detect_placeholder_encoding(transform, transform_arg); + // Read stdin and parse layout std::string input_text; { - std::ostringstream buffer; - buffer << std::cin.rdbuf(); - input_text = buffer.str(); + input_text.assign(std::istreambuf_iterator(std::cin), + std::istreambuf_iterator()); } std::istringstream layout_iss(input_text); Layout layout; @@ -1458,18 +1344,18 @@ int run_spratconvert(int argc, char** argv) { 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); + parse_markers_data(markers_text, layout, sprite_index_by_path, sprite_index_by_name, + sprite_names, sprite_markers); int animation_fps = -1; std::vector animation_items = - parse_animations_data(animations_text, sprite_index_by_path, sprite_index_by_name, animation_fps); + parse_animations_data(animations_text, sprite_index_by_path, sprite_index_by_name, + animation_fps); if (auto_animations) { std::map> grouped; for (size_t i = 0; i < sprite_names.size(); ++i) { std::string anim_name = get_animation_name(sprite_names[i]); - if (anim_name.empty()) { - continue; - } + if (anim_name.empty()) continue; grouped[anim_name].push_back(static_cast(i)); } for (auto const& [name, frames] : grouped) { @@ -1485,8 +1371,8 @@ int run_spratconvert(int argc, char** argv) { } 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 normalized_animations = animation_items; + for (AnimationItem& item : normalized_animations) { std::vector filtered; filtered.reserve(item.sprite_indexes.size()); for (int idx : item.sprite_indexes) { @@ -1509,239 +1395,141 @@ int run_spratconvert(int argc, char** argv) { } } - std::map global_vars; - if (!layout.atlases.empty()) { - global_vars["atlas_width"] = std::to_string(layout.atlases[0].width); - global_vars["atlas_height"] = std::to_string(layout.atlases[0].height); - } else { - global_vars["atlas_width"] = "0"; - global_vars["atlas_height"] = "0"; - } - global_vars["atlas_count"] = std::to_string(layout.atlases.size()); - global_vars["multipack"] = layout.multipack ? "true" : "false"; - global_vars["output_pattern"] = output_pattern_arg; - - std::ostringstream atlases_oss; - if (placeholder_encoding == PlaceholderEncoding::json) { - atlases_oss << "["; - for (size_t i = 0; i < layout.atlases.size(); ++i) { - if (i > 0) atlases_oss << ","; - std::string a_path = format_atlas_path(output_pattern_arg, static_cast(i)); - atlases_oss << "{\"width\":" << layout.atlases[i].width << ",\"height\":" << layout.atlases[i].height; - if (!a_path.empty()) { - atlases_oss << ",\"path\":\"" << escape_json(a_path) << "\""; - } - atlases_oss << "}"; - } - atlases_oss << "]"; - } else if (placeholder_encoding == PlaceholderEncoding::xml) { - for (size_t i = 0; i < layout.atlases.size(); ++i) { - std::string a_path = format_atlas_path(output_pattern_arg, static_cast(i)); - atlases_oss << "\n"; - } - } - global_vars["atlases"] = atlases_oss.str(); - global_vars["scale"] = format_double(layout.scale); - global_vars["extrude"] = std::to_string(layout.extrude); - 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 : DEFAULT_ANIMATION_FPS); - 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["animations_raw"] = animations_text; - if (!transform.header.empty()) { - std::cout << replace_tokens(transform.header, global_vars, placeholder_encoding); + // Mode detection: group vs single + const bool has_dot = transform_arg.find('.') != std::string::npos; + bool group_mode = false; + std::vector group_members; + if (!output_dir_arg.empty() && !has_dot) { + group_members = discover_group_transforms(transform_arg, find_transforms_dir()); + group_mode = !group_members.empty(); } - auto populate_marker_vars = [&](std::map& vars, const MarkerItem& marker, size_t index) { - vars["marker_index"] = std::to_string(index); - vars["marker_name"] = marker.name; - vars["marker_type"] = 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"] = format_vertices(marker.vertices, placeholder_encoding); - vars["marker_sprite_index"] = std::to_string(marker.sprite_index); - vars["marker_sprite_name"] = marker.sprite_name; - vars["marker_sprite_path"] = marker.sprite_path; + // Helper to compute output_stem from a transform path + auto compute_output_stem = [](const std::string& targ) -> std::string { + const std::string stem_str = resolve_transform_path(targ).stem().string(); + std::string stem = extract_variant(stem_str); + if (stem.empty()) stem = stem_str; + return stem; }; - auto populate_sprite_vars = [&](std::map& vars, size_t i) { - const Sprite& s = layout.sprites[i]; - vars["index"] = std::to_string(i); - vars["atlas_index"] = std::to_string(s.atlas_index); - std::string a_path = format_atlas_path(output_pattern_arg, s.atlas_index); - vars["atlas_path"] = a_path; - if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { - vars["atlas_width"] = std::to_string(layout.atlases[static_cast(s.atlas_index)].width); - vars["atlas_height"] = std::to_string(layout.atlases[static_cast(s.atlas_index)].height); - } - vars["path"] = s.path; - vars["name"] = sprite_names[i]; - 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["pivot_x"] = has_global_pivot ? std::to_string(global_pivot_x) : "0"; - vars["pivot_y"] = has_global_pivot ? std::to_string(global_pivot_y) : "0"; - for (const auto& marker : sprite_markers[i]) { - if (marker.name == "pivot" && marker.type == "point") { - vars["pivot_x"] = std::to_string(marker.x); - vars["pivot_y"] = std::to_string(marker.y); - break; + // Helper to write a single TransformResult to a destination + auto write_result = [&](const TransformResult& result, + const std::string& out_dir, + const std::string& file_stem, + std::ostream* stdout_out) -> int { + if (!result.files.empty()) { + // Multi-file mode: requires --output-dir + if (out_dir.empty()) { + std::cerr << tr("Transform produces multiple files; use --output-dir\n"); + return 1; } - } - 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["markers_json"] = format_markers_json(sprite_markers[i]); // Shortcut for quick JSON inclusion - - if (!transform.sprite_marker.empty()) { - std::string sprite_markers_formatted; - if (!sprite_markers[i].empty()) { - if (!transform.sprite_markers_header.empty()) { - sprite_markers_formatted += replace_tokens(transform.sprite_markers_header, vars, placeholder_encoding); - } - for (size_t j = 0; j < sprite_markers[i].size(); ++j) { - if (j > 0 && !transform.sprite_markers_separator.empty()) { - sprite_markers_formatted += replace_tokens(transform.sprite_markers_separator, vars, placeholder_encoding); - } - std::map mvars = vars; - populate_marker_vars(mvars, sprite_markers[i][j], j); - sprite_markers_formatted += replace_tokens(transform.sprite_marker, mvars, placeholder_encoding); - } - if (!transform.sprite_markers_footer.empty()) { - sprite_markers_formatted += replace_tokens(transform.sprite_markers_footer, vars, placeholder_encoding); + std::error_code ec; + fs::create_directories(out_dir, ec); + if (ec) { + std::cerr << tr("Failed to create output directory: ") << ec.message() << "\n"; + return 1; + } + int exit_code = 0; + for (const auto& fe : result.files) { + const fs::path out_path = fs::path(out_dir) / fe.filename; + std::ofstream out_file(out_path, std::ios::binary); + if (!out_file) { + std::cerr << tr("Failed to open output file: ") << out_path.string() << "\n"; + exit_code = 1; + continue; } + out_file << fe.content; } - vars["sprite_markers"] = sprite_markers_formatted; + return exit_code; } - vars["rotation"] = s.rotated ? "90" : "0"; - vars["rotated"] = s.rotated ? "true" : "false"; - }; - - if (!marker_items.empty()) { - if (!transform.if_markers.empty()) { - std::cout << replace_tokens(transform.if_markers, global_vars, placeholder_encoding); - } - if (!transform.markers_header.empty()) { - std::cout << replace_tokens(transform.markers_header, global_vars, placeholder_encoding); - } - 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, placeholder_encoding); - } - std::map vars = global_vars; - populate_marker_vars(vars, marker_items[i], i); - std::cout << replace_tokens(transform.markers, vars, placeholder_encoding); + // Single content mode + if (!out_dir.empty()) { + std::error_code ec; + fs::create_directories(out_dir, ec); + if (ec) { + std::cerr << tr("Failed to create output directory: ") << ec.message() << "\n"; + return 1; + } + const std::string out_filename = file_stem + result.extension; + const fs::path out_path = fs::path(out_dir) / out_filename; + std::ofstream out_file(out_path, std::ios::binary); + if (!out_file) { + std::cerr << tr("Failed to open output file: ") << out_path.string() << "\n"; + return 1; } + out_file << result.content; + return 0; } - if (!transform.markers_footer.empty()) { - std::cout << replace_tokens(transform.markers_footer, global_vars, placeholder_encoding); + + if (stdout_out) { + *stdout_out << result.content; } - } else if (!transform.if_no_markers.empty()) { - std::cout << replace_tokens(transform.if_no_markers, global_vars, placeholder_encoding); - } + return 0; + }; - if (!transform.atlas.empty()) { - if (!transform.atlas_header.empty()) { - std::cout << replace_tokens(transform.atlas_header, global_vars, placeholder_encoding); + if (group_mode) { + std::error_code ec; + fs::create_directories(output_dir_arg, ec); + if (ec) { + std::cerr << tr("Failed to create output directory: ") << ec.message() << "\n"; + return 1; } - for (size_t i = 0; i < layout.atlases.size(); ++i) { - if (i > 0 && !transform.atlas_separator.empty()) { - std::cout << replace_tokens(transform.atlas_separator, global_vars, placeholder_encoding); - } - std::map avars = global_vars; - avars["atlas_index"] = std::to_string(i); - avars["atlas_width"] = std::to_string(layout.atlases[i].width); - avars["atlas_height"] = std::to_string(layout.atlases[i].height); - std::string a_path = format_atlas_path(output_pattern_arg, static_cast(i)); - avars["atlas_path"] = a_path; - avars["atlas_path_json"] = escape_json(a_path); - avars["atlas_path_xml"] = escape_xml(a_path); - avars["atlas_path_csv"] = escape_csv(a_path); - avars["atlas_path_css"] = escape_css_string(a_path); - - std::string sprites_in_atlas; - for (size_t j = 0; j < layout.sprites.size(); ++j) { - if (layout.sprites[j].atlas_index == (int)i) { - if (!sprites_in_atlas.empty() && !transform.separator.empty()) { - sprites_in_atlas += replace_tokens(transform.separator, avars, placeholder_encoding); - } - std::map svars = avars; - populate_sprite_vars(svars, j); - sprites_in_atlas += replace_tokens(transform.sprite, svars, placeholder_encoding); - } + int exit_code = 0; + for (const GroupMember& member : group_members) { + const std::string sprat_json = build_sprat_json( + layout, sprite_names, marker_items, normalized_animations, sprite_markers, + global_pivot_x, global_pivot_y, has_global_pivot, + output_pattern_arg, member.variant, + markers_path_arg, animations_path_arg, animation_fps); + + std::string eval_error; + std::string output = evaluate_transform(member.path, sprat_json, eval_error); + if (output.empty() && !eval_error.empty()) { + std::cerr << eval_error << "\n"; + exit_code = 1; + continue; } - avars["sprites"] = sprites_in_atlas; - std::cout << replace_tokens(transform.atlas, avars, placeholder_encoding); - } - if (!transform.atlas_footer.empty()) { - std::cout << replace_tokens(transform.atlas_footer, global_vars, placeholder_encoding); - } - } else { - for (size_t i = 0; i < layout.sprites.size(); ++i) { - if (i > 0 && !transform.separator.empty()) { - std::cout << replace_tokens(transform.separator, global_vars, placeholder_encoding); + + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << parse_error << "\n"; + exit_code = 1; + continue; } - std::map vars = global_vars; - populate_sprite_vars(vars, i); - std::cout << replace_tokens(transform.sprite, vars, placeholder_encoding); + + const int r = write_result(result, output_dir_arg, member.variant, nullptr); + if (r != 0) exit_code = r; } + return exit_code; } - if (!normalized_animation_items.empty()) { - if (!transform.if_animations.empty()) { - std::cout << replace_tokens(transform.if_animations, global_vars, placeholder_encoding); - } - if (!transform.animations_header.empty()) { - std::cout << replace_tokens(transform.animations_header, global_vars, placeholder_encoding); - } - 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, placeholder_encoding); - } - 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_sprite_count"] = std::to_string(animation.sprite_indexes.size()); - vars["sprite_indexes"] = format_sprite_indexes(animation.sprite_indexes, placeholder_encoding); - vars["fps"] = std::to_string(animation.fps); - vars["animation_fps"] = vars["fps"]; - std::cout << replace_tokens(transform.animations, vars, placeholder_encoding); - } - } - if (!transform.animations_footer.empty()) { - std::cout << replace_tokens(transform.animations_footer, global_vars, placeholder_encoding); - } - } else if (!transform.if_no_animations.empty()) { - std::cout << replace_tokens(transform.if_no_animations, global_vars, placeholder_encoding); + // Single transform mode + const fs::path transform_path = resolve_transform_path(transform_arg); + const std::string output_stem = !output_dir_arg.empty() + ? compute_output_stem(transform_arg) + : ""; + + const std::string sprat_json = build_sprat_json( + layout, sprite_names, marker_items, normalized_animations, sprite_markers, + global_pivot_x, global_pivot_y, has_global_pivot, + output_pattern_arg, output_stem, + markers_path_arg, animations_path_arg, animation_fps); + + std::string eval_error; + std::string output = evaluate_transform(transform_path, sprat_json, eval_error); + if (output.empty() && !eval_error.empty()) { + std::cerr << eval_error << "\n"; + return 1; } - if (!transform.footer.empty()) { - std::cout << replace_tokens(transform.footer, global_vars, placeholder_encoding); + TransformResult result; + std::string parse_error; + if (!parse_transform_result(output, result, parse_error)) { + std::cerr << parse_error << "\n"; + return 1; } - return 0; + return write_result(result, output_dir_arg, output_stem, &std::cout); } diff --git a/src/commands/spratframes_command.cpp b/src/commands/spratframes_command.cpp index 16c684a..7a5629f 100644 --- a/src/commands/spratframes_command.cpp +++ b/src/commands/spratframes_command.cpp @@ -36,7 +36,6 @@ namespace fs = std::filesystem; #include #include #include -#include #include "core/cli_parse.h" #include "core/i18n.h" @@ -137,6 +136,9 @@ class SpriteFramesDetector { std::vector component_labels_; std::vector component_bounds_; std::vector component_sizes_; + + // Reusable flood fill stack + std::vector> fill_stack_; // Rectangle detection std::vector detected_rectangles_; @@ -257,45 +259,45 @@ class SpriteFramesDetector { } Rectangle flood_fill_rectangle(int start_x, int start_y, std::vector& visited) { - Rectangle bounds{.x=start_x, .y=start_y, .w=1, .h=1}; - std::queue> queue; - queue.emplace(start_x, start_y); + fill_stack_.clear(); + fill_stack_.emplace_back(start_x, start_y); visited.at((static_cast(start_y) * width_) + start_x) = 1; - + int min_x = start_x; int max_x = start_x; int min_y = start_y; int max_y = start_y; - - const std::array dx = {-1, 1, 0, 0}; - const std::array dy = {0, 0, -1, 1}; - - while (!queue.empty()) { - auto [x, y] = queue.front(); - queue.pop(); - + + constexpr std::array dx = {-1, 1, 0, 0}; + constexpr std::array dy = {0, 0, -1, 1}; + + while (!fill_stack_.empty()) { + auto [x, y] = fill_stack_.back(); + fill_stack_.pop_back(); + min_x = std::min(min_x, x); max_x = std::max(max_x, x); min_y = std::min(min_y, y); max_y = std::max(max_y, y); - + for (size_t i = 0; i < 4; ++i) { - int nx = x + dx.at(i); - int ny = y + dy.at(i); - + int nx = x + dx[i]; + int ny = y + dy[i]; + if (nx >= 0 && nx < width_ && ny >= 0 && ny < height_ && (visited.at((static_cast(ny) * width_) + nx) == 0U) && is_rectangle_pixel(nx, ny)) { visited.at((static_cast(ny) * width_) + nx) = 1; - queue.emplace(nx, ny); + fill_stack_.emplace_back(nx, ny); } } } - + + Rectangle bounds{}; bounds.x = min_x; bounds.y = min_y; bounds.w = max_x - min_x + 1; bounds.h = max_y - min_y + 1; - + return bounds; } @@ -442,47 +444,47 @@ class SpriteFramesDetector { } int flood_fill_component(int start_x, int start_y, int component_id, Rectangle& bounds) { - std::queue> queue; - queue.emplace(start_x, start_y); + fill_stack_.clear(); + fill_stack_.emplace_back(start_x, start_y); component_labels_.at((static_cast(start_y) * width_) + start_x) = component_id; - + int min_x = start_x; int max_x = start_x; int min_y = start_y; int max_y = start_y; int size = 0; - - const std::array dx = {-1, 1, 0, 0, -1, 1, -1, 1}; - const std::array dy = {0, 0, -1, 1, -1, -1, 1, 1}; - - while (!queue.empty()) { - auto [x, y] = queue.front(); - queue.pop(); + + constexpr std::array dx = {-1, 1, 0, 0, -1, 1, -1, 1}; + constexpr std::array dy = {0, 0, -1, 1, -1, -1, 1, 1}; + + while (!fill_stack_.empty()) { + auto [x, y] = fill_stack_.back(); + fill_stack_.pop_back(); size++; - + min_x = std::min(min_x, x); max_x = std::max(max_x, x); min_y = std::min(min_y, y); max_y = std::max(max_y, y); - + for (size_t i = 0; i < dx.size(); ++i) { - int nx = x + dx.at(i); - int ny = y + dy.at(i); - + int nx = x + dx[i]; + int ny = y + dy[i]; + if (nx >= 0 && nx < width_ && ny >= 0 && ny < height_ && component_labels_.at((static_cast(ny) * width_) + nx) == -1 && (is_sprite_pixel(nx, ny) || is_near_sprite_pixel(nx, ny))) { component_labels_.at((static_cast(ny) * width_) + nx) = component_id; - queue.emplace(nx, ny); + fill_stack_.emplace_back(nx, ny); } } } - + bounds.x = min_x; bounds.y = min_y; bounds.w = max_x - min_x + 1; bounds.h = max_y - min_y + 1; - + return size; } diff --git a/src/commands/spratlayout_command.cpp b/src/commands/spratlayout_command.cpp index d0e3755..73ed414 100644 --- a/src/commands/spratlayout_command.cpp +++ b/src/commands/spratlayout_command.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #ifndef STDIN_FILENO #define STDIN_FILENO 0 #endif @@ -23,6 +24,12 @@ #endif #endif +#ifdef _MSC_VER +static inline int popcount64(unsigned long long x) { return (int)__popcnt64(x); } +#else +static inline int popcount64(unsigned long long x) { return __builtin_popcountll(x); } +#endif + #include #include #include @@ -32,6 +39,7 @@ namespace fs = std::filesystem; #include #include +#include #include #include #include @@ -49,6 +57,8 @@ namespace fs = std::filesystem; #include #include #include +#include +#include #include #include #include @@ -84,19 +94,33 @@ std::string trim_copy(const std::string& s) { return s.substr(start, end - start); } -enum class Mode : std::uint8_t { POT, COMPACT, FAST }; +std::string normalize_path_key(const fs::path& path) { + std::error_code ec; + fs::path absolute = fs::absolute(path, ec); + if (!ec) { + return absolute.lexically_normal().string(); + } + return path.lexically_normal().string(); +} + +bool parse_quoted_path_argument(std::string_view input, size_t& pos, std::string& out) { + std::string error; + return sprat::core::parse_quoted(input, pos, out, error); +} + +enum class Mode : std::uint8_t { POT, COMPACT, FAST, GRID }; enum class OptimizeTarget : std::uint8_t { GPU, SPACE }; enum class ResolutionReference : std::uint8_t { Largest, Smallest }; struct ProfileDefinition { std::string name; + std::string label; Mode mode = Mode::COMPACT; OptimizeTarget optimize_target = OptimizeTarget::GPU; std::optional max_width; std::optional max_height; std::optional padding; std::optional extrude; - std::optional max_combinations; std::optional scale; std::optional trim_transparent; std::optional rotate; @@ -108,14 +132,12 @@ struct ProfileDefinition { }; 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_extrude = 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; @@ -141,9 +163,9 @@ constexpr size_t k_ustar_sig_required_len = k_tar_magic_offset + 6; constexpr int k_floating_point_precision = 17; constexpr int k_search_step_divisor = 24; constexpr int k_search_step_min = 8; -constexpr size_t k_guided_offsets_count = 11; +constexpr size_t k_guided_offsets_count = 15; constexpr std::array k_guided_search_offsets = { - 0, -1, 1, -2, 2, -4, 4, -8, 8, -12, 12 + 0, -1, 1, -2, 2, -4, 4, -8, 8, -12, 12, -16, 16, -20, 20 }; constexpr size_t k_sort_mode_count = 6; constexpr size_t k_sort_mode_index_area = 0; @@ -160,7 +182,7 @@ enum class RectHeuristic : std::uint8_t { }; constexpr size_t k_rect_heuristic_count = 3; -constexpr size_t k_guided_anchor_count = 3; +constexpr size_t k_guided_anchor_count = 4; constexpr size_t k_guided_sort_mode_count = 4; constexpr std::array k_guided_sort_indices = { k_sort_mode_index_height, @@ -168,10 +190,11 @@ constexpr std::array k_guided_sort_indices = { k_sort_mode_index_maxside, k_sort_mode_index_none }; -constexpr size_t k_guided_heuristic_count = 2; +constexpr size_t k_guided_heuristic_count = 3; constexpr std::array k_guided_heuristics = { RectHeuristic::BestShortSideFit, - RectHeuristic::BestAreaFit + RectHeuristic::BestAreaFit, + RectHeuristic::BottomLeft }; constexpr long long k_cache_max_age_seconds = 3600; constexpr size_t k_cache_max_layout_files = 16; @@ -206,6 +229,10 @@ bool parse_mode_from_string(const std::string& value, Mode& out, std::string& er out = Mode::FAST; return true; } + if (lower == "grid") { + out = Mode::GRID; + return true; + } error = "invalid mode '" + value + "'"; return false; } @@ -224,6 +251,32 @@ bool parse_optimize_target_from_string(const std::string& value, OptimizeTarget& return false; } +struct PresetDefinition { + Mode mode; + OptimizeTarget optimize_target; +}; + +bool parse_preset_from_string(const std::string& value, PresetDefinition& out) { + std::string lower = to_lower_copy(value); + if (lower == "fast") { + out = {Mode::FAST, OptimizeTarget::GPU}; + return true; + } + if (lower == "quality") { + out = {Mode::COMPACT, OptimizeTarget::GPU}; + return true; + } + if (lower == "small") { + out = {Mode::COMPACT, OptimizeTarget::SPACE}; + return true; + } + if (lower == "pot") { + out = {Mode::POT, OptimizeTarget::GPU}; + return true; + } + return false; +} + bool parse_resolution_reference_from_string(const std::string& value, ResolutionReference& out, std::string& error) { @@ -443,13 +496,6 @@ bool parse_profiles_config(std::istream& input, return false; } current->extrude = parsed_extrude; - } 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)) { @@ -512,6 +558,8 @@ bool parse_profiles_config(std::istream& input, return false; } current->resolution_reference = ref; + } else if (lower_key == "label") { + current->label = value; } else { error = "unknown key '" + key + "' at line " + std::to_string(line_number); return false; @@ -541,8 +589,8 @@ bool load_profiles_config_from_file(const fs::path& path, } std::optional resolve_user_profiles_config_path() { - // 1. Windows: %APPDATA%\sprat or %LOCALAPPDATA%\sprat #ifdef _WIN32 + // Windows: %APPDATA%\sprat\spratprofiles.cfg static const char* const envs[] = {"APPDATA", "LOCALAPPDATA"}; for (const char* env : envs) { const char* val = std::getenv(env); @@ -554,46 +602,52 @@ std::optional resolve_user_profiles_config_path() { } } } -#endif - + return std::nullopt; +#elif defined(__APPLE__) + // macOS: ~/Library/Application Support/sprat/spratprofiles.cfg const char* home = std::getenv("HOME"); if (home == nullptr || home[0] == '\0') { return std::nullopt; } - - // 2. macOS: ~/Library/Preferences/sprat/spratprofiles.cfg -#ifdef __APPLE__ - const fs::path mac_cfg = fs::path(home) / "Library" / "Preferences" / "sprat" / k_profiles_config_filename; + const fs::path mac_cfg = fs::path(home) / "Library" / "Application Support" / "sprat" / k_profiles_config_filename; std::error_code ec_mac; if (fs::exists(mac_cfg, ec_mac) && !ec_mac) { return mac_cfg; } -#endif - - // 3. Others (Linux): ~/.config/sprat/spratprofiles.cfg - const fs::path home_cfg = fs::path(home) / k_user_profiles_config_relpath; + return std::nullopt; +#else + // Linux/other: $XDG_CONFIG_HOME/sprat/spratprofiles.cfg (default ~/.config/sprat/) + const char* home = std::getenv("HOME"); + if (home == nullptr || home[0] == '\0') { + return std::nullopt; + } + const char* xdg_config_home = std::getenv("XDG_CONFIG_HOME"); + const fs::path cfg = (xdg_config_home != nullptr && xdg_config_home[0] != '\0') + ? fs::path(xdg_config_home) / "sprat" / k_profiles_config_filename + : fs::path(home) / ".config" / "sprat" / k_profiles_config_filename; std::error_code ec; - if (fs::exists(home_cfg, ec) && !ec) { - return home_cfg; + if (fs::exists(cfg, ec) && !ec) { + return cfg; } return std::nullopt; +#endif } -std::vector build_default_profiles_config_candidates(const fs::path& cwd, const fs::path& exec_dir) { +std::vector build_default_profiles_config_candidates(const fs::path& exec_dir) { std::vector candidates; // Lookup order: - // 1) user config (Windows: %APPDATA%\sprat\spratprofiles.cfg, - // others: ~/.config/sprat/spratprofiles.cfg) - // 2) ./spratprofiles.cfg (current directory) - // 3) {exec_dir}/spratprofiles.cfg (beside executable) - // 4) global installed config + // 1) {exec_dir}/spratprofiles.cfg (beside executable, portable install) + // 2) user config: + // Windows: %APPDATA%\sprat\spratprofiles.cfg + // macOS: ~/Library/Application Support/sprat/spratprofiles.cfg + // Linux: $XDG_CONFIG_HOME/sprat/spratprofiles.cfg (default ~/.config/sprat/) + // 3) global installed config + if (!exec_dir.empty()) { + candidates.push_back(exec_dir / k_profiles_config_filename); + } if (std::optional user_config = resolve_user_profiles_config_path()) { candidates.push_back(*user_config); } - candidates.push_back(cwd / k_profiles_config_filename); - if (exec_dir != cwd && !exec_dir.empty()) { - candidates.push_back(exec_dir / k_profiles_config_filename); - } candidates.emplace_back(k_global_profiles_config_path); return candidates; } @@ -635,7 +689,8 @@ struct ImageCacheEntry { int trim_right = 0; int trim_bottom = 0; long long cached_at_unix = 0; - uint64_t content_hash = 0; // FNV-1a hash of raw RGBA buffer (0 if not computed) + uint64_t content_hash = 0; // FNV-1a hash of visible pixel region (0 = not computed) + uint64_t perceptual_hash = 0; // dHash of visible pixel region (0 = not computed) }; struct LayoutCandidate { @@ -724,15 +779,19 @@ bool compute_trim_bounds(const unsigned char* rgba, return false; } + // Use direct pointer arithmetic for alpha channel access. + // Bounds are already validated above (w > 0, h > 0, w/h <= k_max_image_dimension). + const size_t stride = static_cast(w) * 4; + int top_hit_x = -1; for (int y = 0; y < h; ++y) { + const unsigned char* row_alpha = rgba + static_cast(y) * stride + 3; for (int x = 0; x < w; ++x) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (row_alpha[static_cast(x) * 4] != 0) { + min_y = y; + top_hit_x = x; + break; } - min_y = y; - top_hit_x = x; - break; } if (top_hit_x >= 0) { break; @@ -744,13 +803,13 @@ bool compute_trim_bounds(const unsigned char* rgba, int bottom_hit_x = -1; for (int y = h - 1; y >= min_y; --y) { + const unsigned char* row_alpha = rgba + static_cast(y) * stride + 3; for (int x = w - 1; x >= 0; --x) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (row_alpha[static_cast(x) * 4] != 0) { + max_y = y; + bottom_hit_x = x; + break; } - max_y = y; - bottom_hit_x = x; - break; } if (bottom_hit_x >= 0) { break; @@ -761,13 +820,13 @@ bool compute_trim_bounds(const unsigned char* rgba, min_x = left_search_end; for (int x = 0; x <= left_search_end; ++x) { bool found = false; + const unsigned char* col_alpha = rgba + static_cast(x) * 4 + 3; for (int y = min_y; y <= max_y; ++y) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (col_alpha[static_cast(y) * stride] != 0) { + min_x = x; + found = true; + break; } - min_x = x; - found = true; - break; } if (found) { break; @@ -778,13 +837,13 @@ bool compute_trim_bounds(const unsigned char* rgba, max_x = right_search_start; for (int x = w - 1; x >= right_search_start; --x) { bool found = false; + const unsigned char* col_alpha = rgba + static_cast(x) * 4 + 3; for (int y = min_y; y <= max_y; ++y) { - if (!pixel_is_opaque(rgba, w, x, y)) { - continue; + if (col_alpha[static_cast(y) * stride] != 0) { + max_x = x; + found = true; + break; } - max_x = x; - found = true; - break; } if (found) { break; @@ -1090,7 +1149,8 @@ enum class InputType : std::uint8_t { Directory, ListFile, TarFile, - StdinTar + StdinTar, + StdinList }; struct InputContext { @@ -1141,7 +1201,7 @@ bool detect_and_extract_tar_content(const fs::path& input_path, InputContext& ou out_context.working_folder = input_path; return true; } - + return false; } @@ -1173,6 +1233,13 @@ bool load_content_from_stdin(InputContext& out_context) { return true; } +bool load_list_from_stdin(InputContext& out_context, const fs::path& cwd) { + out_context.type = InputType::StdinList; + out_context.working_folder = cwd; + out_context.temp_dirs_to_cleanup.clear(); + return true; +} + void prune_stale_cache_entries(std::unordered_map& entries, long long now_unix, long long max_age_seconds) { @@ -1202,7 +1269,7 @@ bool load_image_cache(const fs::path& cache_path, if (!(in >> header_tag >> version)) { return false; } - if (header_tag != "spratlayout_cache" || (version != 1 && version != 2)) { + if (header_tag != "spratlayout_cache" || (version != 1 && version != 2 && version != 3)) { return false; } @@ -1223,17 +1290,25 @@ bool load_image_cache(const fs::path& cache_path, >> entry.trim_bottom)) { break; } - if (version == 2) { + if (version >= 2) { if (!(in >> entry.cached_at_unix)) { break; } } + if (version >= 3) { + if (!(in >> entry.content_hash >> entry.perceptual_hash)) { + break; + } + } if (entry.w <= 0 || entry.h <= 0 || entry.w > k_max_image_dimension || entry.h > k_max_image_dimension) { continue; } entry.trim_transparent = trim_flag != 0; const std::string key = path + (entry.trim_transparent ? "|1" : "|0"); out[key] = entry; + if (out.size() >= k_max_cache_entries) { + break; + } } return true; @@ -1241,10 +1316,6 @@ bool load_image_cache(const fs::path& cache_path, bool save_image_cache(const fs::path& cache_path, const std::unordered_map& entries) { - if (entries.size() > k_max_cache_entries) { // Limit cache size - return false; - } - fs::path tmp = cache_path; tmp += ".tmp"; @@ -1253,18 +1324,32 @@ bool save_image_cache(const fs::path& cache_path, return false; } - out << "spratlayout_cache 2\n"; + // Collect valid entries; if over the limit, keep only the most recently used. + using KV = std::pair; + std::vector valid; + valid.reserve(entries.size()); for (const auto& kv : entries) { - std::string path = kv.first; + const ImageCacheEntry& e = kv.second; + if (e.w > 0 && e.h > 0 && e.w <= k_max_image_dimension && e.h <= k_max_image_dimension) { + valid.push_back({&kv.first, &e}); + } + } + if (valid.size() > k_max_cache_entries) { + std::ranges::sort(valid, [](const KV& a, const KV& b) { + return a.second->cached_at_unix > b.second->cached_at_unix; // newest first + }); + valid.resize(k_max_cache_entries); + } + + out << "spratlayout_cache 3\n"; + for (const auto& [key_ptr, e_ptr] : valid) { + std::string path = *key_ptr; 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 > k_max_image_dimension || e.h > k_max_image_dimension) { - continue; - } + const ImageCacheEntry& e = *e_ptr; out << std::quoted(path) << " " << (e.trim_transparent ? 1 : 0) << " " << e.file_size << " " @@ -1275,7 +1360,9 @@ bool save_image_cache(const fs::path& cache_path, << e.trim_top << " " << e.trim_right << " " << e.trim_bottom << " " - << e.cached_at_unix << "\n"; + << e.cached_at_unix << " " + << e.content_hash << " " + << e.perceptual_hash << "\n"; } out.close(); if (!out) { @@ -1355,29 +1442,36 @@ fs::path build_seed_cache_path(const fs::path& base_cache_path, } std::string to_hex_size_t(size_t value) { - std::ostringstream oss; - oss << std::hex << value; - return oss.str(); + std::array buf{}; + int n = std::snprintf(buf.data(), buf.size(), "%zx", value); + return std::string(buf.data(), n > 0 ? static_cast(n) : 0); } void print_usage() { std::cout << tr("Usage: spratlayout [OPTIONS]\n") + << tr(" spratlayout --stdin-list [OPTIONS]\n") << tr("\n") << tr("Scan an image folder/list/tar and write a text layout to standard output.\n") << tr("Rotated sprites are emitted with a trailing 'rotated' token.\n") << tr("\n") + << tr("Presets (recommended starting point):\n") + << tr(" --preset fast Quick shelf packing, GPU-friendly dimensions\n") + << tr(" --preset quality Best packing quality, GPU-friendly dimensions\n") + << tr(" --preset small Best packing quality, smallest possible area\n") + << tr(" --preset pot Power-of-two atlas, GPU-friendly dimensions\n") + << tr("\n") << tr("Options:\n") << tr(" --profile NAME Profile name from config (default: fast)\n") << tr(" --profiles-config PATH Use an explicit profile configuration file\n") - << tr(" --mode MODE Packing mode: compact, pot, or fast\n") - << tr(" --optimize TARGET Optimization target: gpu or space\n") + << tr(" --default-profiles-config Print the default profiles config path and exit\n") + << tr(" --mode MODE Packing algorithm: compact, pot, or fast\n") + << tr(" --optimize TARGET Optimization metric: gpu (min max-side) or space (min area)\n") << tr(" --max-width N Maximum atlas width\n") << tr(" --max-height N Maximum atlas height\n") << tr(" --no-max-width Disable width limit (even if profile sets one)\n") << tr(" --no-max-height Disable height limit (even if profile sets one)\n") << tr(" --padding N Extra pixels between packed sprites\n") << tr(" --extrude N Repeat edge pixels N times (padding should be >= extrude * 2)\n") - << tr(" --max-combinations N Max combinations for compact search (0=auto)\n") << tr(" --source-resolution WxH Source design resolution baseline\n") << tr(" --target-resolution WxH Target output resolution\n") << tr(" --resolution-reference REF Axis ratio driver: largest or smallest\n") @@ -1386,9 +1480,13 @@ void print_usage() { << tr(" --rotate Allow 90-degree sprite rotation during packing\n") << tr(" --multipack Split into multiple atlases if they don't fit\n") << tr(" --deduplicate Deduplication mode: none, exact, perceptual\n") - << tr(" --sort name|none Order of sprites in layout (default: name for folders)\n") + << tr(" --sort name|none|stable[:] Order of sprites in layout (default: none)\n") + << tr(" stable: deterministic sort by size then path; is\n") + << tr(" area (default), maxside, height, width, or perimeter\n") << tr(" --threads N Number of worker threads\n") << tr(" --debug Enable detailed error reporting and debug visualization\n") + << tr(" --stdin-list Read image paths from stdin (one per line) instead of \n") + << tr(" Directory inputs honor .spratlayoutignore; list files may use exclude \"path\"\n") << tr(" --help, -h Show this help message\n") << tr(" --version, -v Show version\n"); } @@ -1407,12 +1505,32 @@ bool is_file_older_than_seconds(const fs::path& path, long long max_age_seconds) } fs::file_time_type now = fs::file_time_type::clock::now(); if (file_time > now) { - return false; + return true; // future mtime (clock skew); treat as stale } long long age = std::chrono::duration_cast(now - file_time).count(); return age > max_age_seconds; } +// Builds a sorted-or-ordered list of "path|file_size|mtime" strings for all sources. +std::vector build_source_sig_parts(bool preserve_source_order, + const std::vector& sources) { + std::vector parts; + parts.reserve(sources.size()); + for (const auto& source : sources) { + std::string line; + line += source.path; + line += '|'; + line += std::to_string(source.meta.file_size); + line += '|'; + line += std::to_string(source.meta.mtime_ticks); + parts.push_back(std::move(line)); + } + if (!preserve_source_order) { + std::ranges::sort(parts); + } + return parts; +} + std::string build_layout_signature(const std::string& profile_name, Mode mode, OptimizeTarget optimize_target, @@ -1420,42 +1538,46 @@ std::string build_layout_signature(const std::string& profile_name, int max_height_limit, int padding, int extrude, - int max_combinations, double scale, bool trim_transparent, bool allow_rotate, bool preserve_source_order, const std::string& deduplicateMode, 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::ranges::sort(parts); - } - - std::ostringstream sig; - sig << profile_name << "|" - << static_cast(mode) << "|" - << static_cast(optimize_target) << "|" - << max_width_limit << "|" - << max_height_limit << "|" - << padding << "|" - << extrude << "|" - << max_combinations << "|" - << std::setprecision(k_floating_point_precision) << scale << "|" - << (trim_transparent ? 1 : 0) << "|" - << (allow_rotate ? 1 : 0) << "|" - << (preserve_source_order ? 1 : 0) << "|" - << deduplicateMode; + const std::vector parts = build_source_sig_parts(preserve_source_order, sources); + + std::array scale_buf{}; + std::snprintf(scale_buf.data(), scale_buf.size(), "%.*g", k_floating_point_precision, scale); + + std::string sig; + sig += profile_name; + sig += '|'; + sig += std::to_string(static_cast(mode)); + sig += '|'; + sig += std::to_string(static_cast(optimize_target)); + sig += '|'; + sig += std::to_string(max_width_limit); + sig += '|'; + sig += std::to_string(max_height_limit); + sig += '|'; + sig += std::to_string(padding); + sig += '|'; + sig += std::to_string(extrude); + sig += '|'; + sig += scale_buf.data(); + sig += '|'; + sig += (trim_transparent ? '1' : '0'); + sig += '|'; + sig += (allow_rotate ? '1' : '0'); + sig += '|'; + sig += (preserve_source_order ? '1' : '0'); + sig += '|'; + sig += deduplicateMode; for (const std::string& part : parts) { - sig << "\n" << part; + sig += '\n'; + sig += part; } - return to_hex_size_t(std::hash{}(sig.str())); + return to_hex_size_t(std::hash{}(sig)); } std::string build_layout_seed_signature(const std::string& profile_name, @@ -1464,39 +1586,41 @@ std::string build_layout_seed_signature(const std::string& profile_name, int max_width_limit, int max_height_limit, int extrude, - int max_combinations, double scale, bool trim_transparent, bool allow_rotate, 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::ranges::sort(parts); - } - - std::ostringstream sig; - sig << profile_name << "|" - << static_cast(mode) << "|" - << static_cast(optimize_target) << "|" - << max_width_limit << "|" - << max_height_limit << "|" - << extrude << "|" - << max_combinations << "|" - << std::setprecision(k_floating_point_precision) << scale << "|" - << (trim_transparent ? 1 : 0) << "|" - << (allow_rotate ? 1 : 0) << "|" - << (preserve_source_order ? 1 : 0); + const std::vector parts = build_source_sig_parts(preserve_source_order, sources); + + std::array scale_buf{}; + std::snprintf(scale_buf.data(), scale_buf.size(), "%.*g", k_floating_point_precision, scale); + + std::string sig; + sig += profile_name; + sig += '|'; + sig += std::to_string(static_cast(mode)); + sig += '|'; + sig += std::to_string(static_cast(optimize_target)); + sig += '|'; + sig += std::to_string(max_width_limit); + sig += '|'; + sig += std::to_string(max_height_limit); + sig += '|'; + sig += std::to_string(extrude); + sig += '|'; + sig += scale_buf.data(); + sig += '|'; + sig += (trim_transparent ? '1' : '0'); + sig += '|'; + sig += (allow_rotate ? '1' : '0'); + sig += '|'; + sig += (preserve_source_order ? '1' : '0'); for (const std::string& part : parts) { - sig << "\n" << part; + sig += '\n'; + sig += part; } - return to_hex_size_t(std::hash{}(sig.str())); + return to_hex_size_t(std::hash{}(sig)); } bool load_output_cache(const fs::path& cache_path, @@ -1706,7 +1830,7 @@ void prune_cache_family_group(const fs::path& base_cache_path, continue; } - if (name.size() >= 4 && name.substr(name.size() - 4) == ".tmp") { + if (name.ends_with(".tmp")) { fs::remove(entry.path(), ec); ec.clear(); continue; @@ -1950,7 +2074,8 @@ std::string build_layout_output_text(const std::vector& atlases, bool multipack, const std::vector& sprites, const std::vector>& aliases, - bool debug) { + bool debug, + const fs::path& root) { std::ostringstream output; if (debug) { output << "# Sprat Layout Debug Info\n"; @@ -1960,6 +2085,7 @@ std::string build_layout_output_text(const std::vector& atlases, output << "# Aliases: " << aliases.size() << "\n"; output << "# Multipack: " << (multipack ? "true" : "false") << "\n"; } + output << "root " << to_quoted(root.string()) << "\n"; output << "scale " << std::setprecision(k_output_precision) << scale << "\n"; if (extrude > 0) { output << "extrude " << extrude << "\n"; @@ -1967,13 +2093,21 @@ std::string build_layout_output_text(const std::vector& atlases, if (multipack) { output << "multipack true\n"; } + // Pre-group sprite indices by atlas_index to avoid O(sprites * atlases) scan. + std::vector> sprites_by_atlas(atlases.size()); + for (size_t si = 0; si < sprites.size(); ++si) { + int ai = sprites[si].atlas_index; + if (ai >= 0 && static_cast(ai) < atlases.size()) { + sprites_by_atlas[static_cast(ai)].push_back(si); + } + } for (size_t i = 0; i < atlases.size(); ++i) { output << "atlas " << atlases[i].width << "," << atlases[i].height << "\n"; - for (const auto& s : sprites) { - if (s.atlas_index != static_cast(i)) { - continue; - } - std::string path = s.path; + for (size_t si : sprites_by_atlas[i]) { + const auto& s = sprites[si]; + fs::path sprite_path(s.path); + fs::path relative_path = fs::relative(sprite_path, root); + std::string path = relative_path.string(); // Standardize path separators to forward slashes for output consistency std::replace(path.begin(), path.end(), '\\', '/'); output << "sprite " << to_quoted(path) << " " @@ -1990,9 +2124,17 @@ std::string build_layout_output_text(const std::vector& atlases, } } for (const auto& alias_pair : aliases) { - const auto& alias_path = alias_pair.first; - const auto& canonical_path = alias_pair.second; - output << "alias " << to_quoted(alias_path) << " " << to_quoted(canonical_path) << "\n"; + fs::path alias_fs_path(alias_pair.first); + fs::path relative_alias_path = fs::relative(alias_fs_path, root); + std::string alias_str = relative_alias_path.string(); + std::replace(alias_str.begin(), alias_str.end(), '\\', '/'); + + fs::path canonical_fs_path(alias_pair.second); + fs::path relative_canonical_path = fs::relative(canonical_fs_path, root); + std::string canonical_str = relative_canonical_path.string(); + std::replace(canonical_str.begin(), canonical_str.end(), '\\', '/'); + + output << "alias " << to_quoted(alias_str) << " " << to_quoted(canonical_str) << "\n"; } return output.str(); } @@ -2095,9 +2237,17 @@ bool try_pack(std::unique_ptr& root, std::vector& sprites, int pad return true; } -enum class FrameSort : std::uint8_t { Name, None }; +enum class FrameSort : std::uint8_t { Name, None, Stable }; + +enum class StableMetric : std::uint8_t { + Area, + MaxSide, + Height, + Width, + Perimeter +}; -bool parse_frame_sort_from_string(const std::string& value, FrameSort& out) { +bool parse_frame_sort_from_string(const std::string& value, FrameSort& out, StableMetric& out_metric) { std::string lower = to_lower_copy(value); if (lower == "name") { out = FrameSort::Name; @@ -2107,6 +2257,16 @@ bool parse_frame_sort_from_string(const std::string& value, FrameSort& out) { out = FrameSort::None; return true; } + if (lower == "stable" || lower.starts_with("stable:")) { + out = FrameSort::Stable; + const std::string metric_str = lower.size() > 7 ? lower.substr(7) : "area"; + if (metric_str == "area") { out_metric = StableMetric::Area; return true; } + if (metric_str == "maxside") { out_metric = StableMetric::MaxSide; return true; } + if (metric_str == "height") { out_metric = StableMetric::Height; return true; } + if (metric_str == "width") { out_metric = StableMetric::Width; return true; } + if (metric_str == "perimeter") { out_metric = StableMetric::Perimeter; return true; } + return false; + } return false; } @@ -2180,6 +2340,60 @@ bool sort_sprites_by_mode(std::vector& sprites, SortMode mode) { return false; } +void sort_sprites_stable(std::vector& sprites, StableMetric metric) { + auto path_cmp = [](const Sprite& a, const Sprite& b) { + return sprat::core::compare_natural(a.path, b.path) < 0; + }; + switch (metric) { + case StableMetric::Area: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + const long long area_a = static_cast(a.w) * a.h; + const long long area_b = static_cast(b.w) * b.h; + if (area_a != area_b) { return area_a > area_b; } + if (a.h != b.h) { return a.h > b.h; } + if (a.w != b.w) { return a.w > b.w; } + return path_cmp(a, b); + }); + break; + case StableMetric::MaxSide: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + const int max_a = std::max(a.w, a.h); + const int max_b = std::max(b.w, b.h); + if (max_a != max_b) { return max_a > max_b; } + const long long area_a = static_cast(a.w) * a.h; + const long long area_b = static_cast(b.w) * b.h; + if (area_a != area_b) { return area_a > area_b; } + return path_cmp(a, b); + }); + break; + case StableMetric::Height: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + if (a.h != b.h) { return a.h > b.h; } + if (a.w != b.w) { return a.w > b.w; } + return path_cmp(a, b); + }); + break; + case StableMetric::Width: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + if (a.w != b.w) { return a.w > b.w; } + if (a.h != b.h) { return a.h > b.h; } + return path_cmp(a, b); + }); + break; + case StableMetric::Perimeter: + std::ranges::sort(sprites, [&](const Sprite& a, const Sprite& b) { + const int p_a = a.w + a.h; + const int p_b = b.w + b.h; + if (p_a != p_b) { return p_a > p_b; } + const long long area_a = static_cast(a.w) * a.h; + const long long area_b = static_cast(b.w) * b.h; + if (area_a != area_b) { return area_a > area_b; } + return path_cmp(a, b); + }); + break; + } +} + struct Rect { int x = 0; int y = 0; @@ -2198,9 +2412,33 @@ bool rect_contains(const Rect& a, const Rect& b) { b.y + b.h <= a.y + a.h; } +void append_pruned_free_rect(std::vector& out, const Rect& candidate) { + if (candidate.w <= 0 || candidate.h <= 0) { + return; + } + + for (const Rect& existing : out) { + if (rect_contains(existing, candidate)) { + return; + } + } + + size_t write = 0; + for (size_t i = 0; i < out.size(); ++i) { + if (!rect_contains(candidate, out[i])) { + if (write != i) { + out[write] = out[i]; + } + ++write; + } + } + out.resize(write); + out.push_back(candidate); +} + 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); + append_pruned_free_rect(out, free_rect); return true; } @@ -2210,51 +2448,28 @@ bool split_free_rect(const Rect& free_rect, const Rect& used_rect, std::vector free_rect.x) { - out.push_back({free_rect.x, free_rect.y, used_rect.x - free_rect.x, free_rect.h}); + append_pruned_free_rect(out, {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}); + append_pruned_free_rect(out, {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}); + append_pruned_free_rect(out, {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}); + append_pruned_free_rect(out, {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, @@ -2274,6 +2489,7 @@ bool pack_compact_maxrects( int used_w = 0; int used_h = 0; + std::vector next_free; for (auto& s : sprites) { int rw = 0; @@ -2368,22 +2584,14 @@ bool pack_compact_maxrects( used_w = std::max(used.x + used.w, used_w); used_h = std::max(used.y + used.h, used_h); - std::vector next_free; - next_free.reserve(free_rects.size() * 2); + next_free.clear(); 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); + std::swap(free_rects, next_free); } out_width = used_w; @@ -2415,6 +2623,7 @@ bool pack_compact_maxrects_partial( std::vector free_rects; free_rects.push_back({0, 0, width_limit, max_height}); + std::vector next_free; for (const auto& src : sprites) { Sprite s = src; @@ -2516,21 +2725,13 @@ bool pack_compact_maxrects_partial( } out.packed_area += sprite_area; - std::vector next_free; - next_free.reserve(free_rects.size() * 2); + next_free.clear(); 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); + std::swap(free_rects, next_free); out.packed.push_back(s); } @@ -2628,6 +2829,94 @@ bool pack_fast_shelf( return out_width > 0 && out_height > 0; } +// Pack sprites into a uniform grid where every cell is max_sprite_width x max_sprite_height. +// Sprites are placed left-to-right, top-to-bottom. The number of columns is chosen to make +// the atlas as square as possible, subject to width_limit / height_limit (0 = no limit). +bool pack_grid( + std::vector& sprites, + int padding, + int width_limit, + int height_limit, + int& out_width, + int& out_height +) { + if (sprites.empty()) { + return false; + } + + const int ref_w = sprites[0].w; + const int ref_h = sprites[0].h; + for (const auto& s : sprites) { + if (s.w != ref_w || s.h != ref_h) { + std::cerr << tr("Error: grid mode requires all sprites to be the same size; ") + << s.path << " is " << s.w << "x" << s.h + << tr(", expected ") << ref_w << "x" << ref_h << '\n'; + return false; + } + } + + int cell_w = 0; + int cell_h = 0; + if (!checked_add_int(ref_w, padding, cell_w) || !checked_add_int(ref_h, padding, cell_h)) { + return false; + } + if (cell_w <= 0 || cell_h <= 0) { + return false; + } + if ((width_limit > 0 && cell_w > width_limit) || (height_limit > 0 && cell_h > height_limit)) { + return false; + } + + int n = static_cast(sprites.size()); + int max_cols = (width_limit > 0) ? (width_limit / cell_w) : n; + if (max_cols <= 0) { + return false; + } + + int cols = static_cast(std::ceil(std::sqrt(static_cast(n)))); + if (cols <= 0) { + cols = 1; + } + cols = std::min(cols, max_cols); + + // Widen if height limit is exceeded + if (height_limit > 0) { + while (cols < max_cols) { + int rows_needed = (n + cols - 1) / cols; + if (static_cast(rows_needed) * cell_h <= static_cast(height_limit)) { + break; + } + ++cols; + } + int rows_needed = (n + cols - 1) / cols; + if (static_cast(rows_needed) * cell_h > static_cast(height_limit)) { + return false; + } + } + + for (int i = 0; i < n; ++i) { + int col = i % cols; + int row = i / cols; + long long x_ll = static_cast(col) * cell_w; + long long y_ll = static_cast(row) * cell_h; + if (x_ll > std::numeric_limits::max() || y_ll > std::numeric_limits::max()) { + return false; + } + sprites[i].x = static_cast(x_ll); + sprites[i].y = static_cast(y_ll); + } + + int rows = (n + cols - 1) / cols; + long long total_w_ll = static_cast(cols) * cell_w; + long long total_h_ll = static_cast(rows) * cell_h; + if (total_w_ll > std::numeric_limits::max() || total_h_ll > std::numeric_limits::max()) { + return false; + } + out_width = static_cast(total_w_ll); + out_height = static_cast(total_h_ll); + return true; +} + bool compute_tight_atlas_bounds(const std::vector& sprites, int& out_width, int& out_height) { out_width = 0; out_height = 0; @@ -2722,6 +3011,59 @@ bool pack_atlases( return true; } + // Grid mode: uniform-cell layout, split into equal-capacity atlases when needed. + if (mode == Mode::GRID) { + const int ref_w = sprites[0].w; + const int ref_h = sprites[0].h; + for (const auto& s : sprites) { + if (s.w != ref_w || s.h != ref_h) { + std::cerr << tr("Error: grid mode requires all sprites to be the same size; ") + << s.path << " is " << s.w << "x" << s.h + << tr(", expected ") << ref_w << "x" << ref_h << '\n'; + return false; + } + } + int cell_w = 0; + int cell_h = 0; + if (!checked_add_int(ref_w, padding, cell_w) || !checked_add_int(ref_h, padding, cell_h)) { + return false; + } + if (cell_w <= 0 || cell_h <= 0 || cell_w > max_w || cell_h > max_h) { + return false; + } + int cols = max_w / cell_w; + int rows_per_atlas = max_h / cell_h; + if (cols <= 0 || rows_per_atlas <= 0) { + return false; + } + int capacity = cols * rows_per_atlas; + int n = static_cast(sprites.size()); + int atlas_idx = 0; + for (int base = 0; base < n; base += capacity, ++atlas_idx) { + int count = std::min(capacity, n - base); + int atlas_rows = (count + cols - 1) / cols; + long long aw = static_cast(cols) * cell_w; + long long ah = static_cast(atlas_rows) * cell_h; + if (aw > std::numeric_limits::max() || ah > std::numeric_limits::max()) { + return false; + } + out_atlases.push_back({static_cast(aw), static_cast(ah)}); + for (int i = 0; i < count; ++i) { + int col = i % cols; + int row = i / cols; + long long x_ll = static_cast(col) * cell_w; + long long y_ll = static_cast(row) * cell_h; + if (x_ll > std::numeric_limits::max() || y_ll > std::numeric_limits::max()) { + return false; + } + sprites[base + i].x = static_cast(x_ll); + sprites[base + i].y = static_cast(y_ll); + sprites[base + i].atlas_index = atlas_idx; + } + } + return true; + } + std::vector remaining = sprites; std::vector all_packed; int atlas_index = 0; @@ -2817,6 +3159,9 @@ bool pack_atlases( if (candidate.packed_count != best.packed_count) { return candidate.packed_count > best.packed_count; } + if (candidate.packed_area != best.packed_area) { + return candidate.packed_area > best.packed_area; + } return pick_better_layout_candidate( candidate.area, candidate.used_w, candidate.used_h, true, best.area, best.used_w, best.used_h, @@ -2901,6 +3246,45 @@ bool pack_atlases( return true; } +// Maximum allowed Hamming distance between two dHashes to consider sprites perceptually equal. +static constexpr int k_dhash_threshold = 5; + +// Compute a 64-bit dHash for an RGBA pixel buffer. +// Algorithm: sample a 9x8 greyscale grid (nearest-neighbor), then for each of the 8 rows +// compare 8 adjacent column pairs; bit=1 if left < right. Returns 64-bit hash. +// Alpha-premultiplied luma: grey = (0.299*R + 0.587*G + 0.114*B) * (A/255.0) +static uint64_t compute_dhash(const unsigned char* rgba, int w, int h) { + if (rgba == nullptr || w <= 0 || h <= 0) { + return 0; + } + // Sample a 9x8 grid (9 cols, 8 rows) + double grid[8][9]; + for (int row = 0; row < 8; ++row) { + for (int col = 0; col < 9; ++col) { + int px = (col * (w - 1)) / 8; + int py = (row * (h - 1)) / 7; + if (px < 0) px = 0; + if (px >= w) px = w - 1; + if (py < 0) py = 0; + if (py >= h) py = h - 1; + const unsigned char* p = rgba + (static_cast(py) * static_cast(w) + static_cast(px)) * 4; + double a = p[3] / 255.0; + grid[row][col] = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * a; + } + } + uint64_t hash = 0; + int bit = 0; + for (int row = 0; row < 8; ++row) { + for (int col = 0; col < 8; ++col) { + if (grid[row][col] < grid[row][col + 1]) { + hash |= (uint64_t(1) << bit); + } + ++bit; + } + } + return hash; +} + int run_spratlayout(int argc, char** argv) { #ifdef _WIN32 if (_setmode(_fileno(stdin), _O_BINARY) == -1) { @@ -2914,6 +3298,8 @@ int run_spratlayout(int argc, char** argv) { fs::path folder; std::string requested_profile_name; std::string profiles_config_path; + bool has_preset = false; + PresetDefinition preset_definition{Mode::FAST, OptimizeTarget::GPU}; bool has_mode_override = false; Mode mode_override = Mode::COMPACT; bool has_optimize_override = false; @@ -2928,8 +3314,6 @@ int run_spratlayout(int argc, char** argv) { bool has_padding_override = false; int extrude = 0; bool has_extrude_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; @@ -2949,9 +3333,12 @@ int run_spratlayout(int argc, char** argv) { std::string deduplicateMode = "none"; bool has_deduplicate_override = false; FrameSort frame_sort = FrameSort::Name; + StableMetric stable_metric = StableMetric::Area; bool has_frame_sort_override = false; unsigned int thread_limit = 0; bool has_threads_override = false; + bool show_profiles_config = false; + bool stdin_list = false; // parse args for (int i = 1; i < argc; ++i) { @@ -2966,8 +3353,18 @@ int run_spratlayout(int argc, char** argv) { debug = true; } else if (arg == "--profile" && i + 1 < argc) { requested_profile_name = argv[++i]; + } else if (arg == "--preset" && i + 1 < argc) { + std::string value = argv[++i]; + if (!parse_preset_from_string(value, preset_definition)) { + std::cerr << tr("Invalid preset: ") << value + << tr(". Valid presets: fast, quality, small, pot\n"); + return 1; + } + has_preset = true; } else if (arg == "--profiles-config" && i + 1 < argc) { profiles_config_path = argv[++i]; + } else if (arg == "--default-profiles-config") { + show_profiles_config = true; } else if (arg == "--mode" && i + 1 < argc) { std::string value = argv[++i]; std::string error; @@ -3026,13 +3423,6 @@ int run_spratlayout(int argc, char** argv) { return 1; } has_extrude_override = true; - } else if (arg == "--max-combinations" && i + 1 < argc) { - std::string value = argv[++i]; - if (!parse_non_negative_int(value, max_combinations)) { - std::cerr << tr("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)) { @@ -3085,7 +3475,7 @@ int run_spratlayout(int argc, char** argv) { has_deduplicate_override = true; } else if (arg == "--sort" && i + 1 < argc) { std::string value = argv[++i]; - if (!parse_frame_sort_from_string(value, frame_sort)) { + if (!parse_frame_sort_from_string(value, frame_sort, stable_metric)) { std::cerr << tr("Invalid sort value: ") << value << "\n"; return 1; } @@ -3097,6 +3487,8 @@ int run_spratlayout(int argc, char** argv) { return 1; } has_threads_override = true; + } else if (arg == "--stdin-list") { + stdin_list = true; } else if (arg.starts_with("-")) { std::cerr << tr("Unknown argument: ") << arg << "\n"; return 1; @@ -3110,7 +3502,21 @@ int run_spratlayout(int argc, char** argv) { } } - if (folder.empty()) { + if (show_profiles_config) { + const fs::path exec_dir_local = sprat::core::get_executable_dir(argv[0]); + const auto candidates = build_default_profiles_config_candidates(exec_dir_local); + for (const fs::path& candidate : candidates) { + std::error_code ec; + if (fs::exists(candidate, ec) && !ec) { + std::cout << candidate.string() << "\n"; + return 0; + } + } + std::cout << k_global_profiles_config_path << "\n"; + return 0; + } + + if (folder.empty() && !stdin_list) { print_usage(); return 1; } @@ -3123,6 +3529,17 @@ int run_spratlayout(int argc, char** argv) { selected_profile_name = requested_profile_name; } + if (has_preset) { + if (!has_mode_override) { + mode_override = preset_definition.mode; + has_mode_override = true; + } + if (!has_optimize_override) { + optimize_override = preset_definition.optimize_target; + has_optimize_override = true; + } + } + if (!has_mode_override) { mode = k_default_mode; } else { @@ -3139,9 +3556,6 @@ int run_spratlayout(int argc, char** argv) { if (!has_extrude_override) { extrude = k_default_extrude; } - if (!has_max_combinations_override) { - max_combinations = k_default_max_combinations; - } if (!has_scale_override) { scale = k_default_scale; } @@ -3164,7 +3578,7 @@ int run_spratlayout(int argc, char** argv) { } config_candidates.push_back(std::move(config_candidate)); } else { - config_candidates = build_default_profiles_config_candidates(cwd, exec_dir); + config_candidates = build_default_profiles_config_candidates(exec_dir); } bool loaded_profile_file = false; @@ -3255,9 +3669,6 @@ int run_spratlayout(int argc, char** argv) { if (!has_extrude_override && selected_profile.extrude) { extrude = *selected_profile.extrude; } - 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; } @@ -3298,7 +3709,7 @@ int run_spratlayout(int argc, char** argv) { if (profile_debug) { std::cerr << "[profile-debug] applied_profile=" << selected_profile.name << "\n"; std::cerr << "[profile-debug] mode=" - << (mode == Mode::FAST ? "fast" : (mode == Mode::COMPACT ? "compact" : "pot")) + << (mode == Mode::FAST ? "fast" : (mode == Mode::COMPACT ? "compact" : (mode == Mode::GRID ? "grid" : "pot"))) << " optimize=" << (optimize_target == OptimizeTarget::GPU ? "gpu" : "space") << " padding=" << padding @@ -3332,9 +3743,16 @@ int run_spratlayout(int argc, char** argv) { } InputContext input_context; - - // Check if we should read from stdin (when folder is "-") - if (folder == "-") { + + if (stdin_list) { +#ifdef _WIN32 + _setmode(_fileno(stdin), _O_TEXT); +#endif + if (!load_list_from_stdin(input_context, cwd)) { + std::cerr << tr("Error: Failed to initialize stdin list mode\n"); + return 1; + } + } else if (folder == "-") { if (!load_content_from_stdin(input_context)) { std::cerr << tr("Error: Failed to load content from stdin\n"); return 1; @@ -3353,14 +3771,95 @@ int run_spratlayout(int argc, char** argv) { 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 << tr("Invalid extension in list input: ") << to_quoted(image_path) << "\n"; + std::unordered_set excluded_source_paths; + auto add_excluded_source = [&](const fs::path& path, const std::string* relative_key = nullptr) { + excluded_source_paths.insert(normalize_path_key(path)); + if (relative_key != nullptr && !relative_key->empty()) { + excluded_source_paths.insert(*relative_key); + } + }; + auto is_excluded_source = [&](const fs::path& path, const fs::path* root) { + if (excluded_source_paths.contains(normalize_path_key(path))) { + return true; + } + if (root != nullptr) { + std::error_code ec; + fs::path relative = fs::relative(path, *root, ec); + if (!ec && excluded_source_paths.contains(relative.lexically_normal().string())) { + return true; + } + } + return false; + }; + auto load_exclusion_file = [&](const fs::path& file_path, const fs::path& base_root, bool strict) -> bool { + std::ifstream in(file_path); + if (!in) { + return !strict; + } + std::string line; + size_t line_number = 0; + while (std::getline(in, line)) { + ++line_number; + std::string trimmed = trim_copy(line); + if (trimmed.empty() || trimmed.front() == '#') { + continue; + } + + std::string path_text; + if (trimmed.size() >= 7 && trimmed.compare(0, 7, "exclude") == 0 && + (trimmed.size() == 7 || std::isspace(static_cast(trimmed[7])))) { + size_t pos = 7; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos])) != 0) { + ++pos; + } + if (pos < trimmed.size() && trimmed[pos] == '"') { + if (!parse_quoted_path_argument(trimmed, pos, path_text)) { + if (strict) { + std::cerr << tr("Invalid exclude path at line ") << line_number + << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + continue; + } + } else if (pos < trimmed.size()) { + path_text = trimmed.substr(pos); + } + } else { + path_text = trimmed; + } + + if (path_text.empty()) { + if (strict) { + std::cerr << tr("Invalid exclude path at line ") << line_number + << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + continue; + } + + fs::path excluded_path(path_text); + std::optional relative_key; + if (excluded_path.is_relative()) { + relative_key = excluded_path.lexically_normal().string(); + excluded_path = base_root / excluded_path; + } + add_excluded_source(excluded_path, relative_key ? &*relative_key : nullptr); + } + return true; + }; + auto add_source = [&](const fs::path& image_path, bool strict) -> bool { + if (!is_supported_image_extension(image_path)) { + if (strict) { + std::cerr << tr("Invalid extension in list input: ") << to_quoted(image_path) << "\n"; return false; } return true; } + const fs::path* exclusion_root = + input_context.type == InputType::Directory ? &input_context.working_folder : nullptr; + if (is_excluded_source(image_path, exclusion_root)) { + return true; + } ImageMeta meta; if (!read_image_meta(image_path, meta)) { if (strict) { @@ -3378,7 +3877,8 @@ int run_spratlayout(int argc, char** argv) { }; if (input_context.type == InputType::Directory) { - for (const auto& entry : fs::directory_iterator(input_context.working_folder)) { + load_exclusion_file(input_context.working_folder / ".spratlayoutignore", input_context.working_folder, false); + for (const auto& entry : fs::recursive_directory_iterator(input_context.working_folder)) { if (!entry.is_regular_file()) { continue; } @@ -3407,45 +3907,108 @@ int run_spratlayout(int argc, char** argv) { } } } else { - std::ifstream list_file(input_context.working_folder); - if (!list_file) { - std::cerr << tr("Failed to open list file: ") << input_context.working_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; + // Parse a list-format stream (used for both ListFile and StdinList). + // base_dir is used to resolve relative paths when no "root" directive is present. + auto parse_list_stream = [&](std::istream& stream, const fs::path& base_dir) -> bool { + std::string line; + size_t line_number = 0; + fs::path list_root; // optional root override from "root" directive + while (std::getline(stream, line)) { + ++line_number; + std::string trimmed = trim_copy(line); + if (trimmed.empty() || trimmed.front() == '#') { + continue; + } + if (trimmed.size() >= 7 && trimmed.compare(0, 7, "exclude") == 0 && + (trimmed.size() == 7 || std::isspace(static_cast(trimmed[7])))) { + size_t pos = 7; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos])) != 0) { + ++pos; + } + std::string excluded_path_text; + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + if (!sprat::core::parse_quoted(trimmed, pos, excluded_path_text, error)) { + std::cerr << tr("Invalid exclude path at line ") << line_number << tr(": ") << error << "\n"; + return false; + } + } else if (pos < trimmed.size()) { + excluded_path_text = trimmed.substr(pos); + } + if (excluded_path_text.empty()) { + std::cerr << tr("Invalid exclude path at line ") << line_number << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + fs::path excluded_path(excluded_path_text); + if (excluded_path.is_relative()) { + const fs::path& base = !list_root.empty() ? list_root : base_dir; + excluded_path = base / excluded_path; + } + add_excluded_source(excluded_path); + continue; + } + // "root " directive: sets the base directory for resolving relative paths + if (trimmed.size() >= 4 && trimmed.compare(0, 4, "root") == 0 && + (trimmed.size() == 4 || std::isspace(static_cast(trimmed[4])))) { + size_t pos = 4; + while (pos < trimmed.size() && std::isspace(static_cast(trimmed[pos]))) ++pos; + std::string root_str; + if (pos < trimmed.size() && trimmed[pos] == '"') { + std::string error; + sprat::core::parse_quoted(trimmed, pos, root_str, error); + } else if (pos < trimmed.size()) { + root_str = trimmed.substr(pos); + } + if (!root_str.empty()) { + fs::path rp(root_str); + if (rp.is_relative()) { + rp = base_dir / rp; + } + list_root = rp; + } + continue; + } + fs::path entry_path(trimmed); + if (entry_path.is_relative()) { + const fs::path& base = !list_root.empty() ? list_root : base_dir; + entry_path = base / entry_path; + } + if (!fs::exists(entry_path) || !fs::is_regular_file(entry_path)) { + std::cerr << tr("Invalid image path at line ") << line_number << tr(": ") << to_quoted(trimmed) << "\n"; + return false; + } + if (!add_source(entry_path, true)) { + return false; + } } - fs::path entry_path(trimmed); - if (entry_path.is_relative()) { - entry_path = input_context.working_folder.parent_path() / entry_path; + return true; + }; + + if (input_context.type == InputType::StdinList) { + if (!parse_list_stream(std::cin, input_context.working_folder)) { + return 1; } - if (!fs::exists(entry_path) || !fs::is_regular_file(entry_path)) { - std::cerr << tr("Invalid image path at line ") << line_number << tr(": ") << to_quoted(trimmed) << "\n"; + } else { + std::ifstream list_file(input_context.working_folder); + if (!list_file) { + std::cerr << tr("Failed to open list file: ") << input_context.working_folder << "\n"; return 1; } - if (!add_source(entry_path, true)) { + if (!parse_list_stream(list_file, input_context.working_folder.parent_path())) { return 1; } } } - const bool is_stdin_or_list = - (input_context.type == InputType::ListFile || input_context.type == InputType::StdinTar); bool do_sort = false; if (has_frame_sort_override) { do_sort = (frame_sort == FrameSort::Name); - } else { - do_sort = !is_stdin_or_list; } - const bool enforce_name_order = (has_frame_sort_override && frame_sort == FrameSort::Name); + const bool enforce_name_order = (has_frame_sort_override && frame_sort == FrameSort::Name); + const bool enforce_stable_order = (has_frame_sort_override && frame_sort == FrameSort::Stable); - if (do_sort) { + if (do_sort || enforce_stable_order) { std::ranges::sort(sources, [](const ImageSource& lhs, const ImageSource& rhs) { int cmp = sprat::core::compare_natural(lhs.path, rhs.path); if (cmp != 0) { @@ -3463,13 +4026,13 @@ int run_spratlayout(int argc, char** argv) { return 1; } - const bool is_file = !do_sort; + const bool is_file = !do_sort && !enforce_stable_order; const std::string layout_signature = build_layout_signature( selected_profile_name, mode, optimize_target, max_width_limit, max_height_limit, - padding, extrude, max_combinations, scale, trim_transparent, allow_rotate, is_file, deduplicateMode, sources); + padding, extrude, scale, trim_transparent, allow_rotate, is_file, deduplicateMode, sources); const std::string layout_seed_signature = build_layout_seed_signature( selected_profile_name, mode, optimize_target, max_width_limit, max_height_limit, - extrude, max_combinations, scale, trim_transparent, allow_rotate, is_file, sources); + extrude, scale, trim_transparent, allow_rotate, 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)) { @@ -3484,45 +4047,99 @@ int run_spratlayout(int argc, char** argv) { 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) { + struct SpriteLoadResult { + bool ok = false; + bool from_cache = false; + bool failed = false; + std::string fail_reason; + Sprite sprite; + std::string cache_key; + ImageCacheEntry new_entry; + }; + + const size_t source_count = sources.size(); + std::vector load_results(source_count); + + auto process_source = [&](size_t i) { + const auto& source = sources[i]; const std::string& path = source.path; const ImageMeta& meta = source.meta; + SpriteLoadResult& result = load_results[i]; const std::string cache_key = path + (trim_transparent ? "|1" : "|0"); + result.cache_key = cache_key; + + // Step 4a: cache hit 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; + // If deduplication is requested and the relevant hash is missing, + // fall through to reload so we can compute the hash. + bool need_hash = false; + if (deduplicateMode == "exact" && cached.content_hash == 0) { + need_hash = true; + } else if (deduplicateMode == "perceptual" && cached.perceptual_hash == 0) { + need_hash = true; + } + if (!need_hash) { + 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; + result.ok = true; + result.from_cache = true; + result.sprite = std::move(s); + return; + } } } Sprite loaded_sprite; loaded_sprite.path = path; if (!trim_transparent) { - int w; - int h; - int channels; - if (stbi_info(path.c_str(), &w, &h, &channels) == 0) { - continue; + // Step 4b: when deduplication is active, load pixel data to compute hashes. + uint64_t entry_content_hash = 0; + uint64_t entry_perceptual_hash = 0; + int w = 0; + int h = 0; + if (deduplicateMode != "none") { + int channels = 0; + unsigned char* px = stbi_load(path.c_str(), &w, &h, &channels, 4); + if (px == nullptr) { + result.failed = true; + result.fail_reason = stbi_failure_reason(); + return; + } + // FNV-1a over the full RGBA buffer + const size_t nbytes = static_cast(w) * static_cast(h) * 4; + uint64_t fnv = 14695981039346656037ULL; + for (size_t bi = 0; bi < nbytes; ++bi) { + fnv ^= px[bi]; + fnv *= 1099511628211ULL; + } + entry_content_hash = fnv; + entry_perceptual_hash = compute_dhash(px, w, h); + stbi_image_free(px); + } else { + int channels = 0; + if (stbi_info(path.c_str(), &w, &h, &channels) == 0) { + result.failed = true; + result.fail_reason = stbi_failure_reason(); + return; + } } loaded_sprite.w = w; loaded_sprite.h = h; - sprites.push_back(loaded_sprite); - cache_entries[cache_key] = { + result.ok = true; + result.sprite = loaded_sprite; + result.new_entry = { .trim_transparent=trim_transparent, .file_size=meta.file_size, .mtime_ticks=meta.mtime_ticks, @@ -3532,9 +4149,11 @@ int run_spratlayout(int argc, char** argv) { .trim_top=loaded_sprite.trim_top, .trim_right=loaded_sprite.trim_right, .trim_bottom=loaded_sprite.trim_bottom, - .cached_at_unix=now_unix + .cached_at_unix=now_unix, + .content_hash=entry_content_hash, + .perceptual_hash=entry_perceptual_hash }; - continue; + return; } int w = 0; @@ -3542,14 +4161,18 @@ int run_spratlayout(int argc, char** argv) { int channels = 0; unsigned char* data = stbi_load(path.c_str(), &w, &h, &channels, 4); if (data == nullptr) { - std::cerr << tr("Warning: Failed to load sprite ") << to_quoted(path) << tr(" (Reason: ") << stbi_failure_reason() << tr(")\n"); - continue; + result.failed = true; + result.fail_reason = stbi_failure_reason(); + return; } int min_x = 0; int min_y = 0; int max_x = -1; int max_y = -1; + // Step 4c: compute hashes over trimmed region before freeing pixel data. + uint64_t entry_content_hash = 0; + uint64_t entry_perceptual_hash = 0; 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; @@ -3557,6 +4180,32 @@ int run_spratlayout(int argc, char** argv) { loaded_sprite.trim_bottom = (h - 1) - max_y; loaded_sprite.w = max_x - min_x + 1; loaded_sprite.h = max_y - min_y + 1; + + if (deduplicateMode != "none") { + // FNV-1a over the trimmed pixel region + uint64_t fnv = 14695981039346656037ULL; + for (int ry = min_y; ry <= max_y; ++ry) { + const unsigned char* row = data + (static_cast(ry) * static_cast(w) + static_cast(min_x)) * 4; + for (int rx = 0; rx < (max_x - min_x + 1); ++rx) { + for (int c = 0; c < 4; ++c) { + fnv ^= row[static_cast(rx) * 4 + static_cast(c)]; + fnv *= 1099511628211ULL; + } + } + } + entry_content_hash = fnv; + + // Extract trimmed region for dHash + const int tw = max_x - min_x + 1; + const int th = max_y - min_y + 1; + std::vector trimmed(static_cast(tw) * static_cast(th) * 4); + for (int ry = 0; ry < th; ++ry) { + const unsigned char* src = data + (static_cast(min_y + ry) * static_cast(w) + static_cast(min_x)) * 4; + unsigned char* dst = trimmed.data() + static_cast(ry) * static_cast(tw) * 4; + std::memcpy(dst, src, static_cast(tw) * 4); + } + entry_perceptual_hash = compute_dhash(trimmed.data(), tw, th); + } } else { // Fully transparent image: keep a 1x1 transparent region. loaded_sprite.trim_left = 0; @@ -3565,11 +4214,18 @@ int run_spratlayout(int argc, char** argv) { loaded_sprite.trim_bottom = std::max(0, h - 1); loaded_sprite.w = 1; loaded_sprite.h = 1; + + if (deduplicateMode != "none") { + // Sentinel: all fully-transparent sprites are equivalent. + entry_content_hash = 0xFFFFFFFFFFFFFFFFULL; + entry_perceptual_hash = 0xFFFFFFFFFFFFFFFFULL; + } } stbi_image_free(data); - sprites.push_back(loaded_sprite); - cache_entries[cache_key] = { + result.ok = true; + result.sprite = loaded_sprite; + result.new_entry = { .trim_transparent=trim_transparent, .file_size=meta.file_size, .mtime_ticks=meta.mtime_ticks, @@ -3579,12 +4235,147 @@ int run_spratlayout(int argc, char** argv) { .trim_top=loaded_sprite.trim_top, .trim_right=loaded_sprite.trim_right, .trim_bottom=loaded_sprite.trim_bottom, - .cached_at_unix=now_unix + .cached_at_unix=now_unix, + .content_hash=entry_content_hash, + .perceptual_hash=entry_perceptual_hash }; + }; + + unsigned int load_worker_count = + thread_limit > 0 ? thread_limit : std::thread::hardware_concurrency(); + if (load_worker_count == 0) load_worker_count = 1; +#ifdef __EMSCRIPTEN__ + load_worker_count = 1; +#endif + load_worker_count = std::min( + load_worker_count, static_cast(source_count)); + + if (load_worker_count <= 1 || source_count <= 1) { + for (size_t i = 0; i < source_count; ++i) process_source(i); + } else { + std::vector workers; + workers.reserve(load_worker_count); + for (unsigned int wi = 0; wi < load_worker_count; ++wi) { + workers.emplace_back([&, wi]() { + const size_t begin = (source_count * wi) / load_worker_count; + const size_t end = (source_count * (wi + 1)) / load_worker_count; + for (size_t i = begin; i < end; ++i) process_source(i); + }); + } + for (auto& t : workers) t.join(); + } + + // Serial collection pass: merge results in source order. + // cache_entries writes are deferred here to avoid concurrent map mutations. + std::vector sprites; + sprites.reserve(source_count); + for (size_t i = 0; i < source_count; ++i) { + const SpriteLoadResult& r = load_results[i]; + if (r.failed) { + std::cerr << tr("Warning: Failed to load sprite ") + << to_quoted(sources[i].path) + << tr(" (Reason: ") << r.fail_reason << tr(")\n"); + continue; + } + if (!r.ok) continue; + sprites.push_back(r.sprite); + if (r.from_cache) { + auto it = cache_entries.find(r.cache_key); + if (it != cache_entries.end()) it->second.cached_at_unix = now_unix; + } else { + cache_entries[r.cache_key] = r.new_entry; + } } save_image_cache(cache_path, cache_entries); + // Step 5: Deduplication pass + std::vector> layout_aliases; + if (deduplicateMode == "exact") { + // O(N) hash-map dedup keyed by (content_hash, w, h) + struct DedupKey { + uint64_t hash; + int w; + int h; + bool operator==(const DedupKey& o) const { + return hash == o.hash && w == o.w && h == o.h; + } + }; + struct DedupKeyHash { + size_t operator()(const DedupKey& k) const { + size_t h = std::hash{}(k.hash); + h ^= std::hash{}(k.w) + 0x9e3779b9u + (h << 6u) + (h >> 2u); + h ^= std::hash{}(k.h) + 0x9e3779b9u + (h << 6u) + (h >> 2u); + return h; + } + }; + std::unordered_map canonical_map; + std::vector deduped; + deduped.reserve(sprites.size()); + for (const auto& s : sprites) { + const std::string ck = s.path + (trim_transparent ? "|1" : "|0"); + const auto it = cache_entries.find(ck); + const uint64_t h = (it != cache_entries.end()) ? it->second.content_hash : 0; + if (h == 0) { + deduped.push_back(s); + continue; + } + DedupKey key{h, s.w, s.h}; + auto [ins_it, inserted] = canonical_map.emplace(key, s.path); + if (inserted) { + deduped.push_back(s); + } else { + layout_aliases.push_back({s.path, ins_it->second}); + } + } + sprites = std::move(deduped); + } else if (deduplicateMode == "perceptual") { + // O(N²) pairwise + union-find dedup + const size_t N = sprites.size(); + // Gather hashes + std::vector phash(N, 0); + for (size_t i = 0; i < N; ++i) { + const std::string ck = sprites[i].path + (trim_transparent ? "|1" : "|0"); + const auto it = cache_entries.find(ck); + if (it != cache_entries.end()) { + phash[i] = it->second.perceptual_hash; + } + } + // Union-find + std::vector parent(N); + std::iota(parent.begin(), parent.end(), 0); + std::function find = [&](size_t x) -> size_t { + while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } + return x; + }; + for (size_t i = 0; i < N; ++i) { + if (phash[i] == 0) continue; + for (size_t j = i + 1; j < N; ++j) { + if (phash[j] == 0) continue; + if (sprites[i].w != sprites[j].w || sprites[i].h != sprites[j].h) continue; + if (popcount64(phash[i] ^ phash[j]) <= k_dhash_threshold) { + size_t ri = find(i), rj = find(j); + if (ri != rj) parent[rj] = ri; + } + } + } + // Build deduped list + std::unordered_map canonical_map; // root -> first sprite index + canonical_map.reserve(N); + std::vector deduped; + deduped.reserve(N); + for (size_t i = 0; i < N; ++i) { + size_t root = find(i); + auto [ins_it, inserted] = canonical_map.emplace(root, i); + if (inserted) { + deduped.push_back(sprites[i]); + } else { + layout_aliases.push_back({sprites[i].path, sprites[ins_it->second].path}); + } + } + sprites = std::move(deduped); + } + if (sprites.empty()) { std::cerr << tr("Error: no valid images found\n"); return 1; @@ -3652,6 +4443,10 @@ int run_spratlayout(int argc, char** argv) { return 1; } + if (enforce_stable_order) { + sort_sprites_stable(sprites, stable_metric); + } + bool reused_layout_seed = false; bool have_layout_seed = false; LayoutSeedCache seed_cache; @@ -3673,7 +4468,7 @@ int run_spratlayout(int argc, char** argv) { atlases.push_back({atlas_width, atlas_height}); for (auto& s : sprites) { s.atlas_index = 0; } } else if (multipack) { - if (!pack_atlases(sprites, width_upper_bound, height_upper_bound, padding, mode, optimize_target, allow_rotate, enforce_name_order, atlases)) { + if (!pack_atlases(sprites, width_upper_bound, height_upper_bound, padding, mode, optimize_target, allow_rotate, enforce_name_order || enforce_stable_order, atlases)) { std::cerr << tr("Error: failed to compute multipack layout\n"); return 1; } @@ -3701,15 +4496,37 @@ int run_spratlayout(int argc, char** argv) { return 1; } + // Pre-build sorted sprite arrays for each sort mode. + const bool enforce_sort_order_pot = enforce_name_order || enforce_stable_order; + std::array, k_sort_mode_count> pot_sorted; + pot_sorted[0] = sprites; + if (!(enforce_sort_order_pot && sort_modes[0] != SortMode::None)) { + sort_sprites_by_mode(pot_sorted[0], sort_modes[0]); + } + for (size_t si = 1; si < sort_modes.size(); ++si) { + pot_sorted[si] = pot_sorted[0]; // copy already-allocated vector + if (!(enforce_sort_order_pot && sort_modes[si] != SortMode::None)) { + sort_sprites_by_mode(pot_sorted[si], sort_modes[si]); + } + } + // 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; + int best_gpu_w = 0; + int best_gpu_h = 0; + size_t best_gpu_area = 0; + bool have_best_gpu = false; + std::vector best_gpu_sprites; + + int best_space_w = 0; + int best_space_h = 0; + size_t best_space_area = 0; + bool have_best_space = false; + std::vector best_space_sprites; + size_t max_candidate_area = 0; - bool have_best = false; + std::vector trial_sprites; while (true) { if (max_width_limit > 0 && side > max_width_limit) { @@ -3720,26 +4537,26 @@ int run_spratlayout(int argc, char** argv) { std::cerr << tr("Error: no POT layout fits within max height\n"); return 1; } - for (SortMode sort_mode : sort_modes) { - if (enforce_name_order && sort_mode != SortMode::None) { + for (size_t si = 0; si < sort_modes.size(); ++si) { + if (enforce_sort_order_pot && sort_modes[si] != SortMode::None) { continue; } - std::vector trial_sprites = sprites; - sort_sprites_by_mode(trial_sprites, sort_mode); + trial_sprites.assign(pot_sorted[si].begin(), pot_sorted[si].end()); root = std::make_unique(0, 0, side, side); if (!try_pack(root, trial_sprites, padding, allow_rotate)) { 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; + best_gpu_sprites = trial_sprites; + best_space_sprites = std::move(trial_sprites); + best_gpu_w = best_space_w = side; + best_gpu_h = best_space_h = side; + best_gpu_area = best_space_area = area; max_candidate_area = area; - have_best = true; + have_best_gpu = have_best_space = true; break; } - if (have_best) { + if (have_best_gpu) { break; } if (side > std::numeric_limits::max() / 2) { @@ -3751,13 +4568,13 @@ int run_spratlayout(int argc, char** argv) { std::vector pot_widths; std::vector pot_heights; - for (int w = min_pot_width; w > 0 && std::cmp_less_equal(w, best_area); w *= 2) { + for (int w = min_pot_width; w > 0 && std::cmp_less_equal(w, max_candidate_area); w *= 2) { pot_widths.push_back(w); if (w > std::numeric_limits::max() / 2) { break; } } - for (int h = min_pot_height; h > 0 && std::cmp_less_equal(h, best_area); h *= 2) { + for (int h = min_pot_height; h > 0 && std::cmp_less_equal(h, max_candidate_area); h *= 2) { pot_heights.push_back(h); if (h > std::numeric_limits::max() / 2) { break; @@ -3776,39 +4593,73 @@ int run_spratlayout(int argc, char** argv) { 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)) { + const bool could_beat_gpu = pick_better_layout_candidate(area, w, h, have_best_gpu, best_gpu_area, best_gpu_w, best_gpu_h, OptimizeTarget::GPU); + const bool could_beat_space = pick_better_layout_candidate(area, w, h, have_best_space, best_space_area, best_space_w, best_space_h, OptimizeTarget::SPACE); + if (!could_beat_gpu && !could_beat_space) { continue; } - for (SortMode sort_mode : sort_modes) { - if (enforce_name_order && sort_mode != SortMode::None) { + for (size_t si = 0; si < sort_modes.size(); ++si) { + if (enforce_sort_order_pot && sort_modes[si] != SortMode::None) { continue; } - std::vector trial_sprites = sprites; - sort_sprites_by_mode(trial_sprites, sort_mode); + trial_sprites.assign(pot_sorted[si].begin(), pot_sorted[si].end()); root = std::make_unique(0, 0, w, h); if (!try_pack(root, trial_sprites, padding, allow_rotate)) { continue; } - best_sprites = std::move(trial_sprites); - best_w = w; - best_h = h; - best_area = area; - have_best = true; + if (could_beat_gpu) { + best_gpu_sprites = trial_sprites; + best_gpu_w = w; + best_gpu_h = h; + best_gpu_area = area; + have_best_gpu = true; + } + if (could_beat_space) { + if (could_beat_gpu) { + best_space_sprites = best_gpu_sprites; + } else { + best_space_sprites = std::move(trial_sprites); + } + best_space_w = w; + best_space_h = h; + best_space_area = area; + have_best_space = true; + } break; } } } - if (!have_best) { + if (!have_best_gpu && !have_best_space) { std::cerr << tr("Error: failed to compute pot layout\n"); return 1; } - sprites = std::move(best_sprites); - atlas_width = best_w; - atlas_height = best_h; + if (optimize_target == OptimizeTarget::GPU) { + if (have_best_gpu) { + sprites = std::move(best_gpu_sprites); + atlas_width = best_gpu_w; + atlas_height = best_gpu_h; + } else { + std::cerr << tr("Warning: no GPU-optimal POT layout found, using space-optimal fallback\n"); + sprites = std::move(best_space_sprites); + atlas_width = best_space_w; + atlas_height = best_space_h; + } + } else { + if (have_best_space) { + sprites = std::move(best_space_sprites); + atlas_width = best_space_w; + atlas_height = best_space_h; + } else { + std::cerr << tr("Warning: no space-optimal POT layout found, using GPU-optimal fallback\n"); + sprites = std::move(best_gpu_sprites); + atlas_width = best_gpu_w; + atlas_height = best_gpu_h; + } + } atlases.push_back({atlas_width, atlas_height}); for (auto& s : sprites) { s.atlas_index = 0; } } else if (mode == Mode::COMPACT) { @@ -3816,18 +4667,6 @@ int run_spratlayout(int argc, char** argv) { std::cerr << tr("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; @@ -3837,17 +4676,17 @@ int run_spratlayout(int argc, char** argv) { worker_count = 1; #endif + const bool enforce_sort_order_compact = enforce_name_order || enforce_stable_order; std::array, k_sort_mode_count> sorted_sprites_by_mode; - int sort_idx = 0; - for (SortMode sm : sort_modes) { - if (enforce_name_order && sm != SortMode::None) { - // We must still populate the index for the array, but we can skip sorting - sorted_sprites_by_mode[sort_idx] = sprites; - } else { - sorted_sprites_by_mode[sort_idx] = sprites; - sort_sprites_by_mode(sorted_sprites_by_mode[sort_idx], sm); + sorted_sprites_by_mode[0] = sprites; + if (!(enforce_sort_order_compact && sort_modes[0] != SortMode::None)) { + sort_sprites_by_mode(sorted_sprites_by_mode[0], sort_modes[0]); + } + for (size_t sort_idx = 1; sort_idx < sort_modes.size(); ++sort_idx) { + sorted_sprites_by_mode[sort_idx] = sorted_sprites_by_mode[0]; + if (!(enforce_sort_order_compact && sort_modes[sort_idx] != SortMode::None)) { + sort_sprites_by_mode(sorted_sprites_by_mode[sort_idx], sort_modes[sort_idx]); } - ++sort_idx; } int seed_width = max_width; @@ -3899,8 +4738,8 @@ int run_spratlayout(int argc, char** argv) { } if (better_gpu && better_space) { - best_gpu_candidate = candidate; - best_space_candidate = std::move(candidate); + best_gpu_candidate = std::move(candidate); + best_space_candidate = best_gpu_candidate; return; } if (better_gpu) { @@ -3910,17 +4749,13 @@ int run_spratlayout(int argc, char** argv) { 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) { - if (enforce_name_order && sort_modes[sort_idx] != SortMode::None) { + std::vector seed_sprites; + for (size_t sort_idx = 0; sort_idx < sort_modes.size(); ++sort_idx) { + if (enforce_sort_order_compact && sort_modes[sort_idx] != SortMode::None) { continue; } for (RectHeuristic rect_heuristic : rect_heuristics) { - if (!consume_combination_budget()) { - budget_exhausted = true; - break; - } - std::vector seed_sprites = sorted_sprites_by_mode[sort_idx]; + seed_sprites.assign(sorted_sprites_by_mode[sort_idx].begin(), sorted_sprites_by_mode[sort_idx].end()); int seed_used_w = 0; int seed_used_h = 0; if (!pack_compact_maxrects(seed_sprites, seed_width, padding, height_upper_bound, rect_heuristic, allow_rotate, seed_used_w, seed_used_h)) { @@ -3979,7 +4814,7 @@ int run_spratlayout(int argc, char** argv) { const int range = std::max(0, width_upper_bound - fast_target_width); const int step = std::max(k_search_step_min, range / k_search_step_divisor); const std::array offsets = k_guided_search_offsets; - const std::array anchor_widths = {seed_width, fast_target_width, max_width}; + const std::array anchor_widths = {seed_width, fast_target_width, max_width, width_upper_bound}; for (int anchor : anchor_widths) { for (int mul : offsets) { const long long width_ll = @@ -3994,11 +4829,49 @@ int run_spratlayout(int argc, char** argv) { } std::ranges::sort(width_candidates); - if (!budget_exhausted && !width_candidates.empty()) { + if (!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); - auto run_guided_worker = [&](size_t begin, size_t end, LayoutCandidate& out_gpu, LayoutCandidate& out_space) { + const int min_square_side = + total_area > 0 + ? static_cast(std::ceil(std::sqrt(static_cast(total_area)))) + : 0; + auto select_better_candidate = [&](const LayoutCandidate& local_best, const LayoutCandidate& shared_best, OptimizeTarget target) -> const LayoutCandidate* { + if (!local_best.valid) { + return shared_best.valid ? &shared_best : nullptr; + } + if (!shared_best.valid) { + return &local_best; + } + if (pick_better_layout_candidate( + local_best.area, local_best.w, local_best.h, true, + shared_best.area, shared_best.w, shared_best.h, + target)) { + return &local_best; + } + return &shared_best; + }; + auto width_could_beat_best = [&](int width, const LayoutCandidate& local_best, const LayoutCandidate& shared_best, OptimizeTarget target) { + const LayoutCandidate* best = select_better_candidate(local_best, shared_best, target); + if (best == nullptr || total_area == 0) { + return true; + } + const size_t width_size = static_cast(width); + const size_t min_height_size = (total_area + width_size - 1) / width_size; + if (min_height_size > static_cast(std::numeric_limits::max())) { + return false; + } + const int min_height = static_cast(min_height_size); + const int min_max_side = std::max(min_square_side, min_height); + const int optimistic_w = std::min(width, min_max_side); + const int optimistic_h = min_max_side; + return pick_better_layout_candidate( + total_area, optimistic_w, optimistic_h, true, + best->area, best->w, best->h, + target); + }; + auto run_guided_worker = [&](std::atomic* next_width_index, size_t begin, size_t end, LayoutCandidate& out_gpu, LayoutCandidate& out_space) { LayoutCandidate local_best_gpu; LayoutCandidate local_best_space; auto consider_local = [&](LayoutCandidate&& candidate) { @@ -4021,8 +4894,8 @@ int run_spratlayout(int argc, char** argv) { return; } if (better_gpu && better_space) { - local_best_gpu = candidate; - local_best_space = std::move(candidate); + local_best_gpu = std::move(candidate); + local_best_space = local_best_gpu; return; } if (better_gpu) { @@ -4032,23 +4905,35 @@ int run_spratlayout(int argc, char** argv) { 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) { + std::vector trial_sprites; + std::vector shelf_sprites; + while (true) { + size_t width_index = begin; + if (next_width_index != nullptr) { + width_index = next_width_index->fetch_add(1, std::memory_order_relaxed); + if (width_index >= end) { + break; + } + } else if (width_index < end) { + ++begin; + } else { + break; + } + const int width = width_candidates[width_index]; + if (!width_could_beat_best(width, local_best_gpu, best_gpu_candidate, OptimizeTarget::GPU) && + !width_could_beat_best(width, local_best_space, best_space_candidate, OptimizeTarget::SPACE)) { + continue; + } + + bool continue_width_search = true; for (size_t sort_idx : k_guided_sort_indices) { - if (enforce_name_order && sort_idx != k_sort_mode_index_none) { + if (enforce_sort_order_compact && sort_idx != k_sort_mode_index_none) { continue; } - if (local_budget_exhausted) { - break; - } for (RectHeuristic rect_heuristic : k_guided_heuristics) { - if (!consume_combination_budget()) { - local_budget_exhausted = true; - break; - } - std::vector trial_sprites = sorted_sprites_by_mode[sort_idx]; + trial_sprites.assign(sorted_sprites_by_mode[sort_idx].begin(), sorted_sprites_by_mode[sort_idx].end()); int used_w = 0; int used_h = 0; if (!pack_compact_maxrects(trial_sprites, width, padding, height_upper_bound, rect_heuristic, allow_rotate, used_w, used_h)) { @@ -4062,94 +4947,17 @@ int run_spratlayout(int argc, char** argv) { candidate.h = used_h; candidate.sprites = std::move(trial_sprites); consider_local(std::move(candidate)); + if (!width_could_beat_best(width, local_best_gpu, best_gpu_candidate, OptimizeTarget::GPU) && + !width_could_beat_best(width, local_best_space, best_space_candidate, OptimizeTarget::SPACE)) { + continue_width_search = false; + break; + } } - } - } - - out_gpu = std::move(local_best_gpu); - out_space = std::move(local_best_space); - }; - - if (worker_count == 1) { - run_guided_worker(0, width_candidates.size(), worker_gpu[0], worker_space[0]); - } else { - 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; - run_guided_worker(begin, end, worker_gpu[worker_index], worker_space[worker_index]); - }); - } - 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); - auto run_shelf_worker = [&](size_t begin, size_t end, LayoutCandidate& out_gpu, LayoutCandidate& out_space) { - 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 : k_guided_sort_indices) { - if (enforce_name_order && sort_idx != k_sort_mode_index_none) { - continue; - } - if (!consume_combination_budget()) { - local_budget_exhausted = true; + if (!continue_width_search) { break; } - std::vector shelf_sprites = sorted_sprites_by_mode[sort_idx]; + + shelf_sprites.assign(sorted_sprites_by_mode[sort_idx].begin(), sorted_sprites_by_mode[sort_idx].end()); int shelf_w = 0; int shelf_h = 0; if (!pack_fast_shelf(shelf_sprites, width, padding, allow_rotate, shelf_w, shelf_h)) { @@ -4166,6 +4974,10 @@ int run_spratlayout(int argc, char** argv) { shelf_candidate.h = shelf_h; shelf_candidate.sprites = std::move(shelf_sprites); consider_local(std::move(shelf_candidate)); + if (!width_could_beat_best(width, local_best_gpu, best_gpu_candidate, OptimizeTarget::GPU) && + !width_could_beat_best(width, local_best_space, best_space_candidate, OptimizeTarget::SPACE)) { + break; + } } } @@ -4174,15 +4986,14 @@ int run_spratlayout(int argc, char** argv) { }; if (worker_count == 1) { - run_shelf_worker(0, width_candidates.size(), worker_gpu[0], worker_space[0]); + run_guided_worker(nullptr, 0, width_candidates.size(), worker_gpu[0], worker_space[0]); } else { + std::atomic next_width_index{0}; 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; - run_shelf_worker(begin, end, worker_gpu[worker_index], worker_space[worker_index]); + run_guided_worker(&next_width_index, 0, width_candidates.size(), worker_gpu[worker_index], worker_space[worker_index]); }); } for (auto& worker : workers) { @@ -4201,9 +5012,19 @@ int run_spratlayout(int argc, char** argv) { const LayoutCandidate* selected_candidate = nullptr; if (optimize_target == OptimizeTarget::GPU) { - selected_candidate = best_gpu_candidate.valid ? &best_gpu_candidate : &best_space_candidate; + if (best_gpu_candidate.valid) { + selected_candidate = &best_gpu_candidate; + } else { + std::cerr << tr("Warning: no GPU-optimal layout found, using space-optimal fallback\n"); + selected_candidate = &best_space_candidate; + } } else { - selected_candidate = best_space_candidate.valid ? &best_space_candidate : &best_gpu_candidate; + if (best_space_candidate.valid) { + selected_candidate = &best_space_candidate; + } else { + std::cerr << tr("Warning: no space-optimal layout found, using GPU-optimal fallback\n"); + selected_candidate = &best_gpu_candidate; + } } if ((selected_candidate == nullptr) || !selected_candidate->valid) { std::cerr << tr("Error: failed to compute compact layout\n"); @@ -4240,10 +5061,6 @@ int run_spratlayout(int argc, char** argv) { 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 @@ -4260,7 +5077,6 @@ int run_spratlayout(int argc, char** argv) { prewarm_max_height, prewarm_padding, extrude, - prewarm_max_combinations, prewarm_scale, prewarm_trim_transparent, allow_rotate, @@ -4279,6 +5095,9 @@ int run_spratlayout(int argc, char** argv) { std::vector prewarm_atlases; prewarm_atlases.push_back({prewarm_candidate.w, prewarm_candidate.h}); std::vector> empty_prewarm_aliases; + const fs::path prewarm_root = (input_context.type == InputType::ListFile) + ? input_context.working_folder.parent_path() + : input_context.working_folder; const std::string prewarm_output = build_layout_output_text( prewarm_atlases, prewarm_scale, @@ -4287,7 +5106,8 @@ int run_spratlayout(int argc, char** argv) { false, prewarm_candidate.sprites, empty_prewarm_aliases, - false + false, + prewarm_root ); save_output_cache( build_output_cache_path(cache_path, prewarm_signature), @@ -4298,6 +5118,24 @@ int run_spratlayout(int argc, char** argv) { } atlases.push_back({atlas_width, atlas_height}); for (auto& s : sprites) { s.atlas_index = 0; } + } else if (mode == Mode::GRID) { + std::vector sorted_sprites = sprites; + if (enforce_name_order) { + sort_sprites_by_mode(sorted_sprites, SortMode::None); + } else if (enforce_stable_order) { + sort_sprites_stable(sorted_sprites, stable_metric); + } + int grid_width = 0; + int grid_height = 0; + if (!pack_grid(sorted_sprites, padding, width_upper_bound, height_upper_bound, grid_width, grid_height)) { + std::cerr << tr("Error: failed to compute grid layout\n"); + return 1; + } + sprites = std::move(sorted_sprites); + atlas_width = grid_width; + atlas_height = grid_height; + atlases.push_back({atlas_width, atlas_height}); + for (auto& s : sprites) { s.atlas_index = 0; } } else { int target_width = max_width; if (total_area > 0) { @@ -4324,9 +5162,10 @@ int run_spratlayout(int argc, char** argv) { } std::vector sorted_sprites = sprites; - const bool enforce_fast_order = has_frame_sort_override ? (frame_sort == FrameSort::Name) : do_sort; - if (enforce_fast_order) { + if (enforce_name_order) { sort_sprites_by_mode(sorted_sprites, SortMode::None); + } else if (enforce_stable_order) { + sort_sprites_stable(sorted_sprites, stable_metric); } else { sort_sprites_by_mode(sorted_sprites, SortMode::Height); } @@ -4357,14 +5196,38 @@ int run_spratlayout(int argc, char** argv) { } } - if (padding > 0 && !multipack) { - if (!compute_tight_atlas_bounds(sprites, atlas_width, atlas_height)) { - std::cerr << tr("Error: failed to compute final atlas bounds\n"); - return 1; - } - if (!atlases.empty()) { - atlases[0].width = atlas_width; - atlases[0].height = atlas_height; + if (padding > 0 && mode != Mode::GRID) { + if (multipack) { + for (size_t ai = 0; ai < atlases.size(); ++ai) { + int tight_w = 0; + int tight_h = 0; + for (const auto& s : sprites) { + if (s.atlas_index != static_cast(ai)) { + continue; + } + int x1 = 0; + int y1 = 0; + if (!checked_add_int(s.x, s.w, x1) || !checked_add_int(s.y, s.h, y1)) { + std::cerr << tr("Error: failed to compute final atlas bounds\n"); + return 1; + } + tight_w = std::max(x1, tight_w); + tight_h = std::max(y1, tight_h); + } + if (tight_w > 0 && tight_h > 0) { + atlases[ai].width = tight_w; + atlases[ai].height = tight_h; + } + } + } else { + if (!compute_tight_atlas_bounds(sprites, atlas_width, atlas_height)) { + std::cerr << tr("Error: failed to compute final atlas bounds\n"); + return 1; + } + if (!atlases.empty()) { + atlases[0].width = atlas_width; + atlases[0].height = atlas_height; + } } } @@ -4392,8 +5255,9 @@ int run_spratlayout(int argc, char** argv) { save_layout_seed_cache(seed_cache_path, next_seed); } - // Placeholder aliases vector (deduplication not yet fully implemented) - const std::vector> layout_aliases; + const fs::path output_root = (input_context.type == InputType::ListFile) + ? input_context.working_folder.parent_path() + : input_context.working_folder; const std::string output_text = build_layout_output_text( atlases, scale, @@ -4402,7 +5266,8 @@ int run_spratlayout(int argc, char** argv) { multipack, sprites, layout_aliases, - debug + debug, + output_root ); #ifdef _WIN32 diff --git a/src/commands/spratpack_command.cpp b/src/commands/spratpack_command.cpp index 6b36d7a..aae9fb3 100644 --- a/src/commands/spratpack_command.cpp +++ b/src/commands/spratpack_command.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include "core/layout_parser.h" @@ -266,15 +267,16 @@ void dilate_sprite_colors( return; } - // Make a copy of the atlas for reading during dilate - std::vector atlas_snapshot = atlas; + // Two-buffer approach: read_buf holds the previous pass state, atlas is the write target. + // After each pass we copy only the affected sprite region back instead of the full atlas. + std::vector read_buf = atlas; - auto get_pixel = [&](int px, int py, size_t channel) -> unsigned char { + auto get_pixel = [&](const std::vector& buf, int px, int py, size_t channel) -> unsigned char { if (px < 0 || py < 0 || px >= atlas_width || py >= atlas_height) { return 0; } size_t offset = (static_cast(py) * atlas_width + px) * NUM_CHANNELS + channel; - return atlas_snapshot[offset]; + return buf[offset]; }; auto set_pixel_rgb = [&](int px, int py, unsigned char r, unsigned char g, unsigned char b) { @@ -292,31 +294,41 @@ void dilate_sprite_colors( continue; } + // Clamp the dilate region to atlas bounds (sprite bbox expanded by 1 pixel) + const int region_y0 = std::max(0, s.y - 1); + const int region_y1 = std::min(atlas_height - 1, s.y + s.h); + const int region_x0 = std::max(0, s.x - 1); + const int region_x1 = std::min(atlas_width - 1, s.x + s.w); + const size_t region_row_bytes = static_cast(region_x1 - region_x0 + 1) * NUM_CHANNELS; + // For each pass, dilate colors from opaque pixels to transparent neighbors for (int pass = 0; pass < radius; ++pass) { - // Take snapshot after previous pass - atlas_snapshot = atlas; + // Copy only the affected region from atlas to read_buf + for (int y = region_y0; y <= region_y1; ++y) { + size_t row_offset = (static_cast(y) * atlas_width + region_x0) * NUM_CHANNELS; + std::memcpy(&read_buf[row_offset], &atlas[row_offset], region_row_bytes); + } // Check pixels around (and outside) each sprite - for (int y = s.y - 1; y <= s.y + s.h; ++y) { - for (int x = s.x - 1; x <= s.x + s.w; ++x) { + for (int y = region_y0; y <= region_y1; ++y) { + for (int x = region_x0; x <= region_x1; ++x) { // Only process transparent pixels - if (get_pixel(x, y, CHANNEL_A) != 0) { + if (get_pixel(read_buf, x, y, CHANNEL_A) != 0) { continue; } // Check 4 cardinal directions for opaque neighbors - const int dx[] = {-1, 1, 0, 0}; - const int dy[] = {0, 0, -1, 1}; + constexpr int dx[] = {-1, 1, 0, 0}; + constexpr int dy[] = {0, 0, -1, 1}; for (int dir = 0; dir < 4; ++dir) { int nx = x + dx[dir]; int ny = y + dy[dir]; - unsigned char alpha = get_pixel(nx, ny, CHANNEL_A); + unsigned char alpha = get_pixel(read_buf, nx, ny, CHANNEL_A); if (alpha != 0) { // Found opaque neighbor, copy its RGB - unsigned char r = get_pixel(nx, ny, CHANNEL_R); - unsigned char g = get_pixel(nx, ny, CHANNEL_G); - unsigned char b = get_pixel(nx, ny, CHANNEL_B); + unsigned char r = get_pixel(read_buf, nx, ny, CHANNEL_R); + unsigned char g = get_pixel(read_buf, nx, ny, CHANNEL_G); + unsigned char b = get_pixel(read_buf, nx, ny, CHANNEL_B); set_pixel_rgb(x, y, r, g, b); break; // Only copy from first opaque neighbor } @@ -425,7 +437,7 @@ void print_usage() { << tr("Writes PNG to stdout for single-atlas input; TAR to stdout for multipack input.\n") << tr("\n") << tr("Options:\n") - << tr(" -o, --output PATTERN Output filename pattern (e.g. atlas_%d.png)\n") + << tr(" -a, --atlas PATTERN Output filename pattern (e.g. atlas_%d.png)\n") << tr(" --atlas-index N Pick a specific atlas index to output\n") << tr(" --extrude N Repeat edge pixels N times (overrides layout)\n") << tr(" --dilate N Bleed opaque pixels into transparent neighbors (N passes)\n") @@ -473,7 +485,7 @@ int run_spratpack(int argc, char** argv) { protect = true; } else if (arg == "--zopfli") { use_zopfli = true; - } else if ((arg == "--output" || arg == "-o") && i + 1 < argc) { + } else if ((arg == "--atlas" || arg == "-a" || arg == "--output" || arg == "-o") && i + 1 < argc) { output_pattern = argv[++i]; } else if (arg == "--atlas-index" && i + 1 < argc) { std::string value = argv[++i]; @@ -545,6 +557,27 @@ int run_spratpack(int argc, char** argv) { return 1; } + // Resolve relative sprite paths using the root directory from the layout. + if (layout.has_root && !layout.root.empty()) { + std::filesystem::path root_path(layout.root); + for (auto& sprite : layout.sprites) { + std::filesystem::path sp(sprite.path); + if (sp.is_relative()) { + sprite.path = (root_path / sp).string(); + } + } + for (auto& alias : layout.aliases) { + std::filesystem::path ap(alias.first); + if (ap.is_relative()) { + alias.first = (root_path / ap).string(); + } + std::filesystem::path cp(alias.second); + if (cp.is_relative()) { + alias.second = (root_path / cp).string(); + } + } + } + if (requested_atlas_index >= 0 && static_cast(requested_atlas_index) >= layout.atlases.size()) { std::cerr << tr("Error: requested atlas index ") << requested_atlas_index << tr(" out of range (total: ") << layout.atlases.size() << ")\n"; @@ -589,6 +622,14 @@ int run_spratpack(int argc, char** argv) { } } + // Pre-group sprites by atlas index + std::vector> sprites_by_atlas(layout.atlases.size()); + for (const auto& s : layout.sprites) { + if (s.atlas_index >= 0 && static_cast(s.atlas_index) < layout.atlases.size()) { + sprites_by_atlas[static_cast(s.atlas_index)].push_back(s); + } + } + for (size_t atlas_idx = 0; atlas_idx < layout.atlases.size(); ++atlas_idx) { if (requested_atlas_index >= 0 && static_cast(requested_atlas_index) != atlas_idx) { continue; @@ -596,12 +637,7 @@ int run_spratpack(int argc, char** argv) { const int atlas_width = layout.atlases[atlas_idx].width; const int atlas_height = layout.atlases[atlas_idx].height; - std::vector atlas_sprites; - for (const auto& s : layout.sprites) { - if (s.atlas_index == static_cast(atlas_idx)) { - atlas_sprites.push_back(s); - } - } + const std::vector& atlas_sprites = sprites_by_atlas[atlas_idx]; size_t pixel_count = 0; size_t byte_count = 0; diff --git a/src/commands/spratunpack_command.cpp b/src/commands/spratunpack_command.cpp index 0ee4d40..0f32320 100644 --- a/src/commands/spratunpack_command.cpp +++ b/src/commands/spratunpack_command.cpp @@ -593,30 +593,38 @@ class SpriteUnpacker { return false; } - bool write_sprite_to_archive_entry(struct archive* a, const SpriteFrame& frame) { + std::vector extract_sprite_pixels(const SpriteFrame& frame) { const auto& bounds = frame.frame; const int out_w = frame.rotated ? bounds.h : bounds.w; const int out_h = frame.rotated ? bounds.w : bounds.h; - + std::vector sprite_data(static_cast(out_w) * out_h * NUM_CHANNELS); - for (int oy = 0; oy < out_h; oy++) { - for (int ox = 0; ox < out_w; ox++) { - int atlas_x = 0; - int atlas_y = 0; - if (frame.rotated) { - atlas_x = bounds.x + (out_h - 1 - oy); - atlas_y = bounds.y + ox; - } else { - atlas_x = bounds.x + ox; - atlas_y = bounds.y + oy; + if (!frame.rotated) { + const size_t row_bytes = static_cast(out_w) * NUM_CHANNELS; + for (int oy = 0; oy < out_h; oy++) { + const size_t dst_offset = static_cast(oy) * out_w * NUM_CHANNELS; + const size_t src_offset = (static_cast(bounds.y + oy) * width_ + bounds.x) * NUM_CHANNELS; + std::memcpy(&sprite_data[dst_offset], &image_data_[src_offset], row_bytes); + } + } else { + for (int oy = 0; oy < out_h; oy++) { + for (int ox = 0; ox < out_w; ox++) { + const int atlas_x = bounds.x + (out_h - 1 - oy); + const int atlas_y = bounds.y + ox; + const size_t dst_idx = (static_cast(oy) * out_w + ox) * NUM_CHANNELS; + const size_t src_idx = (static_cast(atlas_y) * width_ + atlas_x) * NUM_CHANNELS; + std::memcpy(&sprite_data[dst_idx], &image_data_[src_idx], NUM_CHANNELS); } - - const size_t dst_idx = (static_cast(oy) * out_w + ox) * NUM_CHANNELS; - const size_t src_idx = (static_cast(atlas_y) * width_ + atlas_x) * NUM_CHANNELS; - - std::memcpy(&sprite_data[dst_idx], &image_data_[src_idx], NUM_CHANNELS); } } + return sprite_data; + } + + bool write_sprite_to_archive_entry(struct archive* a, const SpriteFrame& frame) { + const int out_w = frame.rotated ? frame.frame.h : frame.frame.w; + const int out_h = frame.rotated ? frame.frame.w : frame.frame.h; + + std::vector sprite_data = extract_sprite_pixels(frame); // Encode as PNG in memory std::vector png_buffer; @@ -664,35 +672,16 @@ class SpriteUnpacker { } bool save_sprite_image(const SpriteFrame& frame) { - const auto& bounds = frame.frame; - const int out_w = frame.rotated ? bounds.h : bounds.w; - const int out_h = frame.rotated ? bounds.w : bounds.h; - - std::vector sprite_data(static_cast(out_w) * out_h * NUM_CHANNELS); - for (int oy = 0; oy < out_h; oy++) { - for (int ox = 0; ox < out_w; ox++) { - int atlas_x = 0; - int atlas_y = 0; - if (frame.rotated) { - atlas_x = bounds.x + (out_h - 1 - oy); - atlas_y = bounds.y + ox; - } else { - atlas_x = bounds.x + ox; - atlas_y = bounds.y + oy; - } + const int out_w = frame.rotated ? frame.frame.h : frame.frame.w; + const int out_h = frame.rotated ? frame.frame.w : frame.frame.h; - const size_t dst_idx = (static_cast(oy) * out_w + ox) * NUM_CHANNELS; - const size_t src_idx = (static_cast(atlas_y) * width_ + atlas_x) * NUM_CHANNELS; - - std::memcpy(&sprite_data[dst_idx], &image_data_[src_idx], NUM_CHANNELS); - } - } + std::vector sprite_data = extract_sprite_pixels(frame); fs::path output_path = config_.output_dir / frame.name; if (output_path.extension().empty()) { output_path += ".png"; } - + fs::create_directories(output_path.parent_path()); return stbi_write_png(output_path.string().c_str(), diff --git a/src/core/layout_parser.cpp b/src/core/layout_parser.cpp index fffc7a2..79e1016 100644 --- a/src/core/layout_parser.cpp +++ b/src/core/layout_parser.cpp @@ -261,6 +261,23 @@ bool parse_layout(std::istream& in, Layout& out, std::string& error) { return false; } parsed.atlases.push_back({w, h}); + } else if (line.starts_with("root")) { + if (parsed.has_root) { + error = "Duplicate root line"; + return false; + } + size_t pos = 4; + while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) { + ++pos; + } + std::string root_path; + std::string root_error; + if (!parse_quoted(line, pos, root_path, root_error)) { + error = "Invalid root line: " + root_error; + return false; + } + parsed.root = root_path; + parsed.has_root = true; } else if (line.starts_with("scale")) { if (parsed.has_scale) { error = "Duplicate scale line"; diff --git a/src/core/layout_parser.h b/src/core/layout_parser.h index 9581018..8b1a3f0 100644 --- a/src/core/layout_parser.h +++ b/src/core/layout_parser.h @@ -45,6 +45,8 @@ struct Atlas { struct Layout { std::vector atlases; + std::string root; + bool has_root = false; double scale = 1.0; bool has_scale = false; int extrude = 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 21aaf79..96c5321 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,6 +74,12 @@ add_test( $ ) +add_test( + NAME unity + COMMAND ${BASH_EXE} ${CMAKE_CURRENT_SOURCE_DIR}/unity_test.sh + $ +) + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/sort_behavior_test.sh") add_test( NAME sort_behavior @@ -103,3 +109,23 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/compact_seed_search_regression_test.sh") else() message(WARNING "Skipping compact_seed_search_regression test: script not found") endif() + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/recursive_dir_test.sh") + add_test( + NAME recursive_dir + COMMAND ${BASH_EXE} ${CMAKE_CURRENT_SOURCE_DIR}/recursive_dir_test.sh + $ + ) +else() + message(WARNING "Skipping recursive_dir test: script not found") +endif() + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/spratlayout_exclude_test.sh") + add_test( + NAME spratlayout_exclude + COMMAND ${BASH_EXE} ${CMAKE_CURRENT_SOURCE_DIR}/spratlayout_exclude_test.sh + $ + ) +else() + message(WARNING "Skipping spratlayout_exclude test: script not found") +endif() diff --git a/tests/convert_test.sh b/tests/convert_test.sh index 7590623..18cff65 100755 --- a/tests/convert_test.sh +++ b/tests/convert_test.sh @@ -39,8 +39,8 @@ sprite "./frames/a\"q.png" 0,0 8,8 LAYOUTQ "$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 +for fmt in JSON CSV XML CSS; do + if ! grep -qi "^${fmt}\b" "$tmp_dir/list.txt"; then echo "Missing transform in list: $fmt" >&2 exit 1 fi @@ -52,6 +52,7 @@ grep -q '"height": 32' "$tmp_dir/out.json" grep -q '"multipack": false' "$tmp_dir/out.json" grep -q '"extrude": 0' "$tmp_dir/out.json" grep -q '"path": "./frames/b.png"' "$tmp_dir/out.json" +grep -q '"atlas_index": 0' "$tmp_dir/out.json" "$convert_bin" --transform csv < "$layout_file" > "$tmp_dir/out.csv" grep -q '^index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json,rotation$' "$tmp_dir/out.csv" @@ -63,33 +64,22 @@ 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 -Fq '.sprite-b {' "$tmp_dir/out.css" grep -q '^ background-position: -16px -0px;$' "$tmp_dir/out.css" -custom_transform="$tmp_dir/custom.transform" +# ── custom transform (Jsonnet) ─────────────────────────────────────────────── +custom_transform="$tmp_dir/custom.jsonnet" 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}} rotated={{rotated}} - [/sprite] -[/sprites] - -[separator] -; -[/separator] - -[footer] - -END -[/footer] +local sprat = std.extVar("sprat"); +local sprite_line(s) = + "" + s.index + "|" + s.path + "|" + s.x + "," + s.y + " " + s.w + "x" + s.h + " rotated=" + s.rotated; +{ + name: "custom", + extension: "", + content: + "BEGIN " + sprat.atlas_width + "x" + sprat.atlas_height + " count=" + sprat.sprite_count + "\n" + + std.join("\n;\n", [sprite_line(s) for s in sprat.sprites]) + "\n\nEND\n", +} CUSTOM "$convert_bin" --transform "$(fix_path "$custom_transform")" < "$layout_file" > "$tmp_dir/out.custom" @@ -97,6 +87,24 @@ grep -q '^BEGIN 64x32 count=2' "$tmp_dir/out.custom" grep -q '0|./frames/a.png|0,0 16x16 rotated=false' "$tmp_dir/out.custom" grep -q '1|./frames/b.png|16,0 8x8 rotated=true' "$tmp_dir/out.custom" +# ── source_size transform (Jsonnet) ───────────────────────────────────────── +source_size_transform="$tmp_dir/source_size.jsonnet" +cat > "$source_size_transform" <<'SRCSIZE' +local sprat = std.extVar("sprat"); +local sprite_line(s) = "" + s.index + "|" + s.source_w + "x" + s.source_h + "|" + s.has_trim; +{ + name: "source_size", + extension: "", + content: std.join("\n", [sprite_line(s) for s in sprat.sprites]) + "\n", +} +SRCSIZE + +"$convert_bin" --transform "$(fix_path "$source_size_transform")" < "$layout_file" > "$tmp_dir/out.source_size" +# sprite a: no trim, source size equals packed size (16x16) +grep -q '0|16x16|false' "$tmp_dir/out.source_size" +# sprite b: trim_left=1 trim_top=2 trim_right=3 trim_bottom=4, packed 8x8 => source 12x14 +grep -q '1|12x14|true' "$tmp_dir/out.source_size" + markers_file="$tmp_dir/markers.txt" cat > "$markers_file" <<'MARKERS' path "./frames/a.png" @@ -115,26 +123,39 @@ animation "idle" - frame 1 ANIMS -extras_transform="$tmp_dir/extras.transform" +# ── extras transform (Jsonnet) ─────────────────────────────────────────────── +extras_transform="$tmp_dir/extras.jsonnet" 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}}|{{markers_json}} - [/sprite] -[/sprites] +local sprat = std.extVar("sprat"); + +local fmt_marker(m) = + '{"name":' + std.manifestJson(m.name) + ',"type":' + std.manifestJson(m.type) + + ',"x":' + m.x + ',"y":' + m.y + + (if m.type == "circle" then ',"radius":' + m.radius else "") + + (if m.type == "rectangle" then ',"w":' + m.w + ',"h":' + m.h else "") + + (if m.type == "polygon" then + ',"vertices":[' + std.join(",", ['{"x":' + v.x + ',"y":' + v.y + '}' for v in m.vertices]) + ']' + else "") + + "}"; + +local fmt_markers_json(markers) = "[" + std.join(",", [fmt_marker(m) for m in markers]) + "]"; + +local sprite_line(s) = + "" + s.index + "|" + s.name + "|" + s.path + "|" + + std.length(s.markers) + "|" + fmt_markers_json(s.markers); + +local header = + "markers=" + sprat.has_markers + " animations=" + sprat.has_animations + "\n" + + "markers_path=" + sprat.markers_path + "\n" + + "animations_path=" + sprat.animations_path + "\n" + + "marker_count=" + sprat.marker_count + "\n" + + "animation_count=" + sprat.animation_count + "\n\n"; + +{ + name: "extras", + extension: "", + content: header + std.join("", [sprite_line(s) + "\n" for s in sprat.sprites]), +} EXTRAS "$convert_bin" --transform "$(fix_path "$extras_transform")" --markers "$(fix_path "$markers_file")" --animations "$(fix_path "$animations_file")" < "$layout_file" > "$tmp_dir/out.extras" @@ -146,94 +167,42 @@ 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" +# ── iter transform (Jsonnet) ───────────────────────────────────────────────── +iter_transform="$tmp_dir/iter.jsonnet" 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}}:[{{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] +local sprat = std.extVar("sprat"); + +local markers_content = + if sprat.has_markers then + "M_ON\n\nM_BEGIN\n\n" + + std.join("|", [ + "M" + m.index + "=" + m.name + "@" + m.sprite_index + ":" + m.sprite_name + "\n" + for m in sprat.markers + ]) + + "M_END\n\n" + else + "M_EMPTY\n\n"; + +local sprites_content = + std.join("\n;\n", ["S" + s.index + "=" + s.path for s in sprat.sprites]) + "\n\n"; + +local anims_content = + if sprat.has_animations then + "A_ON\n\nA_BEGIN\n\n" + + std.join("|", [ + "A" + a.index + "=" + a.name + ":[" + + std.join(",", ["" + idx for idx in a.frame_indices]) + "]\n" + for a in sprat.animations + ]) + + "A_END\n\n" + else + "A_EMPTY\n\n"; + +{ + name: "iter", + extension: "", + content: "BEGIN\n\n" + markers_content + sprites_content + anims_content + "END\n", +} ITER "$convert_bin" --transform "$(fix_path "$iter_transform")" --markers "$(fix_path "$markers_file")" --animations "$(fix_path "$animations_file")" < "$layout_file" > "$tmp_dir/out.iter.full" @@ -277,66 +246,54 @@ fi 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" +# Marker fields appear on separate lines in pretty-printed JSON output +grep -q '"name": "hit"' "$tmp_dir/out.builtin.json" +grep -q '"type": "point"' "$tmp_dir/out.builtin.json" +grep -q '"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 -atlases_line="$(grep -n '"atlases": \[' "$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 "$atlases_line" ]; then - echo "animations section must be after atlases in json transform" >&2 - exit 1 +# sprite_indexes and sprite_names are pretty-printed arrays; check field presence and values +grep -q '"sprite_indexes"' "$tmp_dir/out.builtin.json" +grep -q '"sprite_names"' "$tmp_dir/out.builtin.json" +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +run = next(a for a in d['animations'] if a['name'] == 'run') +assert run['sprite_indexes'] == [0, 1], 'sprite_indexes mismatch' +assert run['sprite_names'] == ['a', 'b'], 'sprite_names mismatch' +a_sp = next(s for s in d['sprites'] if s['name'] == 'a') +hit = next(m for m in a_sp['markers'] if m['name'] == 'hit') +assert hit['type'] == 'point' and hit['x'] == 3 and hit['y'] == 5, 'hit marker mismatch' +hurt = next(m for m in a_sp['markers'] if m['name'] == 'hurt') +assert hurt['type'] == 'circle' and hurt['x'] == 6 and hurt['y'] == 7 and hurt['radius'] == 4, 'hurt marker mismatch' +" "$(fix_path "$tmp_dir/out.builtin.json")" fi +# Both atlases and animations sections must be present (alphabetical order puts animations first) +grep -q '"atlases"' "$tmp_dir/out.builtin.json" +grep -q '"animations"' "$tmp_dir/out.builtin.json" -json_auto_escape_transform="$tmp_dir/json_auto_escape.transform" +# ── JSON auto-escape transform (Jsonnet) ───────────────────────────────────── +json_auto_escape_transform="$tmp_dir/json_auto_escape.jsonnet" cat > "$json_auto_escape_transform" <<'JSONAUTO' -[meta] -name=json_auto_escape -extension=.json -[/meta] - -[header] -{"markers":[ -[/header] - -[sprites] - [sprite] -{"name":"{{name}}","path":"{{path}}"} - [/sprite] -[/sprites] - -[separator] -, -[/separator] - -[if_markers] -[/if_markers] - -[markers] - [marker] -{"name":"{{marker_name}}","type":"{{marker_type}}"} - [/marker] -[/markers] - -[markers_separator] -, -[/markers_separator] - -[if_no_markers] -],"sprites":[ -[/if_no_markers] - -[markers_footer] -],"sprites":[ -[/markers_footer] - -[footer] -]} -[/footer] +local sprat = std.extVar("sprat"); + +local sprite_part(s) = + '{"name":' + std.manifestJson(s.name) + ',"path":' + std.manifestJson(s.path) + '}'; + +local marker_part(m) = + '{"name":' + std.manifestJson(m.name) + ',"type":' + std.manifestJson(m.type) + '}'; + +{ + name: "json_auto_escape", + extension: ".json", + content: + '{"markers":[\n' + + std.join(",\n", [marker_part(m) for m in sprat.markers]) + + '\n],"sprites":[\n' + + std.join(",\n", [sprite_part(s) for s in sprat.sprites]) + + '\n]}', +} JSONAUTO markers_quotes_file="$tmp_dir/markers.quotes.txt" @@ -355,4 +312,168 @@ grep -Fq '"name":"a\"q"' "$tmp_dir/out.json.autoescape" grep -Fq '"path":"./frames/a\"q.png"' "$tmp_dir/out.json.autoescape" grep -Fq '"name":"hit\"zone"' "$tmp_dir/out.json.autoescape" +# --- Animation alias test --- +animations_alias_file="$tmp_dir/animations_alias.txt" +cat > "$animations_alias_file" <<'ALIASANIMS' +animation "run" +- frame "./frames/a.png" +- frame "b" +animation "idle" +- frame 1 +animation "run-alias" alias "run" flip h +ALIASANIMS + +"$convert_bin" --transform json --animations "$(fix_path "$animations_alias_file")" < "$layout_file" > "$tmp_dir/out.alias.json" +grep -q '"name": "run-alias"' "$tmp_dir/out.alias.json" +grep -q '"alias": "run"' "$tmp_dir/out.alias.json" +grep -q '"flip": "h"' "$tmp_dir/out.alias.json" +if grep -q '"flip": "v' "$tmp_dir/out.alias.json"; then + echo "alias with h-only flip should not have v in flip value" >&2 + exit 1 +fi +# With pretty-printed JSON, use python3 to validate alias vs regular animation fields +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +alias = next(a for a in d['animations'] if a['name'] == 'run-alias') +assert 'fps' not in alias, 'alias entry should not have fps field' +assert 'sprite_indexes' not in alias, 'alias entry should not have sprite_indexes field' +run = next(a for a in d['animations'] if a['name'] == 'run') +assert run['fps'] == 8, 'run fps mismatch' +assert run['sprite_indexes'] == [0, 1], 'run sprite_indexes mismatch' +" "$(fix_path "$tmp_dir/out.alias.json")" +fi +# regular animations unaffected +grep -q '"name": "run"' "$tmp_dir/out.alias.json" +grep -q '"fps": 8' "$tmp_dir/out.alias.json" +grep -q '"sprite_indexes"' "$tmp_dir/out.alias.json" + +# ── Aseprite non-contiguous animation (consecutive_runs) ───────────────────── +noncontig_layout="$tmp_dir/noncontig.txt" +cat > "$noncontig_layout" <<'NCLAYOUT' +atlas 128,16 +scale 1 +sprite "f0.png" 0,0 16,16 +sprite "f1.png" 16,0 16,16 +sprite "f2.png" 32,0 16,16 +sprite "f3.png" 48,0 16,16 +sprite "f4.png" 64,0 16,16 +NCLAYOUT + +noncontig_anims="$tmp_dir/noncontig_anims.txt" +cat > "$noncontig_anims" <<'NCANIMS' +animation "jump" +- frame "f0.png" +- frame "f2.png" +- frame "f4.png" +animation "walk" +- frame "f0.png" +- frame "f1.png" +- frame "f2.png" +NCANIMS + +"$convert_bin" --transform aseprite --animations "$(fix_path "$noncontig_anims")" < "$noncontig_layout" > "$tmp_dir/out.aseprite.nc.json" +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +tags = d['meta']['frameTags'] +jump_tags = [t for t in tags if t['name'] == 'jump'] +assert len(jump_tags) == 3, 'jump: expected 3 frameTags, got ' + str(len(jump_tags)) +assert jump_tags[0]['from'] == 0 and jump_tags[0]['to'] == 0, 'jump tag 0 mismatch' +assert jump_tags[1]['from'] == 2 and jump_tags[1]['to'] == 2, 'jump tag 1 mismatch' +assert jump_tags[2]['from'] == 4 and jump_tags[2]['to'] == 4, 'jump tag 2 mismatch' +walk_tags = [t for t in tags if t['name'] == 'walk'] +assert len(walk_tags) == 1, 'walk: expected 1 frameTag, got ' + str(len(walk_tags)) +assert walk_tags[0]['from'] == 0 and walk_tags[0]['to'] == 2, 'walk tag mismatch' +" "$(fix_path "$tmp_dir/out.aseprite.nc.json")" +fi + +# ── Multi-atlas (multipack) layout ─────────────────────────────────────────── +multipack_layout="$tmp_dir/multipack.txt" +cat > "$multipack_layout" <<'MPLAYOUT' +multipack true +atlas 32,32 +scale 1 +sprite "p0.png" 0,0 16,16 +atlas 32,32 +sprite "p1.png" 0,0 16,16 +MPLAYOUT + +"$convert_bin" --transform json --atlas 'atlas_%d.png' < "$multipack_layout" > "$tmp_dir/out.multipack.json" +grep -q '"multipack": true' "$tmp_dir/out.multipack.json" +if command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys +d = json.load(open(sys.argv[1])) +assert d['multipack'] == True, 'multipack should be true' +assert len(d['atlases']) == 2, 'expected 2 atlases, got ' + str(len(d['atlases'])) +p0 = next(s for s in d['sprites'] if s['name'] == 'p0') +p1 = next(s for s in d['sprites'] if s['name'] == 'p1') +assert p0['atlas_index'] == 0, 'p0 should be atlas_index 0, got ' + str(p0['atlas_index']) +assert p1['atlas_index'] == 1, 'p1 should be atlas_index 1, got ' + str(p1['atlas_index']) +" "$(fix_path "$tmp_dir/out.multipack.json")" +fi + +# ── Empty animations file ───────────────────────────────────────────────────── +empty_anims_file="$tmp_dir/empty_anims.txt" +cat > "$empty_anims_file" <<'EMPTYANIMS' +fps 12 +# no animation definitions follow +EMPTYANIMS + +"$convert_bin" --transform "$(fix_path "$iter_transform")" --animations "$(fix_path "$empty_anims_file")" < "$layout_file" > "$tmp_dir/out.iter.emptyanims" +grep -q '^A_EMPTY$' "$tmp_dir/out.iter.emptyanims" +if grep -q '^A_ON$' "$tmp_dir/out.iter.emptyanims"; then + echo "empty animations file should not activate animation branch" >&2 + exit 1 +fi +"$convert_bin" --transform json --animations "$(fix_path "$empty_anims_file")" < "$layout_file" > "$tmp_dir/out.json.emptyanims" +if grep -q '"animations"' "$tmp_dir/out.json.emptyanims"; then + echo "empty animations file should not produce animations key in JSON output" >&2 + exit 1 +fi +grep -q '"sprites"' "$tmp_dir/out.json.emptyanims" + +# --- --output-dir group mode test --- +# Create two lightweight group transforms in the active transforms directory. +transforms_dir="$("$convert_bin" --transforms-dir)" +grp_a="$transforms_dir/tstsuite.txt.jsonnet" +grp_b="$transforms_dir/tstsuite.json.jsonnet" +trap 'rm -rf "$tmp_dir"; rm -f "$grp_a" "$grp_b"' EXIT + +cat > "$grp_a" <<'GRPA' +local sprat = std.extVar("sprat"); +{ + name: "tstsuite_txt", + extension: ".txt", + content: "stem=" + sprat.output_stem + "\n" + + std.join("\n", [s.name for s in sprat.sprites]) + "\n", +} +GRPA + +cat > "$grp_b" <<'GRPB' +local sprat = std.extVar("sprat"); +{ + name: "tstsuite_json", + extension: ".json", + content: '{"stem":"' + sprat.output_stem + '","sprites":[' + + std.join(",", ['"' + s.name + '"' for s in sprat.sprites]) + ']}', +} +GRPB + +group_out="$tmp_dir/group_out" +"$convert_bin" --transform tstsuite --output-dir "$(fix_path "$group_out")" < "$layout_file" +test -f "$group_out/txt.txt" +test -f "$group_out/json.json" +grep -q 'stem=txt' "$group_out/txt.txt" +grep -q '"stem":"json"' "$group_out/json.json" + +# --output-dir single mode: write to file using stem-derived name +single_out="$tmp_dir/single_out" +"$convert_bin" --transform tstsuite.json --output-dir "$(fix_path "$single_out")" < "$layout_file" +test -f "$single_out/json.json" +grep -q '"stem":"json"' "$single_out/json.json" + echo "convert_test.sh: ok" diff --git a/tests/output_pattern_test.sh b/tests/output_pattern_test.sh index aa7988e..4183657 100644 --- a/tests/output_pattern_test.sh +++ b/tests/output_pattern_test.sh @@ -26,49 +26,68 @@ scale 1 LAYOUT # spratconvert: valid integer substitution and escaped percent. -"$spratconvert_bin" --transform json --output 'atlas_%d%%.png' < "$layout_multi" > "$tmp_dir/convert_valid.json" +"$spratconvert_bin" --transform json --atlas 'atlas_%d%%.png' < "$layout_multi" > "$tmp_dir/convert_valid.json" grep -q '"path": "atlas_0%.png"' "$tmp_dir/convert_valid.json" grep -q '"path": "atlas_1%.png"' "$tmp_dir/convert_valid.json" +# spratconvert: backward compatibility for --output +"$spratconvert_bin" --transform json --output 'compat_%d.png' < "$layout_multi" > "$tmp_dir/convert_compat.json" +grep -q '"path": "compat_0.png"' "$tmp_dir/convert_compat.json" + +# spratconvert: backward compatibility for -o +"$spratconvert_bin" --transform json -o 'short_%d.png' < "$layout_multi" > "$tmp_dir/convert_short.json" +grep -q '"path": "short_0.png"' "$tmp_dir/convert_short.json" + +# spratconvert: new short flag -a +"$spratconvert_bin" --transform json -a 'atlas_a_%d.png' < "$layout_multi" > "$tmp_dir/convert_atlas_a.json" +grep -q '"path": "atlas_a_0.png"' "$tmp_dir/convert_atlas_a.json" + # spratconvert: reject unsupported placeholders. -if "$spratconvert_bin" --transform json --output 'atlas_%s.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_bad_spec.err"; then - echo "spratconvert accepted unsupported output placeholder %s" >&2 +if "$spratconvert_bin" --transform json --atlas 'atlas_%s.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_bad_spec.err"; then + echo "spratconvert accepted unsupported atlas placeholder %s" >&2 exit 1 fi grep -q "Invalid output pattern" "$tmp_dir/convert_bad_spec.err" # spratconvert: reject missing %d for multi-atlas layouts. -if "$spratconvert_bin" --transform json --output 'atlas.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_no_placeholder.err"; then - echo "spratconvert accepted output pattern without %d for multi-atlas layout" >&2 +if "$spratconvert_bin" --transform json --atlas 'atlas.png' < "$layout_multi" > /dev/null 2> "$tmp_dir/convert_no_placeholder.err"; then + echo "spratconvert accepted atlas pattern without %d for multi-atlas layout" >&2 exit 1 fi grep -q "must include %d" "$tmp_dir/convert_no_placeholder.err" # spratconvert: single atlas can use a literal filename. -"$spratconvert_bin" --transform json --output 'atlas.png' < "$layout_single" > "$tmp_dir/convert_single.json" +"$spratconvert_bin" --transform json --atlas 'atlas.png' < "$layout_single" > "$tmp_dir/convert_single.json" grep -q '"path": "atlas.png"' "$tmp_dir/convert_single.json" # spratpack: valid integer substitution writes both atlas files. ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%d.png' < "$layout_multi" + "$spratpack_bin" --atlas 'atlas_%d.png' < "$layout_multi" ) test -f "$tmp_dir/atlas_0.png" test -f "$tmp_dir/atlas_1.png" +# spratpack: backward compatibility for --output +( + cd "$tmp_dir" + "$spratpack_bin" --output 'compat_pack_%d.png' < "$layout_multi" +) +test -f "$tmp_dir/compat_pack_0.png" + # spratpack: escaped percent is supported. ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%d%%.png' < "$layout_single" + "$spratpack_bin" --atlas 'atlas_%d%%.png' < "$layout_single" ) test -f "$tmp_dir/atlas_0%.png" # spratpack: reject unsupported placeholders. if ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%s.png' < "$layout_single" + "$spratpack_bin" --atlas 'atlas_%s.png' < "$layout_single" ) > /dev/null 2> "$tmp_dir/pack_bad_spec.err"; then - echo "spratpack accepted unsupported output placeholder %s" >&2 + echo "spratpack accepted unsupported atlas placeholder %s" >&2 exit 1 fi grep -q "Invalid output pattern" "$tmp_dir/pack_bad_spec.err" @@ -76,9 +95,9 @@ grep -q "Invalid output pattern" "$tmp_dir/pack_bad_spec.err" # spratpack: reject missing %d for multi-atlas outputs. if ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas.png' < "$layout_multi" + "$spratpack_bin" --atlas 'atlas.png' < "$layout_multi" ) > /dev/null 2> "$tmp_dir/pack_no_placeholder.err"; then - echo "spratpack accepted output pattern without %d for multi-atlas layout" >&2 + echo "spratpack accepted atlas pattern without %d for multi-atlas layout" >&2 exit 1 fi grep -q "must include %d" "$tmp_dir/pack_no_placeholder.err" @@ -86,9 +105,9 @@ grep -q "must include %d" "$tmp_dir/pack_no_placeholder.err" # spratpack: reject trailing %. if ( cd "$tmp_dir" - "$spratpack_bin" --output 'atlas_%' < "$layout_single" + "$spratpack_bin" --atlas 'atlas_%' < "$layout_single" ) > /dev/null 2> "$tmp_dir/pack_trailing_percent.err"; then - echo "spratpack accepted trailing % in output pattern" >&2 + echo "spratpack accepted trailing % in atlas pattern" >&2 exit 1 fi grep -q "trailing '%'" "$tmp_dir/pack_trailing_percent.err" diff --git a/tests/profile_resolution_test.sh b/tests/profile_resolution_test.sh index a2eee8c..630cee1 100644 --- a/tests/profile_resolution_test.sh +++ b/tests/profile_resolution_test.sh @@ -100,10 +100,8 @@ assert_matches_explicit() { fi } -cleanup() { - rm -rf "$tmp_dir" -} -trap cleanup EXIT +exe_dir="$(dirname "$spratlayout_bin")" +exe_cfg="$exe_dir/spratprofiles.cfg" appdata_root="$tmp_dir/appdata" userprofile_root="$tmp_dir/userprofile" @@ -111,23 +109,38 @@ home_root="$tmp_dir/home" mkdir -p "$appdata_root" "$userprofile_root" "$home_root" appdata_cfg="$appdata_root/sprat/spratprofiles.cfg" -cwd_cfg="$tmp_dir/spratprofiles.cfg" export APPDATA="$(cygpath -m "$appdata_root")" export USERPROFILE="$(cygpath -m "$userprofile_root")" export HOME="$(cygpath -m "$home_root")" -# 1) User config (APPDATA) should be preferred when present. -write_cfg "$appdata_cfg" 3 -write_cfg "$cwd_cfg" 7 -assert_matches_explicit "$appdata_cfg" "appdata" +# Save and remove any existing exe-dir config so we can control it during tests. +exe_cfg_backed_up=0 +if [ -f "$exe_cfg" ]; then + cp "$exe_cfg" "${exe_cfg}.bak" + exe_cfg_backed_up=1 + rm -f "$exe_cfg" +fi + +cleanup() { + rm -rf "$tmp_dir" + if [ "$exe_cfg_backed_up" -eq 1 ]; then + mv "${exe_cfg}.bak" "$exe_cfg" + fi +} +trap cleanup EXIT -# 2) Current directory config should be used when user config is absent. -rm -f "$appdata_cfg" -write_cfg "$cwd_cfg" 7 -assert_matches_explicit "$cwd_cfg" "cwd" +# 1) Exe-dir config should be preferred over user config (APPDATA) when present. +write_cfg "$exe_cfg" 2 +write_cfg "$appdata_cfg" 3 +assert_matches_explicit "$exe_cfg" "exe_dir" -# 3) Precedence: APPDATA > current directory. -write_cfg "$cwd_cfg" 9 +# 2) User config (APPDATA) should be used when exe-dir config is absent. +rm -f "$exe_cfg" write_cfg "$appdata_cfg" 5 -assert_matches_explicit "$appdata_cfg" "precedence" +assert_matches_explicit "$appdata_cfg" "appdata" + +# 3) Precedence: exe-dir > APPDATA. +write_cfg "$appdata_cfg" 7 +write_cfg "$exe_cfg" 9 +assert_matches_explicit "$exe_cfg" "precedence" diff --git a/tests/recursive_dir_test.sh b/tests/recursive_dir_test.sh new file mode 100644 index 0000000..484269c --- /dev/null +++ b/tests/recursive_dir_test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: recursive_dir_test.sh " >&2 + exit 1 +fi + +spratlayout_bin="$1" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +# Path conversion for Windows +if [[ "$(uname)" == MINGW* || "$(uname)" == MSYS* ]]; then + tmp_dir_win="$(cygpath -m "$tmp_dir")" + fix_path() { + echo "${1/$tmp_dir/$tmp_dir_win}" + } +else + fix_path() { + echo "$1" + } +fi + +mkdir -p "$tmp_dir/frames/subdir" + +create_png() { + local path="$1" + local w="$2" + local h="$3" + # Create a minimal valid PNG using printf if ImageMagick is not available + # Or just use spratconvert if we had it, but we want to be independent if possible. + # Actually, the repo has a lot of PNGs. Let's just copy one if it exists. + local source_png + source_png=$(find . -name "*.png" | head -n 1) + if [ -n "$source_png" ]; then + cp "$source_png" "$path" + elif command -v magick >/dev/null; then + magick -size "${w}x${h}" xc:red "$path" + elif command -v convert >/dev/null; then + convert -size "${w}x${h}" xc:red "$path" + else + echo "Error: ImageMagick not found and no source PNG found in repo" >&2 + exit 1 + fi +} + +create_png "$tmp_dir/frames/top.png" 10 10 +create_png "$tmp_dir/frames/subdir/nested.png" 10 10 + +# Helper to extract paths from spratlayout output +extract_sprite_paths() { + grep "^sprite " | sed -E 's/^sprite ("[^"]*").*$/\1/' +} + +echo "Running recursive directory test..." +OUTPUT=$("$spratlayout_bin" "$(fix_path "$tmp_dir/frames")") + +if ! echo "$OUTPUT" | grep -q "nested.png"; then + echo "FAILED: Did not find nested.png in subdirectory" + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi + +if ! echo "$OUTPUT" | grep -q "top.png"; then + echo "FAILED: Did not find top.png in top-level directory" + echo "Output was:" + echo "$OUTPUT" + exit 1 +fi + +echo "Recursive directory test passed!" diff --git a/tests/sort_behavior_test.sh b/tests/sort_behavior_test.sh index 2d740c7..3c43831 100755 --- a/tests/sort_behavior_test.sh +++ b/tests/sort_behavior_test.sh @@ -45,7 +45,7 @@ create_png "$tmp_dir/frames/a.png" 10 10 create_png "$tmp_dir/frames/c.png" 20 20 create_png "$tmp_dir/frames/d.png" 30 30 -# Natural sorting tests +# Natural sorting tests (used with --sort name) create_png "$tmp_dir/frames/walk_2.png" 10 10 create_png "$tmp_dir/frames/walk_10.png" 10 10 create_png "$tmp_dir/frames/walk 2.png" 10 10 @@ -60,24 +60,19 @@ extract_sprite_paths() { grep "^sprite " | sed -E 's/^sprite ("[^"]*").*$/\1/' } -# Test 1: Default behavior for directory (should be naturally sorted by name) -echo "Test 1: Default behavior (natural name sort)" -"$spratlayout_bin" "$(fix_path "$tmp_dir/frames")" --mode fast | extract_sprite_paths | sed "s|$(fix_path "$tmp_dir/frames/")||g" > "$tmp_dir/out_default.txt" - -cat > "$tmp_dir/expected_subset.txt" < "$tmp_dir/out_default.txt" +# In fast mode sprites are height-sorted: b(40) > d(30) > c(20) > a(10) +cat > "$tmp_dir/expected_default.txt" < "$tmp_dir/out_walk_only.txt" - -if ! diff -u "$tmp_dir/expected_subset.txt" "$tmp_dir/out_walk_only.txt"; then - echo "FAILED: Default behavior should be natural name sort" +if ! diff -u "$tmp_dir/expected_default.txt" "$tmp_dir/out_default.txt"; then + echo "FAILED: Default behavior should allow optimization (height sort in FAST mode), not sort by name" exit 1 fi @@ -90,7 +85,7 @@ $(fix_path "$tmp_dir/frames/b.png") $(fix_path "$tmp_dir/frames/d.png") $(fix_path "$tmp_dir/frames/a.png") EOF -"$spratlayout_bin" "$(fix_path "$list_file")" --mode fast --sort none | extract_sprite_paths | sed "s|$(fix_path "$tmp_dir/frames/")||g" > "$tmp_dir/out_none.txt" +"$spratlayout_bin" "$(fix_path "$list_file")" --mode fast --sort none | extract_sprite_paths | sed -e "s|$(fix_path "$tmp_dir/frames/")||g" -e "s|frames/||g" > "$tmp_dir/out_none.txt" # Height order: b (40), d (30), c (20), a (10) cat > "$tmp_dir/expected_none.txt" < "$list_file_default" < "$tmp_dir/out_list_default.txt" +"$spratlayout_bin" "$(fix_path "$list_file_default")" --mode fast | extract_sprite_paths | sed -e "s|$(fix_path "$tmp_dir/frames/")||g" -e "s|frames/||g" > "$tmp_dir/out_list_default.txt" # Height order: b (40), d (30), c (20), a (10) cat > "$tmp_dir/expected_list_default.txt" < "$tmp_dir/expected_list_default.txt" < "$list_file_name" < "$tmp_dir/out_name.txt" +"$spratlayout_bin" "$(fix_path "$list_file_name")" --mode fast --sort name | extract_sprite_paths | sed -e "s|$(fix_path "$tmp_dir/frames/")||g" -e "s|frames/||g" > "$tmp_dir/out_name.txt" cat > "$tmp_dir/expected_name.txt" < "$tmp_dir/out_walk.txt" + +cat > "$tmp_dir/expected_walk.txt" <" >&2 + exit 1 +fi + +spratlayout_bin="$1" + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +if command -v magick >/dev/null; then + create_image_cmd="magick" +elif command -v convert >/dev/null; then + create_image_cmd="convert" +else + echo "Error: ImageMagick not found" >&2 + exit 1 +fi + +mkdir -p "$tmp_dir/frames" +"$create_image_cmd" -size 8x8 xc:red "$tmp_dir/frames/a.png" +"$create_image_cmd" -size 8x8 xc:green "$tmp_dir/frames/b.png" +"$create_image_cmd" -size 8x8 xc:blue "$tmp_dir/frames/c.png" + +cat > "$tmp_dir/frames/.spratlayoutignore" <<'EOF' +# Persistently exclude this sprite from directory scans +exclude "b.png" +EOF + +dir_layout="$tmp_dir/dir_layout.txt" +"$spratlayout_bin" "$tmp_dir/frames" > "$dir_layout" + +if grep -q 'sprite "b\.png"' "$dir_layout"; then + echo "FAILED: directory scan should honor .spratlayoutignore" >&2 + cat "$dir_layout" >&2 + exit 1 +fi + +if ! grep -q 'sprite "a\.png"' "$dir_layout" || ! grep -q 'sprite "c\.png"' "$dir_layout"; then + echo "FAILED: directory scan omitted non-excluded sprites" >&2 + cat "$dir_layout" >&2 + exit 1 +fi + +cat > "$tmp_dir/frames.txt" <<'EOF' +root "frames" +exclude "c.png" +a.png +b.png +c.png +EOF + +list_layout="$tmp_dir/list_layout.txt" +"$spratlayout_bin" "$tmp_dir/frames.txt" > "$list_layout" + +if grep -q 'sprite "frames/c\.png"' "$list_layout"; then + echo "FAILED: list input should honor exclude directive" >&2 + cat "$list_layout" >&2 + exit 1 +fi + +if ! grep -q 'sprite "frames/a\.png"' "$list_layout" || ! grep -q 'sprite "frames/b\.png"' "$list_layout"; then + echo "FAILED: list input omitted non-excluded sprites" >&2 + cat "$list_layout" >&2 + exit 1 +fi + +echo "Spratlayout exclude test passed!" diff --git a/tests/unity_test.sh b/tests/unity_test.sh new file mode 100644 index 0000000..a722ff8 --- /dev/null +++ b/tests/unity_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: unity_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 100,200 +scale 1 +sprite "player.png" 10,20 30,40 5,5 5,5 +LAYOUT + +markers_file="$tmp_dir/markers.txt" +cat > "$markers_file" <<'MARKERS' +path "player.png" +- marker "pivot" point 20,30 +MARKERS + +custom_transform="$tmp_dir/test.jsonnet" +cat > "$custom_transform" <<'CUSTOM' +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; +local sprite_line(s) = + "y=" + s.y + " unity_y=" + s.unity_y + + " pxn=" + lib.format_double(s.pivot_x_norm) + + " pyn=" + lib.format_double(s.pivot_y_norm) + + " pynr=" + lib.format_double(s.pivot_y_norm_raw) + + " nh=" + s.name_hash_decimal + + " nhh=" + s.name_hash_hex; +{ + name: "test", + extension: "", + content: std.join("\n", [sprite_line(s) for s in sprat.sprites]) + "\n", +} +CUSTOM + +"$convert_bin" --transform "$custom_transform" --markers "$markers_file" < "$layout_file" > "$tmp_dir/out.txt" + +grep -q "y=20" "$tmp_dir/out.txt" +grep -q "unity_y=140" "$tmp_dir/out.txt" +grep -q "pxn=0.5" "$tmp_dir/out.txt" +grep -q "pyn=0.4" "$tmp_dir/out.txt" +grep -q "pynr=0.6" "$tmp_dir/out.txt" +grep -q "nh=" "$tmp_dir/out.txt" +grep -q "nhh=" "$tmp_dir/out.txt" diff --git a/transforms/aseprite.jsonnet b/transforms/aseprite.jsonnet new file mode 100644 index 0000000..bb68b1c --- /dev/null +++ b/transforms/aseprite.jsonnet @@ -0,0 +1,45 @@ +// aseprite.jsonnet – Aseprite JSON Array sprite sheet format. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_obj(s) = { + filename: s.name, + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + duration: 100, +}; + +// Build frameTags from animations: each animation may be non-contiguous, so split into runs. +local frame_tag(anim) = + local runs = lib.consecutive_runs(anim.frame_indices); + [ + { name: anim.name, from: run.from, to: run.to, direction: "forward" } + for run in runs + ]; + +local frame_tags = std.flatMap(frame_tag, sprat.animations); + +local result = { + frames: [frame_obj(s) for s in sprat.sprites], + meta: { + app: "https://www.aseprite.org/", + version: "1.3", + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + frameTags: frame_tags, + layers: [], + slices: [], + }, +}; + +{ + name: "Aseprite", + description: "Aseprite JSON Array sprite sheet format (frameTags populated when animations are present)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/css.jsonnet b/transforms/css.jsonnet new file mode 100644 index 0000000..679fd82 --- /dev/null +++ b/transforms/css.jsonnet @@ -0,0 +1,34 @@ +// css.jsonnet – CSS classes for web sprite rendering. +local sprat = std.extVar("sprat"); + +local sprite_css(s) = + '.sprite-' + s.name_css + ' {\n' + + (if s.atlas_path != "" then ' background-image: url(\'' + s.atlas_path + '\');\n' else "") + + ' background-position: -' + s.x + 'px -' + s.y + 'px;\n' + + ' width: ' + s.w + 'px;\n' + + ' height: ' + s.h + 'px;\n' + + ' /* source: ' + s.path + ' */\n' + + ' /* name: ' + s.name + ' */\n' + + ' /* atlas_index: ' + s.atlas_index + ' */\n' + + (if s.rotated then + ' transform: rotate(-90deg) translate(-100%, 0);\n transform-origin: top left;\n' + else "") + + '}\n'; + +local header = + ':root {\n' + + ' --atlas-width: ' + sprat.atlas_width + 'px;\n' + + ' --atlas-height: ' + sprat.atlas_height + 'px;\n' + + ' --atlas-scale: ' + sprat.scale + ';\n' + + '}\n\n' + + '.sprat-sprite {\n' + + ' background-repeat: no-repeat;\n' + + ' display: inline-block;\n' + + '}\n'; + +{ + name: "CSS", + description: "CSS classes for web sprite rendering", + extension: ".css", + content: header + std.join("\n", [sprite_css(s) for s in sprat.sprites]), +} diff --git a/transforms/csv.jsonnet b/transforms/csv.jsonnet new file mode 100644 index 0000000..78ad047 --- /dev/null +++ b/transforms/csv.jsonnet @@ -0,0 +1,74 @@ +// csv.jsonnet – CSV rows for spreadsheets and data tools. +local sprat = std.extVar("sprat"); + +local csv_escape(s) = + local needs_quotes = std.length( + [c for c in std.stringChars(s) if c == '"' || c == ',' || c == '\n' || c == '\r'] + ) > 0; + if needs_quotes then + '"' + std.strReplace(s, '"', '""') + '"' + else + s; + +local marker_vertices_csv(verts) = + std.join("|", ["" + v.x + "," + v.y for v in verts]); + +local marker_json(m) = + '{"name":' + std.manifestJsonEx(m.name, "") + ',"type":"' + m.type + '"' + + ',"x":' + m.x + ',"y":' + m.y + + (if m.type == "circle" then ',"radius":' + m.radius else "") + + (if m.type == "rectangle" then ',"w":' + m.w + ',"h":' + m.h else "") + + (if m.type == "polygon" then + ',"vertices":[' + std.join(",", ['{"x":' + v.x + ',"y":' + v.y + '}' for v in m.vertices]) + ']' + else "") + + "}"; + +local markers_json_array(markers) = + "[" + std.join(",", [marker_json(m) for m in markers]) + "]"; + +local header = "index,name,path,atlas_index,atlas_path,x,y,w,h,pivot_x,pivot_y,trim_left,trim_top,trim_right,trim_bottom,marker_count,markers_json,rotation\n"; + +local sprite_row(s) = + "" + s.index + "," + + csv_escape(s.name) + "," + + csv_escape(s.path) + "," + + s.atlas_index + "," + + csv_escape(s.atlas_path) + "," + + s.x + "," + s.y + "," + s.w + "," + s.h + "," + + s.pivot_x + "," + s.pivot_y + "," + + s.trim_left + "," + s.trim_top + "," + s.trim_right + "," + s.trim_bottom + "," + + std.length(s.markers) + "," + + markers_json_array(s.markers) + "," + + (if s.rotated then "90" else "0") + "\n"; + +local marker_row(m) = + "marker," + m.index + "," + + csv_escape(m.name) + "," + + m.type + "," + + m.x + "," + m.y + "," + m.radius + "," + m.w + "," + m.h + "," + + marker_vertices_csv(m.vertices) + "," + + m.sprite_index + "," + + csv_escape(m.sprite_name) + "," + + csv_escape(m.sprite_path) + "\n"; + +local anim_row(a) = + if a.is_alias then + "animation," + a.index + "," + csv_escape(a.name) + ",alias," + + csv_escape(a.alias_source) + + (if a.flip != "" then "," + a.flip else "") + "\n" + else + "animation," + a.index + "," + csv_escape(a.name) + "," + a.fps + "," + + std.join("|", ["" + idx for idx in a.frame_indices]) + + (if a.flip != "" then "," + a.flip else "") + "\n"; + +local body = + std.join("", [sprite_row(s) for s in sprat.sprites]) + + std.join("", [marker_row(m) for m in sprat.markers]) + + std.join("", [anim_row(a) for a in sprat.animations]); + +{ + name: "CSV", + description: "CSV rows for spreadsheets and data tools", + extension: ".csv", + content: header + body, +} diff --git a/transforms/godot.jsonnet b/transforms/godot.jsonnet new file mode 100644 index 0000000..4be4e0c --- /dev/null +++ b/transforms/godot.jsonnet @@ -0,0 +1,39 @@ +// godot.jsonnet – Godot-compatible JSON sprite sheet. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_obj(s) = { + name: s.name, + region: { x: s.x, y: s.y, w: s.w, h: s.h }, + margin: { left: s.trim_left, top: s.trim_top, right: s.trim_right, bottom: s.trim_bottom }, + source_size: { w: s.source_w, h: s.source_h }, + rotated: s.rotated, + pivot_offset: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +// Godot animations use from/to indices (first and last frame index). +local anim_obj(a) = { + name: a.name, + from: if std.length(a.frame_indices) > 0 then a.frame_indices[0] else 0, + to: if std.length(a.frame_indices) > 0 then a.frame_indices[std.length(a.frame_indices) - 1] else 0, + speed: a.fps, + loop: true, +}; + +local result = + { + image: sprat.atlas_path, + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: sprat.scale, + frames: [frame_obj(s) for s in sprat.sprites], + } + + (if sprat.has_animations then { + animations: [anim_obj(a) for a in sprat.animations], + } else {}); + +{ + name: "Godot", + description: "Godot-compatible JSON sprite sheet (load at runtime with AtlasTexture/SpriteFrames)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/json.jsonnet b/transforms/json.jsonnet new file mode 100644 index 0000000..e69a482 --- /dev/null +++ b/transforms/json.jsonnet @@ -0,0 +1,40 @@ +// json.jsonnet – Generic JSON metadata format. +local sprat = std.extVar("sprat"); + +local sprite_obj(s) = { + name: s.name, + path: s.path, + atlas_index: s.atlas_index, + rect: { x: s.x, y: s.y, w: s.w, h: s.h }, + pivot: { x: s.pivot_x, y: s.pivot_y }, + trim: { left: s.trim_left, top: s.trim_top, right: s.trim_right, bottom: s.trim_bottom }, + markers: s.markers, + rotation: if s.rotated then 90 else 0, +}; + +local anim_obj(a) = + if a.is_alias then + { name: a.name, alias: a.alias_source } + + (if a.flip != "" then { flip: a.flip } else {}) + else + { name: a.name, fps: a.fps, sprite_indexes: a.frame_indices, sprite_names: [f.name for f in a.frames] } + + (if a.flip != "" then { flip: a.flip } else {}); + +local atlas_obj(at) = { width: at.width, height: at.height, path: at.path }; + +local result = { + multipack: sprat.multipack, + scale: sprat.scale, + extrude: sprat.extrude, + atlases: [atlas_obj(at) for at in sprat.atlases], + sprites: [sprite_obj(s) for s in sprat.sprites], +} + (if sprat.has_animations then { + animations: [anim_obj(a) for a in sprat.animations], +} else {}); + +{ + name: "JSON", + description: "JSON metadata for scripting and runtime loading", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/libgdx.jsonnet b/transforms/libgdx.jsonnet new file mode 100644 index 0000000..f03cda3 --- /dev/null +++ b/transforms/libgdx.jsonnet @@ -0,0 +1,26 @@ +// libgdx.jsonnet – LibGDX TextureAtlas format (.atlas). +local sprat = std.extVar("sprat"); + +local sprite_entry(s) = + s.name + "\n" + + " rotate: " + s.rotated + "\n" + + " xy: " + s.x + ", " + s.y + "\n" + + " size: " + s.w + ", " + s.h + "\n" + + " orig: " + s.source_w + ", " + s.source_h + "\n" + + " offset: " + s.trim_left + ", " + s.trim_bottom + "\n" + + " index: -1\n"; + +local atlas_block(at) = + at.path + "\n" + + "size: " + at.width + "," + at.height + "\n" + + "format: RGBA8888\n" + + "filter: Nearest,Nearest\n" + + "repeat: none\n\n" + + std.join("", [sprite_entry(s) for s in at.sprites]); + +{ + name: "LibGDX", + description: "LibGDX TextureAtlas format (.atlas); animation data is not part of this format", + extension: ".atlas", + content: std.join("", [atlas_block(at) for at in sprat.atlases]), +} diff --git a/transforms/phaser-anims.jsonnet b/transforms/phaser-anims.jsonnet new file mode 100644 index 0000000..68ec4aa --- /dev/null +++ b/transforms/phaser-anims.jsonnet @@ -0,0 +1,22 @@ +// phaser-anims.jsonnet – Phaser 3 animation manager JSON. +local sprat = std.extVar("sprat"); + +local frame_ref(f) = { key: sprat.atlas_stem, frame: f.name }; + +local anim_obj(a) = { + key: a.name, + frameRate: a.fps, + repeat: -1, + frames: [frame_ref(f) for f in a.frames], +}; + +local result = { + anims: [anim_obj(a) for a in sprat.animations], +}; + +{ + name: "Phaser Animations", + description: "Phaser 3 animation manager JSON (load separately via this.anims.fromJSON(); requires --atlas so frame keys resolve to the correct texture)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/phaser-array.jsonnet b/transforms/phaser-array.jsonnet new file mode 100644 index 0000000..72d98cf --- /dev/null +++ b/transforms/phaser-array.jsonnet @@ -0,0 +1,29 @@ +// phaser-array.jsonnet – Phaser 3 JSON Array atlas format. +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + filename: s.name, + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +local result = { + frames: [frame_obj(s) for s in sprat.sprites], + meta: { + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Phaser JSON Array", + description: "Phaser 3 atlas format (JSON Array, compatible with TexturePacker JSON Array output)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/phaser-hash.jsonnet b/transforms/phaser-hash.jsonnet new file mode 100644 index 0000000..d23415c --- /dev/null +++ b/transforms/phaser-hash.jsonnet @@ -0,0 +1,34 @@ +// phaser-hash.jsonnet – Phaser 3 JSON Hash atlas format. +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm_raw }, +}; + +local frames_obj = std.foldl( + function(acc, s) acc { [s.name]: frame_obj(s) }, + sprat.sprites, + {} +); + +local result = { + frames: frames_obj, + meta: { + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Phaser JSON Hash", + description: "Phaser 3 atlas format (JSON Hash, compatible with TexturePacker JSON Hash output)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/plist.jsonnet b/transforms/plist.jsonnet new file mode 100644 index 0000000..2a65097 --- /dev/null +++ b/transforms/plist.jsonnet @@ -0,0 +1,68 @@ +// plist.jsonnet – Cocos2d-x TextureAtlas plist format (format 2). +local sprat = std.extVar("sprat"); + +local xml_escape(s) = + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace(s, "&", "&"), + "<", "<"), + ">", ">"), + '"', """), + "'", "'"); + +local sprite_entry(s) = + local content_w = s.content_w; + local content_h = s.content_h; + local source_w = s.source_w; + local source_h = s.source_h; + local cx = std.floor((s.trim_left - s.trim_right) / 2); + local cy = std.floor((s.trim_bottom - s.trim_top) / 2); + local plist_frame = "{" + s.x + "," + s.y + "},{" + s.w + "," + s.h + "}"; + local plist_offset = "{" + cx + "," + cy + "}"; + local plist_source_color_rect = "{" + s.trim_left + "," + s.trim_top + "},{" + content_w + "," + content_h + "}"; + local plist_source_size = "{" + source_w + "," + source_h + "}"; + '\t\t' + xml_escape(s.name) + '\n' + + '\t\t\n' + + '\t\t\tframe\n' + + '\t\t\t' + plist_frame + '\n' + + '\t\t\toffset\n' + + '\t\t\t' + plist_offset + '\n' + + '\t\t\trotated\n' + + '\t\t\t' + (if s.rotated then '' else '') + '\n' + + '\t\t\tsourceColorRect\n' + + '\t\t\t' + plist_source_color_rect + '\n' + + '\t\t\tsourceSize\n' + + '\t\t\t' + plist_source_size + '\n' + + '\t\t\n'; + +local plist_atlas_size = "{" + sprat.atlas_width + "," + sprat.atlas_height + "}"; + +{ + name: "plist", + description: "Cocos2d-x TextureAtlas plist format (format 2)", + extension: ".plist", + content: + '\n' + + '\n' + + '\n' + + '\n' + + '\tframes\n' + + '\t\n\n' + + std.join("", [sprite_entry(s) for s in sprat.sprites]) + + '\t\n' + + '\tmetadata\n' + + '\t\n' + + '\t\tformat\n' + + '\t\t2\n' + + '\t\trealTextureFileName\n' + + '\t\t' + xml_escape(sprat.atlas_path) + '\n' + + '\t\tsize\n' + + '\t\t' + plist_atlas_size + '\n' + + '\t\ttextureFileName\n' + + '\t\t' + xml_escape(sprat.atlas_path) + '\n' + + '\t\n' + + '\n' + + '\n', +} diff --git a/transforms/sprat.libsonnet b/transforms/sprat.libsonnet new file mode 100644 index 0000000..9a48001 --- /dev/null +++ b/transforms/sprat.libsonnet @@ -0,0 +1,33 @@ +// sprat.libsonnet – shared helpers for all sprat Jsonnet transforms. +{ + // Format a double like C's %.8g: up to 8 decimal places, no trailing zeros. + // Jsonnet v0.20.0 has a known bug with %g format; we use %f + trim instead. + format_double(v):: + local s = std.format("%.8f", v); + local rtrim(str) = + if std.length(str) == 0 then "0" + else if str[std.length(str) - 1] == "0" then rtrim(std.substr(str, 0, std.length(str) - 1)) + else if str[std.length(str) - 1] == "." then std.substr(str, 0, std.length(str) - 1) + else str; + rtrim(s), + + // Split an array of frame indices into contiguous runs. + // Returns [{from: N, to: M}, ...]. + consecutive_runs(indices):: + if std.length(indices) == 0 then [] + else + local fold_result = std.foldl( + function(acc, idx) + if acc.current_end == idx - 1 then + acc { current_end: idx } + else + acc { + runs: acc.runs + [{from: acc.current_start, to: acc.current_end}], + current_start: idx, + current_end: idx, + }, + indices[1:], + { runs: [], current_start: indices[0], current_end: indices[0] } + ); + fold_result.runs + [{from: fold_result.current_start, to: fold_result.current_end}], +} diff --git a/transforms/unity.anim.jsonnet b/transforms/unity.anim.jsonnet new file mode 100644 index 0000000..90dbabd --- /dev/null +++ b/transforms/unity.anim.jsonnet @@ -0,0 +1,71 @@ +// unity.anim.jsonnet – Unity AnimationClip (.anim) per-animation files. +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local frame_entry(frame, i, fps) = + " - time: " + lib.format_double(i / fps) + "\n" + + " value: {fileID: " + frame.name_hash_decimal + + ", guid: " + sprat.output_stem_hash_hex + "0000000000000000, type: 3}\n"; + +local render_clip(anim) = + local eff_fps = if anim.fps > 0 then anim.fps else 8; + "%YAML 1.1\n%TAG !u! tag:unity3d.com,2011:\n" + + "--- !u!74 &740000" + anim.index + "\n" + + "AnimationClip:\n" + + " m_ObjectHideFlags: 0\n" + + " m_CorrespondingSourceObject: {fileID: 0}\n" + + " m_PrefabInstance: {fileID: 0}\n" + + " m_PrefabAsset: {fileID: 0}\n" + + " m_Name: " + anim.name + "\n" + + " serializedVersion: 6\n" + + " m_Legacy: 0\n" + + " m_Compressed: 0\n" + + " m_UseHighQualityCurve: 1\n" + + " m_RotationCurves: []\n" + + " m_CompressedRotationCurves: []\n" + + " m_EulerCurves: []\n" + + " m_PositionCurves: []\n" + + " m_ScaleCurves: []\n" + + " m_FloatCurves: []\n" + + " m_PPtrCurves:\n" + + " - curve:\n" + + std.join("", [frame_entry(anim.frames[i], i, eff_fps) for i in std.range(0, std.length(anim.frames) - 1)]) + + " attribute: m_Sprite\n" + + " path:\n" + + " classID: 212\n" + + " script: {fileID: 0}\n" + + " m_AnimationClipSettings:\n" + + " serializedVersion: 2\n" + + " m_AdditiveReferencePoseClip: {fileID: 0}\n" + + " m_AdditiveReferencePoseTime: 0\n" + + " m_StartTime: 0\n" + + " m_StopTime: " + lib.format_double(anim.duration) + "\n" + + " m_OrientationOffsetY: 0\n" + + " m_Level: 0\n" + + " m_CycleOffset: 0\n" + + " m_HasAdditiveReferencePose: 0\n" + + " m_LoopTime: 1\n" + + " m_LoopBlend: 0\n" + + " m_LoopBlendOrientation: 0\n" + + " m_LoopBlendPositionY: 0\n" + + " m_LoopBlendPositionXZ: 0\n" + + " m_KeepOriginalOrientation: 0\n" + + " m_KeepOriginalPositionY: 1\n" + + " m_KeepOriginalPositionXZ: 0\n" + + " m_HeightFromFeet: 0\n" + + " m_Mirror: 0\n" + + " m_EditorCurves: []\n" + + " m_EulerEditorCurves: []\n" + + " m_HasGenericRootTransform: 0\n" + + " m_HasMotionFloatCurves: 0\n" + + " m_Events: []\n"; + +{ + name: "Unity AnimationClip", + description: "Unity AnimationClip (.anim) sprite animation; GUIDs match the unity.meta transform output; use --output-dir to write one .anim file per animation", + extension: ".anim", + files: [ + { filename: anim.name + ".anim", content: render_clip(anim) } + for anim in sprat.animations + ], +} diff --git a/transforms/unity.json.jsonnet b/transforms/unity.json.jsonnet new file mode 100644 index 0000000..51ea091 --- /dev/null +++ b/transforms/unity.json.jsonnet @@ -0,0 +1,36 @@ +// unity.json.jsonnet – Unity-compatible JSON sprite sheet (TexturePacker JSON Hash format). +local sprat = std.extVar("sprat"); + +local frame_obj(s) = { + frame: { x: s.x, y: s.y, w: s.w, h: s.h }, + rotated: s.rotated, + trimmed: s.has_trim, + spriteSourceSize: { x: s.trim_left, y: s.trim_top, w: s.content_w, h: s.content_h }, + sourceSize: { w: s.source_w, h: s.source_h }, + pivot: { x: s.pivot_x_norm, y: s.pivot_y_norm }, +}; + +local frames_obj = std.foldl( + function(acc, s) acc { [s.name]: frame_obj(s) }, + sprat.sprites, + {} +); + +local result = { + frames: frames_obj, + meta: { + app: "https://github.com/pedroac/sprat-cli", + version: "1.0", + image: sprat.atlas_path, + format: "RGBA8888", + size: { w: sprat.atlas_width, h: sprat.atlas_height }, + scale: "" + sprat.scale, + }, +}; + +{ + name: "Unity JSON", + description: "Unity-compatible JSON sprite sheet (TexturePacker JSON Hash format with normalized pivots)", + extension: ".json", + content: std.manifestJsonEx(result, " ") + "\n", +} diff --git a/transforms/unity.meta.jsonnet b/transforms/unity.meta.jsonnet new file mode 100644 index 0000000..59ebf00 --- /dev/null +++ b/transforms/unity.meta.jsonnet @@ -0,0 +1,114 @@ +// unity.meta.jsonnet – Unity .meta file spriteSheet section (YAML). +local sprat = std.extVar("sprat"); +local lib = import "sprat.libsonnet"; + +local id_entry(s) = + " - first:\n" + + " 213: " + s.name_hash_decimal + "\n" + + " second: " + s.name + "\n"; + +local sprite_rect_entry(s) = + " - serializedVersion: 2\n" + + " name: " + s.name + "\n" + + " rect:\n" + + " serializedVersion: 2\n" + + " x: " + s.x + "\n" + + " y: " + s.unity_y + "\n" + + " width: " + s.content_w + "\n" + + " height: " + s.content_h + "\n" + + " alignment: 9\n" + + " pivot: {x: " + lib.format_double(s.pivot_x_norm) + ", y: " + lib.format_double(s.pivot_y_norm) + "}\n" + + " border: {x: 0, y: 0, z: 0, w: 0}\n" + + " outline: []\n" + + " physicsShape: []\n" + + " tessellationDetail: 0\n" + + " bones: []\n" + + " spriteID: " + s.name_hash_hex + "\n" + + " internalID: " + s.name_hash_decimal + "\n" + + " vertices: []\n" + + " indices:\n" + + " edges: []\n" + + " weights: []\n"; + +{ + name: "Unity Meta", + description: "Unity .meta file spriteSheet section (YAML)", + extension: ".meta", + content: + "fileFormatVersion: 2\n" + + "guid: " + sprat.output_stem_hash_hex + "0000000000000000\n" + + "TextureImporter:\n" + + " internalIDToNameTable:\n" + + std.join("", [id_entry(s) for s in sprat.sprites]) + + " externalObjects: {}\n" + + " serializedVersion: 13\n" + + " mipmaps:\n" + + " mipMapMode: 0\n" + + " enableMipMap: 0\n" + + " sRGBTexture: 1\n" + + " linearTexture: 0\n" + + " fadeOut: 0\n" + + " borderMipMap: 0\n" + + " mipMapsPreserveCoverage: 0\n" + + " alphaTestReferenceValue: 0.5\n" + + " mipMapFadeDistanceStart: 1\n" + + " mipMapFadeDistanceEnd: 3\n" + + " bumpmap:\n" + + " convertToNormalMap: 0\n" + + " externalNormalMap: 0\n" + + " heightScale: 0.25\n" + + " normalMapFilter: 0\n" + + " isReadable: 0\n" + + " streamingMipmaps: 0\n" + + " streamingMipmapsPriority: 0\n" + + " vTOnly: 0\n" + + " ignoreMasterTextureLimit: 0\n" + + " vtOnly: 0\n" + + " ignoreMipmapLimit: 0\n" + + " isDirectBinding: 0\n" + + " importAsync: 0\n" + + " filterMode: 0\n" + + " aniso: 1\n" + + " mipBias: 0\n" + + " textureType: 8\n" + + " textureShape: 1\n" + + " singleChannelComponent: 0\n" + + " flipbookRows: 1\n" + + " flipbookColumns: 1\n" + + " maxTextureSizeSet: 0\n" + + " compressionQuality: 50\n" + + " textureFormat: -1\n" + + " uncompressed: 0\n" + + " alphaUsage: 1\n" + + " alphaIsTransparency: 1\n" + + " spriteMode: 2\n" + + " spriteExtrude: 1\n" + + " spriteMeshType: 1\n" + + " alignment: 0\n" + + " spritePivot: {x: 0.5, y: 0.5}\n" + + " spritePixelsToUnits: 100\n" + + " spriteBorder: {x: 0, y: 0, z: 0, w: 0}\n" + + " spriteGenerateFallbackPhysicsShape: 1\n" + + " alphaTestReferenceValue: 0.5\n" + + " mipMapFadeDistanceStart: 1\n" + + " mipMapFadeDistanceEnd: 3\n" + + " spriteSheet:\n" + + " serializedVersion: 2\n" + + " sprites:\n" + + std.join("", [sprite_rect_entry(s) for s in sprat.sprites]) + + " outline: []\n" + + " physicsShape: []\n" + + " bones: []\n" + + " spriteID:\n" + + " internalID: 0\n" + + " vertices: []\n" + + " indices:\n" + + " edges: []\n" + + " weights: []\n" + + " spritePackingTag:\n" + + " pSDRemoveMatte: 0\n" + + " pSDShowRemoveMatteOption: 0\n" + + " userData:\n" + + " assetBundleName:\n" + + " assetBundleVariant:\n", +} diff --git a/transforms/xml.jsonnet b/transforms/xml.jsonnet new file mode 100644 index 0000000..e2440e8 --- /dev/null +++ b/transforms/xml.jsonnet @@ -0,0 +1,82 @@ +// xml.jsonnet – XML layout format for engine import pipelines. +local sprat = std.extVar("sprat"); + +local xml_escape(s) = + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace( + std.strReplace(s, "&", "&"), + "<", "<"), + ">", ">"), + '"', """), + "'", "'"); + +local marker_xml(m) = + if m.type == "point" then + '' + else if m.type == "circle" then + '' + else if m.type == "rectangle" then + '' + else if m.type == "polygon" then + '' + + std.join("|", ["" + v.x + "," + v.y for v in m.vertices]) + + '' + else ""; + +local sprite_xml(s) = + local marker_section = + if std.length(s.markers) > 0 then + "\n \n " + + std.join("\n ", [marker_xml(m) for m in s.markers]) + + "\n \n" + else ""; + '' + + marker_section + ""; + +local atlas_xml(at) = + ' \n' + + ' \n ' + + std.join("\n ", [sprite_xml(s) for s in at.sprites]) + + '\n \n '; + +local anim_xml(a) = + if a.is_alias then + ' " + else + ' '; + +local atlases_section = + ' \n' + + std.join("\n", [atlas_xml(at) for at in sprat.atlases]) + + '\n \n'; + +local animations_section = + if sprat.has_animations then + ' \n' + + std.join("\n", [anim_xml(a) for a in sprat.animations]) + + '\n \n' + else ""; + +{ + name: "XML", + description: "XML layout format for engine import pipelines", + extension: ".xml", + content: + '\n' + + '\n' + + atlases_section + + animations_section + + '\n', +}