sheet-pack.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. # /// script
  2. # requires-python = ">=3.11"
  3. # dependencies = ["pillow>=10.0"]
  4. # ///
  5. """Pack a directory of tile/sprite PNGs into one spritesheet + a JSON atlas.
  6. Deterministic, name-sorted shelf packer for isometric tile/sprite sets. Reads
  7. every image directly under a directory (non-recursive), optionally trims
  8. transparent margins per-sprite, pads each cell, optionally rounds the sheet to
  9. power-of-two dimensions, and writes one PNG sheet plus one JSON atlas describing
  10. where each source frame landed. Pair with tile-validate.py (QA before packing)
  11. and engine-integration.md (importing the atlas into Godot/Unity/Phaser/Tiled).
  12. Usage: uv run sheet-pack.py <tiles-dir> [OPTIONS]
  13. Input: argv positional <tiles-dir>: a directory of *.png (and other Pillow-
  14. readable raster) tiles, read non-recursively, name-sorted (POSIX
  15. collation on the filename, ties broken by filename).
  16. Output: stdout = the two written paths (one per line), or the --json envelope
  17. (schema claude-mods.isometric-ops.sheet-pack/v1) when --json is set.
  18. The atlas JSON itself is written to disk, not printed.
  19. Stderr: progress (frame count, sheet size), warnings, errors.
  20. Exit: 0 ok, 2 usage, 3 not found, 4 validation (unreadable/empty/oversized
  21. input), 5 missing dependency (Pillow unavailable).
  22. Atlas schema (written to <out>.json, mirrors the TexturePacker/Phaser "hash"
  23. family so it drops into most 2D engines with minimal remapping):
  24. {
  25. "meta": {
  26. "schema": "claude-mods.isometric-ops.sheet-pack/v1",
  27. "image": "<sheet filename>",
  28. "size": {"w": <int>, "h": <int>},
  29. "padding": <int>,
  30. "trimmed": <bool>,
  31. "pot": <bool>,
  32. "scale": 1,
  33. "app": "isometric-ops/sheet-pack.py",
  34. "generated_at": "<ISO-8601 Z>"
  35. },
  36. "frames": {
  37. "<name>": {
  38. "frame": {"x": <int>, "y": <int>, "w": <int>, "h": <int>},
  39. "sourceSize": {"w": <int>, "h": <int>},
  40. "spriteSourceSize": {"x": <int>, "y": <int>, "w": <int>, "h": <int>},
  41. "sourceW": <int>,
  42. "sourceH": <int>,
  43. "trimmed": <bool>,
  44. "rotated": false
  45. },
  46. ...
  47. }
  48. }
  49. "frame" is the rectangle within the packed sheet (post-trim if --trim was
  50. used). "sourceSize" is the original untrimmed image dimensions. "spriteSourceSize"
  51. is where the trimmed frame sits inside that original canvas (x, y = offset of
  52. the trim box from the untrimmed top-left) — this is what lets an engine restore
  53. the original anchor/pivot after trimming (see engine-integration.md, "anchor/pivot
  54. mapping" under "importing sheet-pack.py atlases"). "sourceW"/"sourceH" are flat
  55. duplicates of "sourceSize.w"/"sourceSize.h" (same untrimmed dimensions), provided
  56. directly on the frame object for importers that don't want to descend into the
  57. nested "sourceSize" object — prefer "spriteSourceSize" when you need the trim
  58. *offset* (its x/y), since "sourceW"/"sourceH" alone cannot recover that. "name" is
  59. the source filename without its extension; keep names engine-safe (no spaces, one
  60. extension) — this script does not rewrite them.
  61. Examples:
  62. uv run sheet-pack.py tiles/ --out atlas
  63. uv run sheet-pack.py tiles/ --trim --padding 2 --pot --out dist/tileset
  64. uv run sheet-pack.py tiles/ --json | jq -r '.data.sheet'
  65. uv run sheet-pack.py tiles/ --max-width 2048 --out atlas --force
  66. """
  67. from __future__ import annotations
  68. import argparse
  69. import json
  70. import sys
  71. from datetime import datetime, timezone
  72. from pathlib import Path
  73. from typing import Any, NoReturn
  74. SCHEMA = "claude-mods.isometric-ops.sheet-pack/v1"
  75. EXIT_OK = 0
  76. EXIT_USAGE = 2
  77. EXIT_NOT_FOUND = 3
  78. EXIT_VALIDATION = 4
  79. EXIT_MISSING_DEP = 5
  80. # Pillow-readable raster extensions we'll consider "tiles" for packing.
  81. # (Vector .svg is deliberately excluded — rasterize first; see
  82. # svg-vector-generation.md for the export step.)
  83. RASTER_EXTS = {".png", ".bmp", ".tga", ".tif", ".tiff", ".webp"}
  84. def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
  85. """Print a structured error (stdout, --json only) + human line (stderr), then exit."""
  86. if json_mode:
  87. print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
  88. print(f"ERROR: {message}", file=sys.stderr)
  89. sys.exit(exit_code)
  90. def load_pillow(json_mode: bool):
  91. try:
  92. from PIL import Image # noqa: PLC0415
  93. except ImportError:
  94. err(
  95. json_mode,
  96. "MISSING_DEPENDENCY",
  97. "Pillow is required. This script declares it via PEP 723 inline "
  98. "metadata — run it with `uv run sheet-pack.py ...` (uv installs "
  99. "Pillow into an ephemeral env automatically). Do not invoke with "
  100. "a bare `python`/`python3` that lacks Pillow.",
  101. EXIT_MISSING_DEP,
  102. )
  103. return Image
  104. def discover_tiles(tiles_dir: Path) -> list[Path]:
  105. """Non-recursive, name-sorted (by stem) list of raster files directly in tiles_dir."""
  106. files = [
  107. p
  108. for p in tiles_dir.iterdir()
  109. if p.is_file() and p.suffix.lower() in RASTER_EXTS
  110. ]
  111. # Deterministic order: sort by filename stem (case-insensitive), then full
  112. # name as a tiebreaker so e.g. "Tile2" vs "tile2" is still stable.
  113. return sorted(files, key=lambda p: (p.stem.lower(), p.name))
  114. def bbox_alpha(img) -> tuple[int, int, int, int] | None:
  115. """Bounding box of non-fully-transparent pixels, or None if the image has no alpha."""
  116. if img.mode != "RGBA":
  117. return None
  118. alpha = img.split()[-1]
  119. return alpha.getbbox()
  120. def next_pot(n: int) -> int:
  121. """Smallest power of two >= n (minimum 1)."""
  122. if n <= 1:
  123. return 1
  124. return 1 << (n - 1).bit_length()
  125. def pack_shelves(
  126. frames: list[dict[str, Any]], padding: int, max_width: int
  127. ) -> tuple[int, int]:
  128. """Simple deterministic shelf (row) packer.
  129. Frames are placed left-to-right in name-sorted order, wrapping to a new
  130. shelf when the running row width would exceed max_width. Shelf height is
  131. the tallest frame placed on that shelf. This is intentionally NOT a
  132. bin-packing optimizer (no MaxRects/skyline) — isometric tile/sprite sets
  133. are near-uniform in size, so a shelf packer is simple, fully deterministic
  134. (stable atlas diffs across builds), and wastes negligible space for that
  135. shape of input. Mutates each frame dict in place, adding "x"/"y".
  136. Returns the (width, height) of the packed sheet before any POT rounding.
  137. """
  138. x = padding
  139. y = padding
  140. shelf_h = 0
  141. sheet_w = padding
  142. for f in frames:
  143. w, h = f["_pack_w"], f["_pack_h"]
  144. if x + w + padding > max_width and x > padding:
  145. # New shelf.
  146. x = padding
  147. y += shelf_h + padding
  148. shelf_h = 0
  149. f["x"] = x
  150. f["y"] = y
  151. x += w + padding
  152. shelf_h = max(shelf_h, h)
  153. sheet_w = max(sheet_w, x)
  154. sheet_h = y + shelf_h + padding
  155. return sheet_w, sheet_h
  156. def main() -> int:
  157. ap = argparse.ArgumentParser(
  158. prog="sheet-pack.py",
  159. description=(
  160. "Pack a directory of tile/sprite PNGs into one spritesheet PNG + "
  161. "a JSON atlas (frames keyed by filename stem)."
  162. ),
  163. epilog=(
  164. "Examples:\n"
  165. " uv run sheet-pack.py tiles/ --out atlas\n"
  166. " uv run sheet-pack.py tiles/ --trim --padding 2 --pot --out dist/tileset\n"
  167. " uv run sheet-pack.py tiles/ --json | jq -r '.data.sheet'\n"
  168. " uv run sheet-pack.py tiles/ --max-width 2048 --out atlas --force\n"
  169. ),
  170. formatter_class=argparse.RawDescriptionHelpFormatter,
  171. )
  172. ap.add_argument("tiles_dir", help="directory of tile/sprite images (non-recursive)")
  173. ap.add_argument(
  174. "--out",
  175. default="atlas",
  176. help="output basename, no extension (writes <out>.png + <out>.json); default 'atlas'",
  177. )
  178. ap.add_argument(
  179. "--trim",
  180. action="store_true",
  181. help="crop each sprite to its non-transparent bounding box before packing "
  182. "(RGBA sources only; opaque/no-alpha sources are packed uncropped)",
  183. )
  184. ap.add_argument(
  185. "--padding",
  186. type=int,
  187. default=1,
  188. metavar="N",
  189. help="pixels of empty space between packed frames (default 1)",
  190. )
  191. ap.add_argument(
  192. "--pot",
  193. action="store_true",
  194. help="round the final sheet width and height up to the next power of two",
  195. )
  196. ap.add_argument(
  197. "--max-width",
  198. type=int,
  199. default=4096,
  200. metavar="N",
  201. help="shelf-wrap width budget in px before rounding/POT (default 4096)",
  202. )
  203. ap.add_argument(
  204. "--force",
  205. action="store_true",
  206. help="overwrite <out>.png/<out>.json if they already exist",
  207. )
  208. ap.add_argument("--json", action="store_true", help="emit the JSON envelope on stdout")
  209. args = ap.parse_args()
  210. if args.padding < 0:
  211. err(args.json, "USAGE", "--padding must be >= 0", EXIT_USAGE)
  212. if args.max_width < 1:
  213. err(args.json, "USAGE", "--max-width must be >= 1", EXIT_USAGE)
  214. if not args.out or any(c in args.out for c in ('"', "\x00")):
  215. err(args.json, "USAGE", "--out must be a non-empty, safe basename", EXIT_USAGE)
  216. tiles_dir = Path(args.tiles_dir)
  217. if not tiles_dir.is_dir():
  218. err(args.json, "NOT_FOUND", f"not a directory: {tiles_dir}", EXIT_NOT_FOUND)
  219. out_base = Path(args.out).resolve()
  220. sheet_path = out_base.with_suffix(".png")
  221. atlas_path = out_base.with_suffix(".json")
  222. if not args.force:
  223. for p in (sheet_path, atlas_path):
  224. if p.exists():
  225. err(
  226. args.json,
  227. "VALIDATION",
  228. f"{p} already exists (pass --force to overwrite)",
  229. EXIT_VALIDATION,
  230. )
  231. Image = load_pillow(args.json)
  232. sources = discover_tiles(tiles_dir)
  233. if not sources:
  234. err(
  235. args.json,
  236. "VALIDATION",
  237. f"no raster tiles found directly in {tiles_dir} "
  238. f"(looked for: {', '.join(sorted(RASTER_EXTS))})",
  239. EXIT_VALIDATION,
  240. )
  241. print(f"packing {len(sources)} tile(s) from {tiles_dir}...", file=sys.stderr)
  242. frames: list[dict[str, Any]] = []
  243. opened: list[Any] = []
  244. try:
  245. for src in sources:
  246. try:
  247. img = Image.open(src)
  248. img.load()
  249. except Exception as exc: # noqa: BLE001 - surface as a validation error, not a crash
  250. err(
  251. args.json,
  252. "VALIDATION",
  253. f"unreadable image: {src} ({exc})",
  254. EXIT_VALIDATION,
  255. )
  256. if img.mode not in ("RGBA", "RGB", "P", "LA", "L"):
  257. img = img.convert("RGBA")
  258. if img.mode != "RGBA":
  259. img = img.convert("RGBA")
  260. opened.append(img)
  261. source_w, source_h = img.size
  262. trim_box = bbox_alpha(img) if args.trim else None
  263. trimmed = trim_box is not None and trim_box != (0, 0, source_w, source_h)
  264. if trim_box is None:
  265. trim_box = (0, 0, source_w, source_h)
  266. tx0, ty0, tx1, ty1 = trim_box
  267. frame_w, frame_h = max(1, tx1 - tx0), max(1, ty1 - ty0)
  268. frames.append(
  269. {
  270. "name": src.stem,
  271. "_img": img,
  272. "_crop": trim_box,
  273. "_pack_w": frame_w,
  274. "_pack_h": frame_h,
  275. "sourceSize": {"w": source_w, "h": source_h},
  276. "spriteSourceSize": {
  277. "x": tx0,
  278. "y": ty0,
  279. "w": frame_w,
  280. "h": frame_h,
  281. },
  282. "trimmed": trimmed,
  283. "rotated": False,
  284. }
  285. )
  286. # Guard against duplicate stems silently clobbering atlas entries
  287. # (e.g. "crate.png" and "crate.PNG" in the same directory).
  288. seen: dict[str, Path] = {}
  289. for f, src in zip(frames, sources):
  290. if f["name"] in seen:
  291. err(
  292. args.json,
  293. "VALIDATION",
  294. f"duplicate frame name '{f['name']}' from {seen[f['name']]} and {src} "
  295. "(filenames must be unique ignoring extension)",
  296. EXIT_VALIDATION,
  297. )
  298. seen[f["name"]] = src
  299. sheet_w, sheet_h = pack_shelves(frames, args.padding, args.max_width)
  300. if args.pot:
  301. sheet_w, sheet_h = next_pot(sheet_w), next_pot(sheet_h)
  302. if sheet_w > 16384 or sheet_h > 16384:
  303. err(
  304. args.json,
  305. "VALIDATION",
  306. f"packed sheet would be {sheet_w}x{sheet_h}px, over the 16384px safety "
  307. "ceiling (most GPUs cap texture dims there) — split the tile set or "
  308. "raise --max-width to pack fewer shelves is not the fix; reduce input "
  309. "count/size instead",
  310. EXIT_VALIDATION,
  311. )
  312. sheet = Image.new("RGBA", (sheet_w, sheet_h), (0, 0, 0, 0))
  313. for f in frames:
  314. crop = f["_img"].crop(f["_crop"])
  315. sheet.paste(crop, (f["x"], f["y"]), crop)
  316. sheet_path.parent.mkdir(parents=True, exist_ok=True)
  317. tmp_sheet = sheet_path.with_suffix(sheet_path.suffix + ".tmp")
  318. sheet.save(tmp_sheet, format="PNG")
  319. tmp_sheet.replace(sheet_path)
  320. finally:
  321. for img in opened:
  322. try:
  323. img.close()
  324. except Exception: # noqa: BLE001 - best-effort cleanup
  325. pass
  326. atlas_frames = {}
  327. for f in frames:
  328. atlas_frames[f["name"]] = {
  329. "frame": {"x": f["x"], "y": f["y"], "w": f["_pack_w"], "h": f["_pack_h"]},
  330. "sourceSize": f["sourceSize"],
  331. "spriteSourceSize": f["spriteSourceSize"],
  332. # Flat aliases of sourceSize.w/h so importers can read frame.sourceW/
  333. # frame.sourceH directly (see the schema docstring above for why
  334. # spriteSourceSize is still needed for trim-offset recovery).
  335. "sourceW": f["sourceSize"]["w"],
  336. "sourceH": f["sourceSize"]["h"],
  337. "trimmed": f["trimmed"],
  338. "rotated": f["rotated"],
  339. }
  340. atlas = {
  341. "meta": {
  342. "schema": SCHEMA,
  343. "image": sheet_path.name,
  344. "size": {"w": sheet_w, "h": sheet_h},
  345. "padding": args.padding,
  346. "trimmed": bool(args.trim),
  347. "pot": bool(args.pot),
  348. "scale": 1,
  349. "app": "isometric-ops/sheet-pack.py",
  350. "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
  351. },
  352. "frames": atlas_frames,
  353. }
  354. tmp_atlas = atlas_path.with_suffix(atlas_path.suffix + ".tmp")
  355. tmp_atlas.write_text(json.dumps(atlas, indent=2) + "\n", encoding="utf-8")
  356. tmp_atlas.replace(atlas_path)
  357. print(
  358. f"packed {len(frames)} frame(s) into {sheet_w}x{sheet_h} sheet "
  359. f"({'trimmed, ' if args.trim else ''}padding={args.padding}"
  360. f"{', pot' if args.pot else ''})",
  361. file=sys.stderr,
  362. )
  363. data = {
  364. "sheet": str(sheet_path),
  365. "atlas": str(atlas_path),
  366. "frame_count": len(frames),
  367. "size": {"w": sheet_w, "h": sheet_h},
  368. }
  369. if args.json:
  370. print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
  371. else:
  372. print(sheet_path)
  373. print(atlas_path)
  374. return EXIT_OK
  375. if __name__ == "__main__":
  376. sys.exit(main())