scripts: rewrite gen-atlas with autotrimming support
Uses PIL/Pillow exclusively now, without imagemagick, since we don't really need 16bpc support. Overrides have been replaced with more flexible sprite configs. There is a conversion mechanism for migration, to be applied in the following commit.
This commit is contained in:
parent
1c029671ae
commit
960b38f8e5
5 changed files with 500 additions and 213 deletions
|
@ -1,6 +1,6 @@
|
|||
|
||||
atlases_dir = meson.current_source_dir()
|
||||
atlases_overrides_dir = join_paths(atlases_dir, 'overrides')
|
||||
atlases_config_dir = join_paths(atlases_dir, 'config')
|
||||
resources_gfx_dir = join_paths(resources_pkg_main, 'gfx')
|
||||
|
||||
# Args are applied in this order:
|
||||
|
@ -66,7 +66,7 @@ foreach profile : atlas_profiles
|
|||
run_target(atlas_target,
|
||||
command : [
|
||||
gen_atlas_command,
|
||||
atlases_overrides_dir,
|
||||
atlases_config_dir,
|
||||
join_paths(atlases_dir, atlas_name),
|
||||
resources_gfx_dir,
|
||||
atlas_common_args,
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import rectpack
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import re
|
||||
import functools
|
||||
import itertools
|
||||
import re
|
||||
import rectpack
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from pathlib import (
|
||||
Path,
|
||||
|
@ -18,12 +18,12 @@ from tempfile import (
|
|||
|
||||
from contextlib import (
|
||||
ExitStack,
|
||||
suppress,
|
||||
)
|
||||
|
||||
from concurrent.futures import (
|
||||
ThreadPoolExecutor,
|
||||
ProcessPoolExecutor,
|
||||
ThreadPoolExecutor,
|
||||
as_completed,
|
||||
)
|
||||
|
||||
from PIL import (
|
||||
|
@ -31,37 +31,17 @@ from PIL import (
|
|||
)
|
||||
|
||||
from taiseilib.common import (
|
||||
TaiseiError,
|
||||
run_main,
|
||||
update_text_file,
|
||||
TaiseiError,
|
||||
wait_for_futures,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# To the reasonable person wondering why this god-awful script invokes
|
||||
# an ImageMagick subprocess even though I've just imported PIL:
|
||||
#
|
||||
# It used to just use PIL to handle image composition. That is, until
|
||||
# I discovered the fact that PIL/Pillow does not support multichannel
|
||||
# 16-bits per pixel images.
|
||||
# https://github.com/python-pillow/Pillow/issues/2107#issuecomment-246007526
|
||||
#
|
||||
# Now PIL is only used to extract the image size, though I guess I might
|
||||
# as well just parse the output of `identify` at this point.
|
||||
#
|
||||
# Fuck software tbqh.
|
||||
#
|
||||
import taiseilib.atlas as atlas
|
||||
import taiseilib.keyval as keyval
|
||||
|
||||
|
||||
texture_formats = ['png', 'webp']
|
||||
|
||||
re_comment = re.compile(r'#.*')
|
||||
re_keyval = re.compile(r'([a-z0-9_-]+)\s*=\s*(.+)', re.I)
|
||||
|
||||
|
||||
class ConfigSyntaxError(Exception):
|
||||
pass
|
||||
texture_formats = [s[1:] for s in atlas.SPRITE_SUFFIXES]
|
||||
|
||||
|
||||
def ceildiv(x, y):
|
||||
|
@ -72,101 +52,34 @@ def align_size(size, alignment=4):
|
|||
return tuple(ceildiv(x, alignment) * alignment for x in size)
|
||||
|
||||
|
||||
def write_sprite_def(dst, texture, region, texture_dimensions, overrides=None):
|
||||
def write_sprite_def(dst, data):
|
||||
dst.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
text = (
|
||||
'# Autogenerated by the atlas packer, do not modify\n\n'
|
||||
f'texture = {texture}\n'
|
||||
'region_x = {region_x:.17f}\n'
|
||||
'region_y = {region_y:.17f}\n'
|
||||
'region_w = {region_w:.17f}\n'
|
||||
'region_h = {region_h:.17f}\n'
|
||||
).format(
|
||||
region_x=region[0] / texture_dimensions[0],
|
||||
region_y=region[1] / texture_dimensions[1],
|
||||
region_w=region[2] / texture_dimensions[0],
|
||||
region_h=region[3] / texture_dimensions[1],
|
||||
)
|
||||
|
||||
if overrides is not None:
|
||||
text += f'\n# -- Pasted from the override file --\n\n{overrides.strip()}\n'
|
||||
|
||||
text = '# Autogenerated by the atlas packer, do not modify\n\n'
|
||||
text += keyval.dump(data)
|
||||
update_text_file(dst, text)
|
||||
|
||||
|
||||
def write_texture_def(dst, texture, texture_fmt, global_overrides=None, local_overrides=None, alphamap_tex_fmt=None):
|
||||
dst.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
text = (
|
||||
'# Autogenerated by the atlas packer, do not modify\n\n'
|
||||
f'source = res/gfx/{texture}.{texture_fmt}\n'
|
||||
)
|
||||
data = {
|
||||
'source': f'res/gfx/{texture}.{texture_fmt}',
|
||||
}
|
||||
|
||||
if alphamap_tex_fmt is not None:
|
||||
text += f'alphamap = res/gfx/{texture}.alphamap.{alphamap_tex_fmt}\n'
|
||||
data['alphamap'] = f'res/gfx/{texture}.alphamap.{alphamap_tex_fmt}'
|
||||
|
||||
if global_overrides is not None:
|
||||
text += f'\n# -- Pasted from the global override file --\n\n{global_overrides.strip()}\n'
|
||||
data.update(global_overrides)
|
||||
|
||||
if local_overrides is not None:
|
||||
text += f'\n# -- Pasted from the local override file --\n\n{local_overrides.strip()}\n'
|
||||
data.update(local_overrides)
|
||||
|
||||
text = '# Autogenerated by the atlas packer, do not modify\n\n'
|
||||
text += keyval.dump(data)
|
||||
update_text_file(dst, text)
|
||||
|
||||
|
||||
def write_override_template(dst, size):
|
||||
dst.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
text = (
|
||||
'# This file was generated automatically, because this sprite doesn\'t have a custom override file.\n'
|
||||
'# To override this sprite\'s parameters, edit this file, remove the .renameme suffix and this comment, then commit it to the repository.\n\n'
|
||||
'# Modify these to change the virtual size of the sprite\n'
|
||||
f'w = {size[0]}\n'
|
||||
f'h = {size[1]}\n'
|
||||
)
|
||||
|
||||
update_text_file(dst.with_suffix(dst.suffix + '.renameme'), text)
|
||||
|
||||
|
||||
def parse_sprite_conf(path):
|
||||
conf = {}
|
||||
|
||||
with suppress(FileNotFoundError):
|
||||
with open(path, 'r') as f:
|
||||
for line in f.readlines():
|
||||
line = re_comment.sub('', line)
|
||||
line = line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
key, val = re_keyval.findall(line)[0]
|
||||
except IndexError:
|
||||
raise ConfigSyntaxError(line)
|
||||
|
||||
conf[key] = val
|
||||
|
||||
return conf
|
||||
|
||||
|
||||
def get_override_file_name(basename):
|
||||
if re.match(r'.*\.frame\d{4}$', basename):
|
||||
basename, _ = basename.rsplit('.', 1)
|
||||
basename += '.framegroup'
|
||||
|
||||
return f'{basename}.spr'
|
||||
|
||||
|
||||
def find_alphamap(basepath):
|
||||
for fmt in texture_formats:
|
||||
mpath = basepath.with_suffix(f'.alphamap.{fmt}')
|
||||
|
||||
if mpath.is_file():
|
||||
return mpath
|
||||
|
||||
|
||||
def get_bin_occupied_bounds(bin):
|
||||
xmin, xmax, ymin, ymax = 0xffffffffffffffff, 0, 0xffffffffffffffff, 0
|
||||
|
||||
|
@ -370,18 +283,22 @@ def pack_rects_brute_force(rects, bin_size, single_bin):
|
|||
|
||||
with ProcessPoolExecutor(initializer=bruteforcer_init, initargs=(rects, bin_size, single_bin)) as ex:
|
||||
variants = tuple(itertools.product(algos, sorts))
|
||||
results = zip(variants, ex.map(bruteforcer_pack, variants, chunksize=4))
|
||||
print('results', results)
|
||||
futures = {}
|
||||
|
||||
for (algo_class, (sort_func, sort_name)), result in results:
|
||||
verbose(f'Trying {algo_class} with {sort_name} sort...')
|
||||
for variant in variants:
|
||||
f = ex.submit(bruteforcer_pack, variant)
|
||||
futures[f] = variant
|
||||
|
||||
for future in as_completed(futures):
|
||||
algo_class, (sort_func, sort_name) = futures[future]
|
||||
result = future.result()
|
||||
|
||||
if best is None or result < best:
|
||||
verbose(f'\tResult: {result} (best yet)')
|
||||
verbose(' * {:>20} with {:20} | {!s}'.format(algo_class, sort_name + ' sort', result))
|
||||
best = result
|
||||
best_algos = (algo_class, sort_name)
|
||||
else:
|
||||
verbose(f'\tResult: {result}')
|
||||
verbose(' {:>20} with {:20} | {!s}'.format(algo_class, sort_name + ' sort', result))
|
||||
|
||||
verbose('*' * 64)
|
||||
verbose(f'WINNER: {best_algos[0]} with {best_algos[1]} sort')
|
||||
|
@ -390,44 +307,41 @@ def pack_rects_brute_force(rects, bin_size, single_bin):
|
|||
return best
|
||||
|
||||
|
||||
def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_formats[0], border=1, force_single=False, crop=True, leanify=True):
|
||||
overrides = Path(overrides).resolve()
|
||||
src = Path(src).resolve()
|
||||
dst = Path(dst).resolve()
|
||||
def sprite_sources_iter(srcroot):
|
||||
def imgfilter(path):
|
||||
return (
|
||||
path.is_file() and path.suffix.lower() in atlas.SPRITE_SUFFIXES and
|
||||
path.with_suffix('').suffix != '.alphamap'
|
||||
)
|
||||
|
||||
sprite_configs = {}
|
||||
for path in sorted(filter(imgfilter, srcroot.glob('**/*.*'))):
|
||||
yield path
|
||||
|
||||
def get_border(sprite, default_border=border):
|
||||
return max(default_border, int(sprite_configs[sprite].get('border', default_border)))
|
||||
|
||||
try:
|
||||
texture_local_overrides = (src / 'atlas.tex').read_text()
|
||||
except FileNotFoundError:
|
||||
texture_local_overrides = None
|
||||
def gen_atlas(config_dir, src, dst, binsize, atlasname, tex_format=texture_formats[0], border=1, force_single=False, crop=True, leanify=True):
|
||||
def get_sprite_border(sprite, default_border=border):
|
||||
return max(default_border, int(sprite.config.get('border', default_border)))
|
||||
|
||||
try:
|
||||
texture_global_overrides = (overrides / 'atlas.tex').read_text()
|
||||
except FileNotFoundError:
|
||||
texture_global_overrides = None
|
||||
texture_local_overrides = keyval.parse(src / 'atlas.tex', missing_ok=True)
|
||||
texture_global_overrides = keyval.parse(config_dir / 'atlas.tex', missing_ok=True)
|
||||
|
||||
total_images = 0
|
||||
packed_images = 0
|
||||
rects = []
|
||||
|
||||
def imgfilter(path):
|
||||
return (
|
||||
path.is_file() and path.suffix[1:].lower() in texture_formats and
|
||||
path.with_suffix('').suffix != '.alphamap'
|
||||
)
|
||||
sprites = {}
|
||||
|
||||
for path in sorted(filter(imgfilter, src.glob('**/*.*'))):
|
||||
img = Image.open(path)
|
||||
for path in sprite_sources_iter(src):
|
||||
sprite_name = path.relative_to(src).with_suffix('').as_posix()
|
||||
sprite_config_path = overrides / (get_override_file_name(sprite_name) + '.conf')
|
||||
sprite_configs[sprite_name] = parse_sprite_conf(sprite_config_path)
|
||||
border = get_border(sprite_name)
|
||||
rects.append((img.size[0]+border*2, img.size[1]+border*2, (path, sprite_name)))
|
||||
img.close()
|
||||
sprite_config_path = config_dir / atlas.get_sprite_config_file_name(sprite_name)
|
||||
sprite = atlas.Sprite.load(sprite_name, path, sprite_config_path)
|
||||
sprites[sprite.name] = sprite
|
||||
border = get_sprite_border(sprite)
|
||||
rects.append((
|
||||
sprite.image.size[0] + border*2,
|
||||
sprite.image.size[1] + border*2,
|
||||
sprite.name,
|
||||
))
|
||||
|
||||
pack_result = pack_rects_brute_force(rects=rects, bin_size=binsize, single_bin=force_single)
|
||||
|
||||
|
@ -449,12 +363,9 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
|
|||
|
||||
for i, bin in enumerate(pack_result.bins):
|
||||
textureid = f'atlas_{atlasname}_{i}'
|
||||
# dstfile = temp_dst / f'{textureid}.{tex_format}'
|
||||
# NOTE: we always save PNG first and convert with an external tool later if needed.
|
||||
dstfile = temp_dst / f'{textureid}.png'
|
||||
dstfile_alphamap = temp_dst / f'{textureid}.alphamap.png'
|
||||
print(dstfile)
|
||||
|
||||
dstfile_meta = temp_dst / f'{textureid}.tex'
|
||||
|
||||
if crop:
|
||||
|
@ -465,58 +376,31 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
|
|||
actual_size = (bin.width, bin.height)
|
||||
bin_offset = (0, 0)
|
||||
|
||||
base_composite_cmd = [
|
||||
'convert',
|
||||
'-verbose',
|
||||
'-size', f'{actual_size[0]}x{actual_size[1]}',
|
||||
]
|
||||
|
||||
composite_cmd = base_composite_cmd.copy() + ['xc:none']
|
||||
alphamap_composite_cmd = None
|
||||
bin_image = Image.new('RGBA', actual_size, (0, 0, 0, 0))
|
||||
bin_alphamap_image = None
|
||||
|
||||
for rect in bin:
|
||||
rect.move(rect.x + bin_offset[0], rect.y + bin_offset[1])
|
||||
sprite = sprites[rect.rid]
|
||||
border = get_sprite_border(sprite)
|
||||
paste_coord = (rect.x + border, rect.y + border)
|
||||
bin_image.paste(sprite.image, paste_coord)
|
||||
|
||||
img_path, name = rect.rid
|
||||
border = get_border(name)
|
||||
alphamap_path = find_alphamap(img_path)
|
||||
if sprite.alphamap_image is not None:
|
||||
if bin_alphamap_image is None:
|
||||
bin_alphamap_image = Image.new('L', actual_size, 255)
|
||||
bin_alphamap_image.paste(sprite.alphamap_image, paste_coord)
|
||||
|
||||
composite_cmd += [
|
||||
str(img_path), '-geometry', '{:+}{:+}'.format(rect.x + border, rect.y + border), '-composite'
|
||||
]
|
||||
sprite.texture_id = textureid
|
||||
sprite.texture_region = atlas.Geometry(
|
||||
rect.width - border * 2,
|
||||
rect.height - border * 2,
|
||||
rect.x + border,
|
||||
rect.y + border,
|
||||
) / actual_size
|
||||
|
||||
if alphamap_path:
|
||||
if alphamap_composite_cmd is None:
|
||||
alphamap_composite_cmd = base_composite_cmd.copy() + [
|
||||
'xc:white',
|
||||
'-colorspace', 'Gray',
|
||||
]
|
||||
|
||||
alphamap_composite_cmd += [
|
||||
str(alphamap_path),
|
||||
'-geometry', '{:+}{:+}'.format(rect.x + border, rect.y + border),
|
||||
'-channel', 'R',
|
||||
'-separate',
|
||||
'-composite',
|
||||
]
|
||||
|
||||
override_path = overrides / get_override_file_name(name)
|
||||
|
||||
if override_path.exists():
|
||||
override_contents = override_path.read_text()
|
||||
else:
|
||||
override_contents = None
|
||||
write_override_template(override_path, (rect.width - border * 2, rect.height - border * 2))
|
||||
|
||||
write_sprite_def(
|
||||
temp_dst / f'{name}.spr',
|
||||
textureid,
|
||||
(rect.x + border, rect.y + border, rect.width - border * 2, rect.height - border * 2),
|
||||
actual_size,
|
||||
overrides=override_contents
|
||||
)
|
||||
|
||||
composite_cmd += [str(dstfile)]
|
||||
write_sprite_def(temp_dst / f'{sprite.name}.spr', sprite.dump_spritedef_dict())
|
||||
sprite.unload()
|
||||
|
||||
write_texture_def(
|
||||
dstfile_meta,
|
||||
|
@ -524,11 +408,16 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
|
|||
tex_format,
|
||||
texture_global_overrides,
|
||||
texture_local_overrides,
|
||||
alphamap_tex_fmt=None if alphamap_composite_cmd is None else 'png'
|
||||
alphamap_tex_fmt=None if bin_alphamap_image is None else 'png'
|
||||
)
|
||||
|
||||
def process(composite_cmd, dstfile, tex_format=tex_format):
|
||||
subprocess.check_call(composite_cmd)
|
||||
def process(
|
||||
bin_image=bin_image,
|
||||
dstfile=dstfile,
|
||||
tex_format=tex_format
|
||||
):
|
||||
bin_image.save(dstfile)
|
||||
bin_image.close()
|
||||
|
||||
oldfmt = dstfile.suffix[1:].lower()
|
||||
|
||||
|
@ -556,11 +445,20 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
|
|||
if leanify:
|
||||
subprocess.check_call(['leanify', '-v', str(dstfile)])
|
||||
|
||||
futures.append(executor.submit(lambda: process(composite_cmd, dstfile)))
|
||||
futures.append(executor.submit(process))
|
||||
|
||||
if alphamap_composite_cmd is not None:
|
||||
alphamap_composite_cmd += [str(dstfile_alphamap)]
|
||||
futures.append(executor.submit(lambda: process(alphamap_composite_cmd, dstfile_alphamap, tex_format='png')))
|
||||
def process_alphamap(
|
||||
bin_alphamap_image=bin_alphamap_image,
|
||||
dstfile_alphamap=dstfile_alphamap,
|
||||
):
|
||||
bin_alphamap_image.save(dstfile_alphamap)
|
||||
bin_alphamap_image.close()
|
||||
|
||||
if leanify:
|
||||
subprocess.check_call(['leanify', '-v', str(dstfile_alphamap)])
|
||||
|
||||
if bin_alphamap_image is not None:
|
||||
futures.append(executor.submit(process_alphamap))
|
||||
|
||||
# Wait for subprocesses to complete.
|
||||
wait_for_futures(futures)
|
||||
|
@ -581,11 +479,32 @@ def gen_atlas(overrides, src, dst, binsize, atlasname, tex_format=texture_format
|
|||
shutil.copyfile(str(temp_dst / file), str(dst / file))
|
||||
|
||||
|
||||
def upgrade_configs(overrides_dir, src_dir, config_dir):
|
||||
for path in sprite_sources_iter(src_dir):
|
||||
sprite_name = path.relative_to(src_dir).with_suffix('').as_posix()
|
||||
config_fname = atlas.get_sprite_config_file_name(sprite_name)
|
||||
sprite_config_path = config_dir / config_fname
|
||||
sprite_override_path = overrides_dir / config_fname
|
||||
|
||||
if sprite_config_path.is_file():
|
||||
continue
|
||||
|
||||
sprite = atlas.Sprite.load(sprite_name, path, sprite_override_path, force_autotrim=False)
|
||||
sprite.upgrade_config()
|
||||
|
||||
if sprite.config:
|
||||
sprite_config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
update_text_file(sprite_config_path, keyval.dump(sprite.config))
|
||||
print('{}: {!r}'.format(sprite.name, sprite.config))
|
||||
|
||||
sprite.unload()
|
||||
|
||||
|
||||
def main(args):
|
||||
parser = argparse.ArgumentParser(description='Generate texture atlases and sprite definitions', prog=args[0])
|
||||
|
||||
parser.add_argument('overrides_dir',
|
||||
help='Directory containing per-sprite override files; templates are created automatically for missing files',
|
||||
parser.add_argument('config_dir',
|
||||
help='Directory containing configuration files for individual sprites',
|
||||
type=Path,
|
||||
)
|
||||
|
||||
|
@ -673,23 +592,40 @@ def main(args):
|
|||
default=texture_formats[0],
|
||||
)
|
||||
|
||||
parser.add_argument('--upgrade-configs',
|
||||
help='Generate sprite configs from old-style "overrides". In this mode, config_dir specifies the old "overrides" root, and new configs will be dumped into dest_dir. Existing configs in dest_dir will not be replaced',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.name is None:
|
||||
args.name = args.source_dir.name
|
||||
|
||||
gen_atlas(
|
||||
args.overrides_dir,
|
||||
args.source_dir,
|
||||
args.dest_dir,
|
||||
align_size((args.width, args.height)),
|
||||
tex_format=args.format,
|
||||
atlasname=args.name,
|
||||
border=args.border,
|
||||
force_single=args.single,
|
||||
crop=args.crop,
|
||||
leanify=args.leanify
|
||||
)
|
||||
args.config_dir = args.config_dir.resolve()
|
||||
args.source_dir = args.source_dir.resolve()
|
||||
args.dest_dir = args.dest_dir.resolve()
|
||||
|
||||
if args.upgrade_configs:
|
||||
upgrade_configs(
|
||||
args.config_dir,
|
||||
args.source_dir,
|
||||
args.dest_dir,
|
||||
)
|
||||
else:
|
||||
gen_atlas(
|
||||
args.config_dir,
|
||||
args.source_dir,
|
||||
args.dest_dir,
|
||||
align_size((args.width, args.height)),
|
||||
tex_format=args.format,
|
||||
atlasname=args.name,
|
||||
border=args.border,
|
||||
force_single=args.single,
|
||||
crop=args.crop,
|
||||
leanify=args.leanify,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
259
scripts/taiseilib/atlas.py
Normal file
259
scripts/taiseilib/atlas.py
Normal file
|
@ -0,0 +1,259 @@
|
|||
|
||||
import PIL
|
||||
import collections.abc
|
||||
import dataclasses
|
||||
import operator
|
||||
import re
|
||||
import typing
|
||||
|
||||
from . import keyval
|
||||
|
||||
|
||||
SPRITE_SUFFIXES = ('.png', '.webp')
|
||||
DEFAULT_SPRITE_SCALE = 0.5
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class Geometry:
|
||||
width: float
|
||||
height: float
|
||||
offset_x: float = 0.0
|
||||
offset_y: float = 0.0
|
||||
|
||||
regex: typing.ClassVar = re.compile(r'(\d+)x(\d+)([+-]\d+)([+-]\d+)')
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, string):
|
||||
return cls(*(int(s) for s in cls.regex.findall(string)[0]))
|
||||
|
||||
@classmethod
|
||||
def from_bbox(cls, bbox):
|
||||
left, upper, right, lower = bbox
|
||||
return cls(right - left, lower - upper, left, upper)
|
||||
|
||||
def _apply_op(self, other, op):
|
||||
if isinstance(other, collections.abc.Sequence) and len(other) == 2:
|
||||
mx = other[0]
|
||||
my = other[1]
|
||||
return Geometry(
|
||||
width=op(self.width, mx),
|
||||
height=op(self.height, my),
|
||||
offset_x=op(self.offset_x, mx),
|
||||
offset_y=op(self.offset_y, my),
|
||||
)
|
||||
|
||||
return Geometry(
|
||||
width=op(self.width, other),
|
||||
height=op(self.height, other),
|
||||
offset_x=op(self.offset_x, other),
|
||||
offset_y=op(self.offset_y, other),
|
||||
)
|
||||
|
||||
def __mul__(self, other): return self._apply_op(other, operator.mul)
|
||||
def __truediv__(self, other): return self._apply_op(other, operator.truediv)
|
||||
|
||||
def __str__(self):
|
||||
return '{}x{}{:+}{:+}'.format(self.width, self.height, self.offset_x, self.offset_y)
|
||||
|
||||
|
||||
@dataclasses.dataclass(slots=True, frozen=True)
|
||||
class Padding:
|
||||
top: float = 0.0
|
||||
bottom: float = 0.0
|
||||
left: float = 0.0
|
||||
right: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_offset(cls, x=0, y=0):
|
||||
return cls(top=y, bottom=-y, left=x, right=-x)
|
||||
|
||||
@classmethod
|
||||
def relative(cls, g_base, g_object):
|
||||
top = g_object.offset_y - g_base.offset_y
|
||||
bottom = (g_base.offset_y + g_base.height) - (g_object.offset_y + g_object.height)
|
||||
left = g_object.offset_x - g_base.offset_x
|
||||
right = (g_base.offset_x + g_base.width) - (g_object.offset_x + g_object.width)
|
||||
return cls(top, bottom, left, right)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, conf):
|
||||
return cls(
|
||||
top=float(conf.get('padding_top', 0.0)),
|
||||
bottom=float(conf.get('padding_bottom', 0.0)),
|
||||
left=float(conf.get('padding_left', 0.0)),
|
||||
right=float(conf.get('padding_right', 0.0))
|
||||
)
|
||||
|
||||
def _apply_op(self, other, op):
|
||||
if not isinstance(other, Padding):
|
||||
other = Padding(other, other, other, other)
|
||||
|
||||
return Padding(
|
||||
top=op(self.top, other.top),
|
||||
bottom=op(self.bottom, other.bottom),
|
||||
left=op(self.left, other.left),
|
||||
right=op(self.right, other.right)
|
||||
)
|
||||
|
||||
def __add__(self, other): return self._apply_op(other, operator.add)
|
||||
def __sub__(self, other): return self._apply_op(other, operator.sub)
|
||||
def __mul__(self, other): return self._apply_op(other, operator.mul)
|
||||
def __truediv__(self, other): return self._apply_op(other, operator.truediv)
|
||||
|
||||
def export(self, conf):
|
||||
for side in ('top', 'bottom', 'left', 'right'):
|
||||
key = 'padding_' + side
|
||||
val = getattr(self, side)
|
||||
|
||||
if val:
|
||||
conf[key] = '{:.17g}'.format(val)
|
||||
elif key in conf:
|
||||
del conf[key]
|
||||
|
||||
|
||||
def find_alphamap(basepath):
|
||||
for fmt in SPRITE_SUFFIXES:
|
||||
mpath = basepath.with_suffix(f'.alphamap{fmt}')
|
||||
|
||||
if mpath.is_file():
|
||||
return mpath
|
||||
|
||||
|
||||
def get_sprite_config_file_name(basename):
|
||||
if re.match(r'.*\.frame\d{4}$', basename):
|
||||
gname, _ = basename.rsplit('.', 1)
|
||||
gname += '.framegroup'
|
||||
return f'{gname}.spr'
|
||||
|
||||
return f'{basename}.spr'
|
||||
|
||||
|
||||
@dataclasses.dataclass(eq=False)
|
||||
class Sprite:
|
||||
name: str
|
||||
image: PIL.Image.Image
|
||||
alphamap_image: PIL.Image.Image = None
|
||||
padding: Padding = dataclasses.field(default_factory=Padding)
|
||||
config: dict[str, str] = dataclasses.field(default_factory=dict)
|
||||
texture_id: str = None
|
||||
texture_region: Geometry = None
|
||||
|
||||
def __post_init__(self):
|
||||
self._trimmed = False
|
||||
self._check_alphamap()
|
||||
|
||||
def _check_alphamap(self):
|
||||
if self.alphamap_image is not None:
|
||||
assert self.image.size == self.alphamap_image.size
|
||||
|
||||
@classmethod
|
||||
def load(cls, name, image_path, config_path, force_autotrim=None, default_autotrim=True):
|
||||
config = keyval.parse(config_path, missing_ok=True)
|
||||
image = PIL.Image.open(image_path)
|
||||
alphamap_path = find_alphamap(image_path)
|
||||
|
||||
if alphamap_path is not None:
|
||||
tmp = PIL.Image.open(alphamap_path)
|
||||
alphamap_image, *_ = tmp.split() # Extract red channel
|
||||
tmp.close()
|
||||
else:
|
||||
alphamap_image = None
|
||||
|
||||
sprite = cls(
|
||||
name=name,
|
||||
image=image,
|
||||
alphamap_image=alphamap_image,
|
||||
padding=Padding.from_config(config),
|
||||
config=config,
|
||||
)
|
||||
|
||||
if force_autotrim is not None:
|
||||
autotrim = force_autotrim
|
||||
else:
|
||||
autotrim = keyval.strbool(config.get('autotrim', default_autotrim))
|
||||
|
||||
if autotrim:
|
||||
sprite.trim()
|
||||
|
||||
return sprite
|
||||
|
||||
def upgrade_config(self):
|
||||
image_w = self.image.size[0]
|
||||
virtual_w = float(self.config.get('w', image_w))
|
||||
scale_factor = virtual_w / image_w
|
||||
|
||||
if scale_factor != DEFAULT_SPRITE_SCALE:
|
||||
self.config['scale'] = '{:.17g}'.format(scale_factor)
|
||||
|
||||
def trydel(k):
|
||||
try:
|
||||
del self.config[k]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for key in ('w', 'h'):
|
||||
trydel(key)
|
||||
|
||||
self.padding.export(self.config)
|
||||
|
||||
@property
|
||||
def scale(self):
|
||||
return float(self.config.get('scale', DEFAULT_SPRITE_SCALE))
|
||||
|
||||
def trim(self):
|
||||
if self._trimmed:
|
||||
return
|
||||
|
||||
bbox = self.image.getbbox()
|
||||
trimmed = self.image.crop(bbox)
|
||||
|
||||
g_base = Geometry(*self.image.size)
|
||||
g_trimmed = Geometry.from_bbox(bbox)
|
||||
self.padding = self.padding + Padding.relative(g_base, g_trimmed) * self.scale
|
||||
|
||||
self.image.close()
|
||||
self.image = trimmed
|
||||
|
||||
if self.alphamap_image is not None:
|
||||
alphamap_trimmed = self.alphamap_image.crop(bbox)
|
||||
self.alphamap_image.close()
|
||||
self.alphamap_image = alphamap_trimmed
|
||||
self._check_alphamap()
|
||||
|
||||
self._trimmed = True
|
||||
|
||||
def dump_spritedef_dict(self):
|
||||
d = {}
|
||||
|
||||
if self.texture_id is not None:
|
||||
d['texture'] = str(self.texture_id)
|
||||
|
||||
if self.texture_region is not None:
|
||||
d['region_x'] = '{:.17f}'.format(self.texture_region.offset_x)
|
||||
d['region_y'] = '{:.17f}'.format(self.texture_region.offset_y)
|
||||
d['region_w'] = '{:.17f}'.format(self.texture_region.width)
|
||||
d['region_h'] = '{:.17f}'.format(self.texture_region.height)
|
||||
|
||||
w, h = self.image.size
|
||||
scale = self.scale
|
||||
w *= scale
|
||||
h *= scale
|
||||
|
||||
d['w'] = '{:g}'.format(w)
|
||||
d['h'] = '{:g}'.format(h)
|
||||
|
||||
self.padding.export(d)
|
||||
|
||||
return d
|
||||
|
||||
def unload(self):
|
||||
if self.image is not None:
|
||||
self.image.close()
|
||||
self.image = None
|
||||
|
||||
if self.alphamap_image is not None:
|
||||
self.alphamap_image.close()
|
||||
self.alphamap_image = None
|
||||
|
||||
def __del__(self):
|
||||
self.unload()
|
|
@ -136,3 +136,4 @@ def DirPathType(arg):
|
|||
raise ValueError('Not a directory: %s' % p)
|
||||
|
||||
return p
|
||||
|
||||
|
|
91
scripts/taiseilib/keyval.py
Normal file
91
scripts/taiseilib/keyval.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
|
||||
from .common import TaiseiError
|
||||
|
||||
KV_REGEX = re.compile(r'^\s*(.*?)\s*=\s*(.*?)\s*$')
|
||||
|
||||
|
||||
"""
|
||||
Very dumb configuration parser compatible with Taisei's kvparser.c
|
||||
"""
|
||||
|
||||
|
||||
class KeyvalSyntaxError(TaiseiError):
|
||||
pass
|
||||
|
||||
|
||||
def parse(fobj, filename='<unknown>', dest=None, missing_ok=False):
|
||||
if dest is None:
|
||||
dest = {}
|
||||
|
||||
if isinstance(fobj, (str, os.PathLike)):
|
||||
try:
|
||||
with fobj.open('r') as real_fobj:
|
||||
return parse(real_fobj, filename=fobj, dest=dest)
|
||||
except FileNotFoundError:
|
||||
if missing_ok:
|
||||
return dest
|
||||
raise
|
||||
|
||||
lnum = 0
|
||||
|
||||
for line in fobj.readlines():
|
||||
lnum += 1
|
||||
line = line.lstrip()
|
||||
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
|
||||
m = KV_REGEX.match(line)
|
||||
|
||||
if not m:
|
||||
raise KeyvalSyntaxError('{!s}:{}'.format(filename, lnum))
|
||||
|
||||
key, val = m.groups()
|
||||
dest[key] = val
|
||||
|
||||
return dest
|
||||
|
||||
|
||||
def dump(src):
|
||||
lines = []
|
||||
|
||||
for rkey, rval in src.items():
|
||||
key, val = str(rkey), str(rval)
|
||||
line = '{} = {}'.format(key, val)
|
||||
|
||||
ok = True
|
||||
m = KV_REGEX.match(line)
|
||||
if not m:
|
||||
ok = False
|
||||
else:
|
||||
nkey, nval = m.groups()
|
||||
if key != nkey or val != nval:
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
raise KeyvalSyntaxError('Pair {!r} can\'t be represented'.format((rkey, rval)))
|
||||
|
||||
lines.append(line)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def strbool(s):
|
||||
try:
|
||||
return bool(int(s))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
s = str(s).lower()
|
||||
|
||||
if s in ('on', 'yes', 'true'):
|
||||
return True
|
||||
|
||||
if s in ('off', 'no', 'false'):
|
||||
return False
|
||||
|
||||
raise TaiseiError('Invalid boolean value: {}'.format(s))
|
Loading…
Reference in a new issue