diff --git a/main.py b/main.py index 3882029..0a12752 100644 --- a/main.py +++ b/main.py @@ -1,21 +1,20 @@ -# Refactored by Vorono4ka - import time +from system.lib.config import config +from system.lib.main_menu import ( + check_auto_update, + check_files_updated, + menu, + refill_menu, +) +from system.localization import locale + try: from loguru import logger except ImportError: raise RuntimeError("Please, install loguru using pip") from system import clear -from system.lib import ( - config, - locale, - refill_menu, - menu, - check_auto_update, - check_files_updated, -) from system.lib.features.initialization import initialize @@ -50,4 +49,3 @@ def main(): main() except KeyboardInterrupt: logger.info("Exit.") - pass diff --git a/system/bytestream.py b/system/bytestream.py index 1b49c7a..ae9213b 100644 --- a/system/bytestream.py +++ b/system/bytestream.py @@ -1,5 +1,5 @@ import io -from typing import Literal, Optional +from typing import Literal class Reader(io.BytesIO): @@ -37,7 +37,7 @@ def read_twip(self) -> float: def read_string(self) -> str: length = self.read_uchar() - if length != 255: + if length != 0xFF: return self.read(length).decode() return "" @@ -68,9 +68,9 @@ def write_uint32(self, integer: int): def write_int32(self, integer: int): self.write_int(integer, 4, True) - def write_string(self, string: Optional["str"] = None): + def write_string(self, string: str | None = None): if string is None: - self.write_byte(255) + self.write_byte(0xFF) return encoded = string.encode() self.write_byte(len(encoded)) diff --git a/system/languages/en-EU.json b/system/languages/en-EU.json index 0fc6509..4ef8972 100644 --- a/system/languages/en-EU.json +++ b/system/languages/en-EU.json @@ -70,7 +70,6 @@ "resizing": "Resizing...", "split_pic": "Splitting picture...", "writing_pic": "Writing pixels...", - "header_done": "Header wrote!", "compressing_with": "Compressing texture with %s...", "compression_error": "Compression failed", "compression_done": "Compression done!", diff --git a/system/languages/ru-RU.json b/system/languages/ru-RU.json index afa55b6..e844355 100644 --- a/system/languages/ru-RU.json +++ b/system/languages/ru-RU.json @@ -70,7 +70,6 @@ "resizing": "Изменяем размер...", "split_pic": "Разделяем картинку...", "writing_pic": "Конвертируем пиксели...", - "header_done": "Заголовок записан!", "compressing_with": "Сохраняем с применением %s сжатия...", "compression_error": "Сжатие не удалось", "compression_done": "Сжатие прошло успешно!", diff --git a/system/languages/ua-UA.json b/system/languages/ua-UA.json index b10f1f3..47ba54b 100644 --- a/system/languages/ua-UA.json +++ b/system/languages/ua-UA.json @@ -70,7 +70,6 @@ "resizing": "змінюємо розмір...", "split_pic": "Розділюємо зоображення...", "writing_pic": "Записуємо пікселі...", - "header_done": "Написали Header!", "compressing_with": "Запаковуємо з %s...", "compression_error": "Запаковування не вдалося", "compression_done": "Запаковування виконане!", diff --git a/system/lib/__init__.py b/system/lib/__init__.py index 65fe1cf..97bbcc3 100644 --- a/system/lib/__init__.py +++ b/system/lib/__init__.py @@ -1,17 +1,7 @@ import sys -import time from loguru import logger -from system import clear -from system.lib.config import config -from system.lib.console import Console -from system.lib.features.directories import clear_directories -from system.lib.features.initialization import initialize -from system.lib.features.update.check import check_for_outdated, check_update, get_tags -from system.lib.menu import Menu, menu -from system.localization import locale - logger.remove() logger.add( "./logs/info/{time:YYYY-MM-DD}.log", @@ -28,183 +18,3 @@ level="ERROR", ) logger.add(sys.stdout, format="[{level}] {message}", level="INFO") - - -locale.load(config.language) - - -def check_auto_update(): - if config.auto_update and time.time() - config.last_update > 60 * 60 * 24 * 7: - check_update() - config.last_update = int(time.time()) - config.dump() - - -def check_files_updated(): - if config.has_update: - logger.opt(colors=True).info(f'{locale.update_done % ""}') - if Console.question(locale.done_qu): - latest_tag = get_tags("vorono4ka", "xcoder")[0] - latest_tag_name = latest_tag["name"][1:] - - config.has_update = False - config.version = latest_tag_name - config.last_update = int(time.time()) - config.dump() - else: - exit() - - -# noinspection PyUnresolvedReferences -@logger.catch() -def refill_menu(): - menu.categories.clear() - - sc_category = Menu.Category(0, locale.sc_label) - ktx_category = Menu.Category(1, locale.ktx_label) - csv_category = Menu.Category(2, locale.csv_label) - other = Menu.Category(10, locale.other_features_label) - - menu.add_category(sc_category) - menu.add_category(ktx_category) - menu.add_category(csv_category) - menu.add_category(other) - - try: - import sc_compression - - del sc_compression - except ImportError: - logger.warning(locale.install_to_unlock % "sc-compression") - else: - from system.lib.features.csv.compress import compress_csv - from system.lib.features.csv.decompress import decompress_csv - - try: - import PIL - - del PIL - except ImportError: - logger.warning(locale.install_to_unlock % "PILLOW") - else: - from system.lib.features.sc.decode import ( - decode_and_render_objects, - decode_textures_only, - ) - from system.lib.features.sc.encode import ( - collect_objects_and_encode, - encode_textures_only, - ) - - sc_category.add( - Menu.Item( - name=locale.decode_sc, - description=locale.decode_sc_description, - handler=decode_textures_only, - ) - ) - sc_category.add( - Menu.Item( - name=locale.encode_sc, - description=locale.encode_sc_description, - handler=encode_textures_only, - ) - ) - sc_category.add( - Menu.Item( - name=locale.decode_by_parts, - description=locale.decode_by_parts_description, - handler=decode_and_render_objects, - ) - ) - sc_category.add( - Menu.Item( - name=locale.encode_by_parts, - description=locale.encode_by_parts_description, - handler=collect_objects_and_encode, - ) - ) - sc_category.add( - Menu.Item( - name=locale.overwrite_by_parts, - description=locale.overwrite_by_parts_description, - handler=lambda: collect_objects_and_encode(True), - ) - ) - - from system.lib.features.ktx import ( - convert_ktx_textures_to_png, - convert_png_textures_to_ktx, - ) - from system.lib.pvr_tex_tool import can_use_pvr_tex_tool - - if can_use_pvr_tex_tool(): - ktx_category.add( - Menu.Item( - name=locale.ktx_from_png_label, - description=locale.ktx_from_png_description, - handler=convert_png_textures_to_ktx, - ) - ) - ktx_category.add( - Menu.Item( - name=locale.png_from_ktx_label, - description=locale.png_from_ktx_description, - handler=convert_ktx_textures_to_png, - ) - ) - - csv_category.add( - Menu.Item( - name=locale.decompress_csv, - description=locale.decompress_csv_description, - handler=decompress_csv, - ) - ) - csv_category.add( - Menu.Item( - name=locale.compress_csv, - description=locale.compress_csv_description, - handler=compress_csv, - ) - ) - - other.add( - Menu.Item( - name=locale.check_update, - description=locale.version % config.version, - handler=check_update, - ) - ) - other.add(Menu.Item(name=locale.check_for_outdated, handler=check_for_outdated)) - other.add( - Menu.Item( - name=locale.reinit, - description=locale.reinit_description, - handler=lambda: (initialize(), refill_menu()), - ) - ) - other.add( - Menu.Item( - name=locale.change_language, - description=locale.change_lang_description % config.language, - handler=lambda: (config.change_language(locale.change()), refill_menu()), - ) - ) - other.add( - Menu.Item( - name=locale.clear_directories, - description=locale.clean_dirs_description, - handler=lambda: clear_directories() - if Console.question(locale.clear_qu) - else -1, - ) - ) - other.add( - Menu.Item( - name=locale.toggle_update_auto_checking, - description=locale.enabled if config.auto_update else locale.disabled, - handler=lambda: (config.toggle_auto_update(), refill_menu()), - ) - ) - other.add(Menu.Item(name=locale.exit, handler=lambda: (clear(), exit()))) diff --git a/system/lib/config.py b/system/lib/config.py index 33fbf6b..eefee68 100644 --- a/system/lib/config.py +++ b/system/lib/config.py @@ -16,6 +16,7 @@ def __init__(self): "has_update", "last_update", "auto_update", + "should_render_movie_clips", ) self.initialized: bool = False @@ -24,6 +25,7 @@ def __init__(self): self.has_update: bool = False self.last_update: int = -1 self.auto_update: bool = False + self.should_render_movie_clips: bool = False self.load() diff --git a/system/lib/features/cut_sprites.py b/system/lib/features/cut_sprites.py index 2644ed7..82829e4 100644 --- a/system/lib/features/cut_sprites.py +++ b/system/lib/features/cut_sprites.py @@ -1,7 +1,12 @@ import os from pathlib import Path +from system.lib.config import config from system.lib.console import Console +from system.lib.matrices import Matrix2x3 +from system.lib.objects.renderable.renderable_factory import ( + create_renderable_from_plain, +) from system.lib.swf import SupercellSWF from system.localization import locale @@ -11,32 +16,24 @@ def render_objects(swf: SupercellSWF, output_folder: Path): os.makedirs(output_folder / "shapes", exist_ok=True) os.makedirs(output_folder / "movie_clips", exist_ok=True) - # TODO: Too slow, fix it - # movie_clips_skipped = 0 - # movie_clip_count = len(swf.movie_clips) - # for movie_clip_index in range(movie_clip_count): - # movie_clip = swf.movie_clips[movie_clip_index] - # - # rendered_movie_clip = movie_clip.render(swf) - # if sum(rendered_movie_clip.size) >= 2: - # clip_name = movie_clip.export_name or movie_clip.id - # rendered_movie_clip.save(f"{output_folder}/movie_clips/{clip_name}.png") - # else: - # # For debug: - # # logger.warning(f'MovieClip {movie_clip.id} cannot be rendered.') - # movie_clips_skipped += 1 - # - # Console.progress_bar( - # "Rendering movie clips (%d/%d). Skipped count: %d" - # % (movie_clip_index + 1, movie_clip_count, movie_clips_skipped), - # movie_clip_index, - # movie_clip_count, - # ) - - print() - shapes_count = len(swf.shapes) + swf.xcod_writer.write_uint16(shapes_count) + for shape_index in range(shapes_count): + shape = swf.shapes[shape_index] + + regions_count = len(shape.regions) + swf.xcod_writer.write_uint16(shape.id) + swf.xcod_writer.write_uint16(regions_count) + for region_index in range(regions_count): + region = shape.regions[region_index] + + swf.xcod_writer.write_ubyte(region.texture_index) + swf.xcod_writer.write_ubyte(region.get_point_count()) + + for i in range(region.get_point_count()): + swf.xcod_writer.write_uint16(int(region.get_u(i))) + swf.xcod_writer.write_uint16(int(region.get_v(i))) for shape_index in range(shapes_count): shape = swf.shapes[shape_index] @@ -47,30 +44,36 @@ def render_objects(swf: SupercellSWF, output_folder: Path): shapes_count, ) - rendered_shape = shape.render() + rendered_shape = create_renderable_from_plain(swf, shape).render(Matrix2x3()) rendered_shape.save(f"{output_folder}/shapes/{shape.id}.png") regions_count = len(shape.regions) for region_index in range(regions_count): region = shape.regions[region_index] - rendered_region = region.render(use_original_size=True) + rendered_region = region.get_image() rendered_region.save(f"{output_folder}/shape_{shape.id}_{region_index}.png") - for shape_index in range(shapes_count): - shape = swf.shapes[shape_index] + if config.should_render_movie_clips: + movie_clips_skipped = 0 + movie_clip_count = len(swf.movie_clips) + for movie_clip_index in range(movie_clip_count): + movie_clip = swf.movie_clips[movie_clip_index] - regions_count = len(shape.regions) - swf.xcod_writer.write_uint16(shape.id) - swf.xcod_writer.write_uint16(regions_count) - for region_index in range(regions_count): - region = shape.regions[region_index] - - swf.xcod_writer.write_ubyte(region.texture_index) - swf.xcod_writer.write_ubyte(region.get_points_count()) + rendered_movie_clip = create_renderable_from_plain(swf, movie_clip).render( + Matrix2x3() + ) + if sum(rendered_movie_clip.size) >= 2: + clip_name = movie_clip.export_name or movie_clip.id + rendered_movie_clip.save(f"{output_folder}/movie_clips/{clip_name}.png") + else: + # For debug: + # logger.warning(f'MovieClip {movie_clip.id} cannot be rendered.') + movie_clips_skipped += 1 - for i in range(region.get_points_count()): - swf.xcod_writer.write_uint16(int(region.get_u(i))) - swf.xcod_writer.write_uint16(int(region.get_v(i))) - swf.xcod_writer.write_ubyte(1 if region.is_mirrored else 0) - swf.xcod_writer.write_byte(region.rotation // 90) + Console.progress_bar( + "Rendering movie clips (%d/%d). Skipped count: %d" + % (movie_clip_index + 1, movie_clip_count, movie_clips_skipped), + movie_clip_index, + movie_clip_count, + ) diff --git a/system/lib/features/files.py b/system/lib/features/files.py index 4e0e7cf..661dd81 100644 --- a/system/lib/features/files.py +++ b/system/lib/features/files.py @@ -7,43 +7,24 @@ from system.localization import locale -def write_sc(output_filename: str | os.PathLike, buffer: bytes, use_lzham: bool): +def write_sc( + output_filename: str | os.PathLike, + buffer: bytes, + signature: Signatures, + version: int | None = None, +): with open(output_filename, "wb") as file_out: - logger.info(locale.header_done) + file_out.write(compress(buffer, signature, version)) - if use_lzham: - logger.info(locale.compressing_with % "LZHAM") - # Why is this here? It's included in the compression module - # file_out.write(struct.pack("<4sBI", b"SCLZ", 18, len(buffer))) - compressed = compress(buffer, Signatures.SCLZ) - - file_out.write(compressed) - else: - logger.info(locale.compressing_with % "LZMA") - compressed = compress(buffer, Signatures.SC, 3) - file_out.write(compressed) - logger.info(locale.compression_done) - print() - - -def open_sc(input_filename: str) -> tuple[bytes, bool]: - use_lzham = False +def open_sc(input_filename: str) -> tuple[bytes, Signatures]: with open(input_filename, "rb") as f: file_data = f.read() try: if b"START" in file_data: file_data = file_data[: file_data.index(b"START")] - decompressed_data, signature = decompress(file_data) - - if signature.name != Signatures.NONE: - logger.info(locale.detected_comp % signature.name.upper()) - - if signature == Signatures.SCLZ: - use_lzham = True + return decompress(file_data) except TypeError: logger.info(locale.decompression_error) exit(1) - - return decompressed_data, use_lzham diff --git a/system/lib/features/place_sprites.py b/system/lib/features/place_sprites.py index 2cf8171..753f1ac 100644 --- a/system/lib/features/place_sprites.py +++ b/system/lib/features/place_sprites.py @@ -1,12 +1,11 @@ import os from pathlib import Path -from typing import List -from PIL import Image, ImageDraw +from PIL import Image -from system.lib import Console -from system.lib.helper import get_sides, get_size -from system.lib.images import get_format_by_pixel_type +from system.lib.console import Console +from system.lib.images import create_filled_polygon_image, get_format_by_pixel_type +from system.lib.math.polygon import get_rect from system.lib.xcod import FileInfo from system.localization import locale @@ -15,7 +14,7 @@ def place_sprites( file_info: FileInfo, folder: Path, overwrite: bool = False -) -> List[Image.Image]: +) -> list[Image.Image]: files_to_overwrite = os.listdir(folder / ("overwrite" if overwrite else "")) texture_files = os.listdir(folder / "textures") @@ -40,59 +39,53 @@ def place_sprites( ) for region_index, region_info in enumerate(shape_info.regions): - texture_size = ( - sheets[region_info.texture_id].width, - sheets[region_info.texture_id].height, - ) + texture_width = sheets[region_info.texture_id].width + texture_height = sheets[region_info.texture_id].height filename = f"shape_{shape_info.id}_{region_index}.png" if filename not in files_to_overwrite: continue - img_mask = Image.new("L", texture_size, 0) - ImageDraw.Draw(img_mask).polygon(region_info.points, fill=MASK_COLOR) - bbox = img_mask.getbbox() - - if not bbox: - min_x = min(i[0] for i in region_info.points) - min_y = min(i[1] for i in region_info.points) - max_x = max(i[0] for i in region_info.points) - max_y = max(i[1] for i in region_info.points) + rect = get_rect(region_info.points) - if max_y - min_y != 0: - for _y in range(max_y - min_y): - img_mask.putpixel((max_x - 1, min_y + _y - 1), MASK_COLOR) + img_mask = create_filled_polygon_image( + "L", texture_width, texture_height, region_info.points, MASK_COLOR + ) - elif max_x - min_x != 0: - for _x in range(max_x - min_x): - img_mask.putpixel((min_x + _x - 1, max_y - 1), MASK_COLOR) + if rect.width == 0 or rect.height == 0: + if rect.height != 0: + for _y in range(int(rect.height)): + img_mask.putpixel( + (int(rect.right - 1), int(rect.top + _y - 1)), MASK_COLOR + ) + rect.right += 1 + elif rect.width != 0: + for _x in range(int(rect.width)): + img_mask.putpixel( + (int(rect.left + _x - 1), int(rect.bottom - 1)), MASK_COLOR + ) + rect.bottom += 1 else: - img_mask.putpixel((max_x - 1, max_y - 1), MASK_COLOR) - - left, top, right, bottom = get_sides(region_info.points) - if left == right: - right += 1 - if top == bottom: - bottom += 1 - - width, height = get_size(left, top, right, bottom) - left = int(left) - top = int(top) - - bbox = int(left), int(top), int(right), int(bottom) - - tmp_region = Image.open( + img_mask.putpixel( + (int(rect.right - 1), int(rect.bottom - 1)), MASK_COLOR + ) + rect.right += 1 + rect.bottom += 1 + + x = int(rect.left) + y = int(rect.top) + width = int(rect.width) + height = int(rect.height) + bbox = int(rect.left), int(rect.top), int(rect.right), int(rect.bottom) + + region_image = Image.open( f'{folder}{"/overwrite" if overwrite else ""}/{filename}' ).convert("RGBA") - if region_info.is_mirrored: - tmp_region = tmp_region.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - tmp_region = tmp_region.rotate(region_info.rotation, expand=True) - tmp_region = tmp_region.resize((width, height), Image.Resampling.LANCZOS) sheets[region_info.texture_id].paste( - Image.new("RGBA", (width, height)), (left, top), img_mask.crop(bbox) + Image.new("RGBA", (width, height)), (x, y), img_mask.crop(bbox) ) - sheets[region_info.texture_id].paste(tmp_region, (left, top), tmp_region) + sheets[region_info.texture_id].paste(region_image, (x, y), region_image) print() return sheets diff --git a/system/lib/features/sc/__init__.py b/system/lib/features/sc/__init__.py index 94b0f6f..efb8969 100644 --- a/system/lib/features/sc/__init__.py +++ b/system/lib/features/sc/__init__.py @@ -1,6 +1,5 @@ import struct from pathlib import Path -from typing import List from loguru import logger from PIL import Image @@ -16,7 +15,7 @@ def compile_sc( output_folder: Path, file_info: FileInfo, - sheets: List[Image.Image], + sheets: list[Image.Image], ): sc = Writer() @@ -56,4 +55,12 @@ def compile_sc( sc.write(bytes(5)) - write_sc(output_folder / f"{file_info.name}.sc", sc.getvalue(), file_info.use_lzham) + logger.info(locale.compressing_with % file_info.signature.name.upper()) + write_sc( + output_folder / f"{file_info.name}.sc", + sc.getvalue(), + file_info.signature, + file_info.signature_version, + ) + logger.info(locale.compression_done) + print() diff --git a/system/lib/features/sc/decode.py b/system/lib/features/sc/decode.py index b9f0273..7e5bcd5 100644 --- a/system/lib/features/sc/decode.py +++ b/system/lib/features/sc/decode.py @@ -3,7 +3,9 @@ from pathlib import Path from loguru import logger +from sc_compression import Signatures +from system.bytestream import Writer from system.lib.features.cut_sprites import render_objects from system.lib.swf import SupercellSWF from system.localization import locale @@ -25,7 +27,7 @@ def decode_textures_only(): swf = SupercellSWF() base_name = os.path.basename(file).rsplit(".", 1)[0] try: - texture_loaded, use_lzham = swf.load(f"{input_folder / file}") + texture_loaded, signature = swf.load(f"{input_folder / file}") if not texture_loaded: logger.error(locale.not_found % f"{base_name}_tex.sc") continue @@ -37,7 +39,7 @@ def decode_textures_only(): ) _save_meta_file( - swf, objects_output_folder, base_name.rstrip("_"), use_lzham + swf, objects_output_folder, base_name.rstrip("_"), signature ) _save_textures(swf, objects_output_folder, base_name) except Exception as exception: @@ -66,7 +68,7 @@ def decode_and_render_objects(): base_name = os.path.basename(file).rsplit(".", 1)[0] swf = SupercellSWF() - texture_loaded, use_lzham = swf.load(input_folder / file) + texture_loaded, signature = swf.load(input_folder / file) if not texture_loaded: logger.error(locale.not_found % f"{base_name}_tex.sc") continue @@ -79,7 +81,7 @@ def decode_and_render_objects(): _save_textures(swf, objects_output_folder / "textures", base_name) render_objects(swf, objects_output_folder) - _save_meta_file(swf, objects_output_folder, base_name, use_lzham) + _save_meta_file(swf, objects_output_folder, base_name, signature) except Exception as exception: logger.exception( locale.error @@ -113,10 +115,16 @@ def _save_textures(swf: SupercellSWF, textures_output: Path, base_name: str) -> def _save_meta_file( - swf: SupercellSWF, objects_output_folder: Path, base_name: str, use_lzham: bool + swf: SupercellSWF, + objects_output_folder: Path, + base_name: str, + signature: Signatures, ) -> None: - with open(objects_output_folder / f"{base_name}.xcod", "wb") as xcod_file: - xcod_file.write(b"XCOD") - xcod_file.write(bool.to_bytes(use_lzham, 1, "big")) - xcod_file.write(int.to_bytes(len(swf.textures), 1, "big")) - xcod_file.write(swf.xcod_writer.getvalue()) + writer = Writer() + writer.write(b"XCOD") + writer.write_string(signature.name) + writer.write_ubyte(len(swf.textures)) + writer.write(swf.xcod_writer.getvalue()) + + with open(objects_output_folder / f"{base_name}.xcod", "wb") as file: + file.write(writer.getvalue()) diff --git a/system/lib/helper.py b/system/lib/helper.py deleted file mode 100644 index 4c87e03..0000000 --- a/system/lib/helper.py +++ /dev/null @@ -1,45 +0,0 @@ -from typing import List, Tuple, TypeAlias - -from system.lib.objects.point import Point - -PointType: TypeAlias = Tuple[float, float] | Tuple[int, int] | Point - - -def get_size(left: float, top: float, right: float, bottom: float) -> Tuple[int, int]: - """Returns width and height of given rect. - - :param left: left side of polygon - :param top: top side of polygon - :param right: right side of polygon - :param bottom: bottom side of polygon - :return: width, height - """ - return int(right - left), int(bottom - top) - - -def get_sides( - points: List[Tuple[float, float]] | List[Tuple[int, int]] | List[Point] -) -> Tuple[float, float, float, float]: - """Calculates and returns rect sides. - - :param points: polygon points - :return: left, top, right, bottom - """ - - if len(points) > 0: - point: PointType = points[0] - if isinstance(point, Point): - left = min(point.x for point in points) # type: ignore - top = min(point.y for point in points) # type: ignore - right = max(point.x for point in points) # type: ignore - bottom = max(point.y for point in points) # type: ignore - elif isinstance(point, tuple): - left = min(x for x, _ in points) # type: ignore - top = min(y for _, y in points) # type: ignore - right = max(x for x, _ in points) # type: ignore - bottom = max(y for _, y in points) # type: ignore - else: - raise TypeError("Unknown point type.") - - return left, top, right, bottom - raise ValueError("Empty points list.") diff --git a/system/lib/images.py b/system/lib/images.py index 242f0e6..697fcab 100644 --- a/system/lib/images.py +++ b/system/lib/images.py @@ -1,10 +1,12 @@ import math import PIL.PyAccess -from PIL import Image +from PIL import Image, ImageDraw from system.bytestream import Reader, Writer from system.lib.console import Console +from system.lib.math.point import Point +from system.lib.matrices import Matrix2x3 from system.lib.pixel_utils import ( get_channel_count_by_pixel_type, get_read_function, @@ -17,20 +19,19 @@ def load_image_from_buffer(img: Image.Image) -> None: width, height = img.size - # noinspection PyTypeChecker img_loaded: PIL.PyAccess.PyAccess = img.load() # type: ignore with open("pixel_buffer", "rb") as pixel_buffer: - channels_count = int.from_bytes(pixel_buffer.read(1), "little") + channel_count = int.from_bytes(pixel_buffer.read(1), "little") for y in range(height): for x in range(width): - img_loaded[x, y] = tuple(pixel_buffer.read(channels_count)) + img_loaded[x, y] = tuple(pixel_buffer.read(channel_count)) def join_image(img: Image.Image) -> None: with open("pixel_buffer", "rb") as pixel_buffer: - channels_count = int.from_bytes(pixel_buffer.read(1), "little") + channel_count = int.from_bytes(pixel_buffer.read(1), "little") width, height = img.size # noinspection PyTypeChecker @@ -52,14 +53,14 @@ def join_image(img: Image.Image) -> None: break loaded_img[pixel_x, pixel_y] = tuple( - pixel_buffer.read(channels_count) + pixel_buffer.read(channel_count) ) Console.progress_bar(locale.join_pic, y_chunk, y_chunks_count + 1) -def split_image(img: Image.Image): - def add_pixel(pixel: tuple): +def split_image(img: Image.Image) -> None: + def add_pixel(pixel: tuple) -> None: loaded_image[pixel_index % width, int(pixel_index / width)] = pixel width, height = img.size @@ -122,7 +123,6 @@ def load_texture(reader: Reader, pixel_type: int, img: Image.Image) -> None: with open("pixel_buffer", "wb") as pixel_buffer: pixel_buffer.write(channel_count.to_bytes(1, "little")) - print() width, height = img.size point = -1 @@ -138,14 +138,14 @@ def load_texture(reader: Reader, pixel_type: int, img: Image.Image) -> None: point = curr -def save_texture(writer: Writer, img: Image.Image, pixel_type: int): +def save_texture(writer: Writer, image: Image.Image, pixel_type: int) -> None: write_pixel = get_write_function(pixel_type) if write_pixel is None: raise Exception(locale.unknown_pixel_type % pixel_type) - width, height = img.size + width, height = image.size - pixels = img.getdata() + pixels = image.getdata() point = -1 for y in range(height): for x in range(width): @@ -157,7 +157,9 @@ def save_texture(writer: Writer, img: Image.Image, pixel_type: int): point = curr -def transform_image(image, scale_x, scale_y, angle, x, y): +def transform_image( + image: Image.Image, scale_x: float, scale_y: float, angle: float, x: float, y: float +) -> Image.Image: im_orig = image image = Image.new("RGBA", im_orig.size, (255, 255, 255, 255)) image.paste(im_orig) @@ -198,13 +200,13 @@ def transform_image(image, scale_x, scale_y, angle, x, y): return image.transform( (translated_w, translated_h), - Image.AFFINE, + Image.Transform.AFFINE, (a, b, c, d, e, f), - resample=Image.BILINEAR, + resample=Image.Resampling.BILINEAR, ) -def translate_image(image, x, y): +def translate_image(image, x: float, y: float) -> Image.Image: w, h = image.size translated_w = int(math.ceil(w + math.fabs(x))) @@ -216,23 +218,29 @@ def translate_image(image, x, y): return image.transform( (translated_w, translated_h), - Image.AFFINE, + Image.Transform.AFFINE, (1, 0, -x, 0, 1, -y), - resample=Image.BILINEAR, + resample=Image.Resampling.BILINEAR, ) -def transform_image_by_matrix(image, matrix: list or tuple): - scale_x, rotation_x, x = matrix[:3] - rotation_y, scale_y, y = matrix[3:] - return transform_image( - image, scale_x, scale_y, math.atan2(rotation_x, rotation_y), x, y +def transform_image_by_matrix(image: Image.Image, matrix: Matrix2x3): + new_width = abs(int(matrix.apply_x(image.width, image.height))) + new_height = abs(int(matrix.apply_y(image.width, image.height))) + + return image.transform( + (new_width, new_height), + Image.Transform.AFFINE, + (matrix.a, matrix.b, matrix.x, matrix.c, matrix.d, matrix.y), + resample=Image.Resampling.BILINEAR, ) -if __name__ == "__main__": - transform_image_by_matrix( - Image.open("../../test_0.png"), - [1.0458984375, 0.0, -127.65, 0.0, 1.0458984375, -700.0], - ).show() - input() +def create_filled_polygon_image( + mode: str, width: int, height: int, polygon: list[Point], color: int +) -> Image.Image: + mask_image = Image.new(mode, (width, height), 0) + drawable_image = ImageDraw.Draw(mask_image) + drawable_image.polygon([point.as_tuple() for point in polygon], fill=color) + + return mask_image diff --git a/system/lib/main_menu.py b/system/lib/main_menu.py new file mode 100644 index 0000000..62987ad --- /dev/null +++ b/system/lib/main_menu.py @@ -0,0 +1,191 @@ +import time + +from loguru import logger + +from system import clear +from system.lib.config import config +from system.lib.console import Console +from system.lib.features.directories import clear_directories +from system.lib.features.initialization import initialize +from system.lib.features.update.check import check_for_outdated, check_update, get_tags +from system.lib.menu import Menu +from system.localization import locale + +menu = Menu() + + +def check_auto_update(): + if config.auto_update and time.time() - config.last_update > 60 * 60 * 24 * 7: + check_update() + config.last_update = int(time.time()) + config.dump() + + +def check_files_updated(): + if config.has_update: + logger.opt(colors=True).info(f'{locale.update_done % ""}') + if Console.question(locale.done_qu): + latest_tag = get_tags("vorono4ka", "xcoder")[0] + latest_tag_name = latest_tag["name"][1:] + + config.has_update = False + config.version = latest_tag_name + config.last_update = int(time.time()) + config.dump() + else: + exit() + + +# noinspection PyUnresolvedReferences +@logger.catch() +def refill_menu(): + menu.categories.clear() + + sc_category = Menu.Category(0, locale.sc_label) + ktx_category = Menu.Category(1, locale.ktx_label) + csv_category = Menu.Category(2, locale.csv_label) + other = Menu.Category(10, locale.other_features_label) + + menu.add_category(sc_category) + menu.add_category(ktx_category) + menu.add_category(csv_category) + menu.add_category(other) + + try: + import sc_compression + + del sc_compression + except ImportError: + logger.warning(locale.install_to_unlock % "sc-compression") + else: + from system.lib.features.csv.compress import compress_csv + from system.lib.features.csv.decompress import decompress_csv + + try: + import PIL + + del PIL + except ImportError: + logger.warning(locale.install_to_unlock % "PILLOW") + else: + from system.lib.features.sc.decode import ( + decode_and_render_objects, + decode_textures_only, + ) + from system.lib.features.sc.encode import ( + collect_objects_and_encode, + encode_textures_only, + ) + + sc_category.add( + Menu.Item( + name=locale.decode_sc, + description=locale.decode_sc_description, + handler=decode_textures_only, + ) + ) + sc_category.add( + Menu.Item( + name=locale.encode_sc, + description=locale.encode_sc_description, + handler=encode_textures_only, + ) + ) + sc_category.add( + Menu.Item( + name=locale.decode_by_parts, + description=locale.decode_by_parts_description, + handler=decode_and_render_objects, + ) + ) + sc_category.add( + Menu.Item( + name=locale.encode_by_parts, + description=locale.encode_by_parts_description, + handler=collect_objects_and_encode, + ) + ) + sc_category.add( + Menu.Item( + name=locale.overwrite_by_parts, + description=locale.overwrite_by_parts_description, + handler=lambda: collect_objects_and_encode(True), + ) + ) + + from system.lib.features.ktx import ( + convert_ktx_textures_to_png, + convert_png_textures_to_ktx, + ) + from system.lib.pvr_tex_tool import can_use_pvr_tex_tool + + if can_use_pvr_tex_tool(): + ktx_category.add( + Menu.Item( + name=locale.ktx_from_png_label, + description=locale.ktx_from_png_description, + handler=convert_png_textures_to_ktx, + ) + ) + ktx_category.add( + Menu.Item( + name=locale.png_from_ktx_label, + description=locale.png_from_ktx_description, + handler=convert_ktx_textures_to_png, + ) + ) + + csv_category.add( + Menu.Item( + name=locale.decompress_csv, + description=locale.decompress_csv_description, + handler=decompress_csv, + ) + ) + csv_category.add( + Menu.Item( + name=locale.compress_csv, + description=locale.compress_csv_description, + handler=compress_csv, + ) + ) + + other.add( + Menu.Item( + name=locale.check_update, + description=locale.version % config.version, + handler=check_update, + ) + ) + other.add(Menu.Item(name=locale.check_for_outdated, handler=check_for_outdated)) + other.add( + Menu.Item( + name=locale.reinit, + description=locale.reinit_description, + handler=lambda: (initialize(), refill_menu()), + ) + ) + other.add( + Menu.Item( + name=locale.change_language, + description=locale.change_lang_description % config.language, + handler=lambda: (config.change_language(locale.change()), refill_menu()), + ) + ) + other.add( + Menu.Item( + name=locale.clear_directories, + description=locale.clean_dirs_description, + handler=lambda: ( + clear_directories() if Console.question(locale.clear_qu) else -1 + ), + ) + ) + other.add( + Menu.Item( + name=locale.toggle_update_auto_checking, + description=locale.enabled if config.auto_update else locale.disabled, + handler=lambda: (config.toggle_auto_update(), refill_menu()), + ) + ) + other.add(Menu.Item(name=locale.exit, handler=lambda: (clear(), exit()))) diff --git a/system/lib/math/__init__.py b/system/lib/math/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system/lib/objects/point.py b/system/lib/math/point.py similarity index 66% rename from system/lib/objects/point.py rename to system/lib/math/point.py index 70bfc4a..f1b24b0 100644 --- a/system/lib/objects/point.py +++ b/system/lib/math/point.py @@ -1,6 +1,3 @@ -from typing import Tuple - - class Point: def __init__(self, x: float = 0, y: float = 0): self.x: float = x @@ -11,6 +8,16 @@ def __eq__(self, other): return self.x == other.x and self.y == other.y return False + def __mul__(self, other): + if isinstance(other, int): + self.x *= other + self.y *= other + if isinstance(other, float): + self.x *= other + self.y *= other + + return self + def __add__(self, other): if isinstance(other, Point): self.x += other.x @@ -26,13 +33,7 @@ def __neg__(self): return self def __repr__(self): - return str(self.position) + return str(self.as_tuple()) - @property - def position(self) -> Tuple[float, float]: + def as_tuple(self) -> tuple[float, float]: return self.x, self.y - - @position.setter - def position(self, value: Tuple[float, float]): - self.x = value[0] - self.y = value[1] diff --git a/system/lib/math/polygon.py b/system/lib/math/polygon.py new file mode 100644 index 0000000..1880c10 --- /dev/null +++ b/system/lib/math/polygon.py @@ -0,0 +1,104 @@ +from enum import IntEnum +from math import atan2, degrees +from typing import TypeAlias + +from system.lib.math.point import Point +from system.lib.math.rect import Rect +from system.lib.matrices import Matrix2x3 + +Polygon: TypeAlias = list[Point] + + +class PointOrder(IntEnum): + CLOCKWISE = 0 + COUNTER_CLOCKWISE = 1 + + +def get_polygon_sum_of_edges(polygon: Polygon) -> int: + """ + Mostly like signed area, but two times bigger and more accurate with signs. + + https://stackoverflow.com/questions/1165647/how-to-determine-if-a-list-of-polygon-points-are-in-clockwise-order/1165943#1165943 + + :param polygon: + :return: + """ + points_sum = 0 + for i in range(len(polygon)): + p1 = polygon[i] + p2 = polygon[(i + 1) % len(polygon)] + + points_sum += (p2.x - p1.x) * (p1.y + p2.y) + return points_sum + + +def get_polygon_point_order(polygon: Polygon) -> PointOrder | None: + sum_of_edges = get_polygon_sum_of_edges(polygon) + if sum_of_edges > 0: + return PointOrder.CLOCKWISE + elif sum_of_edges < 0: + return PointOrder.COUNTER_CLOCKWISE + + return None + + +def compare_polygons( + polygon1: Polygon, + polygon2: Polygon, +) -> tuple[float, bool]: + """Calculates rotation and if polygon is mirrored. + + :param polygon1: shape polygon + :param polygon2: sheet polygon + :return: rotation degrees, is polygon mirrored + """ + + polygon1_order = get_polygon_point_order(polygon1) + polygon2_order = get_polygon_point_order(polygon2) + + mirroring = polygon1_order != polygon2_order + + dx = (polygon1[1].x - polygon1[0].x) * (-1 if mirroring else 1) + dy = polygon1[1].y - polygon1[0].y + du = polygon2[1].x - polygon2[0].x + dv = polygon2[1].y - polygon2[0].y + + # Solution from https://stackoverflow.com/a/21484228/14915825 + angle_radians = atan2(dy, dx) - atan2(dv, du) + angle = degrees(angle_radians) + + return angle, mirroring + + +def get_rect(polygon: list[Point]) -> Rect: + """Calculates polygon bounds and returns rect. + + :param polygon: polygon points + :return: Rect object + """ + + rect = Rect(left=100000, top=100000, right=-100000, bottom=-100000) + + for point in polygon: + rect.add_point(point.x, point.y) + + return rect + + +def apply_matrix(polygon: Polygon, matrix: Matrix2x3 | None = None) -> Polygon: + """Applies affine matrix to the given polygon. If matrix is none, returns points. + + :param polygon: polygon points + :param matrix: Affine matrix + """ + + if matrix is None: + return polygon + + return [ + Point( + matrix.apply_x(point.x, point.y), + matrix.apply_y(point.x, point.y), + ) + for point in polygon + ] diff --git a/system/lib/math/rect.py b/system/lib/math/rect.py new file mode 100644 index 0000000..011fc94 --- /dev/null +++ b/system/lib/math/rect.py @@ -0,0 +1,45 @@ +from typing import Self + + +class Rect: + def __init__( + self, *, left: float = 0, top: float = 0, right: float = 0, bottom: float = 0 + ): + self.left: float = left + self.top: float = top + self.right: float = right + self.bottom: float = bottom + + @property + def width(self) -> float: + return self.right - self.left + + @property + def height(self) -> float: + return self.bottom - self.top + + def as_tuple(self) -> tuple[float, float, float, float]: + return self.left, self.top, self.right, self.bottom + + def add_point(self, x: float, y: float): + if x < self.left: + self.left = x + if x > self.right: + self.right = x + if y < self.top: + self.top = y + if y > self.bottom: + self.bottom = y + + def merge_bounds(self, other: Self): + if other.left < self.left: + self.left = other.left + if other.right > self.right: + self.right = other.right + if other.top < self.top: + self.top = other.top + if other.bottom > self.bottom: + self.bottom = other.bottom + + def __str__(self): + return f"Rect{self.left, self.top, self.right, self.bottom}" diff --git a/system/lib/matrices/__init__.py b/system/lib/matrices/__init__.py index e69de29..7a68aa0 100644 --- a/system/lib/matrices/__init__.py +++ b/system/lib/matrices/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["MatrixBank", "Matrix2x3", "ColorTransform"] + +from .color_transform import ColorTransform +from .matrix2x3 import Matrix2x3 +from .matrix_bank import MatrixBank diff --git a/system/lib/matrices/matrix2x3.py b/system/lib/matrices/matrix2x3.py index dddb5b4..9bbba4e 100644 --- a/system/lib/matrices/matrix2x3.py +++ b/system/lib/matrices/matrix2x3.py @@ -1,3 +1,6 @@ +from math import atan2, cos, degrees, hypot, sin +from typing import Self + from system.bytestream import Reader DEFAULT_MULTIPLIER = 1024 @@ -5,14 +8,28 @@ class Matrix2x3: - def __init__(self): - self.shear_x: float = 0 - self.shear_y: float = 0 - self.scale_x: float = 1 - self.scale_y: float = 1 + """ + self matrix looks like: + [a c x] + [b d y] + """ + + def __init__(self, matrix: Self | None = None): + self.a: float = 1 + self.b: float = 0 + self.c: float = 0 + self.d: float = 1 self.x: float = 0 self.y: float = 0 + if matrix is not None: + self.a = matrix.a + self.b = matrix.b + self.c = matrix.c + self.d = matrix.d + self.x = matrix.x + self.y = matrix.y + def load(self, reader: Reader, tag: int): divider: int if tag == 8: @@ -22,15 +39,52 @@ def load(self, reader: Reader, tag: int): else: raise ValueError(f"Unsupported matrix tag: {tag}") - self.scale_x = reader.read_int() / divider - self.shear_x = reader.read_int() / divider - self.shear_y = reader.read_int() / divider - self.scale_y = reader.read_int() / divider + self.a = reader.read_int() / divider + self.b = reader.read_int() / divider + self.c = reader.read_int() / divider + self.d = reader.read_int() / divider self.x = reader.read_twip() self.y = reader.read_twip() def apply_x(self, x: float, y: float): - return x * self.scale_x + y * self.shear_y + self.x + return x * self.a + y * self.c + self.x def apply_y(self, x: float, y: float): - return y * self.scale_y + x * self.shear_x + self.y + return y * self.d + x * self.b + self.y + + def multiply(self, matrix: Self) -> Self: + a = (self.a * matrix.a) + (self.b * matrix.c) + b = (self.a * matrix.b) + (self.b * matrix.d) + c = (self.d * matrix.d) + (self.c * matrix.b) + d = (self.d * matrix.c) + (self.c * matrix.a) + x = matrix.apply_x(self.x, self.y) + y = matrix.apply_y(self.x, self.y) + + self.a = a + self.b = b + self.d = c + self.c = d + self.x = x + self.y = y + + return self + + def get_angle_radians(self) -> float: + theta = atan2(self.b, self.a) + return theta + + def get_angle(self) -> float: + return degrees(self.get_angle_radians()) + + def get_scale(self) -> tuple[float, float]: + scale_x = hypot(self.a, self.b) + + theta = self.get_angle_radians() + sin_theta = sin(theta) + + scale_y = self.d / cos(theta) if abs(sin_theta) <= 0.01 else self.c / sin_theta + + return scale_x, scale_y + + def __str__(self): + return f"Matrix2x3{self.a, self.b, self.c, self.d, self.x, self.y}" diff --git a/system/lib/matrices/matrix_bank.py b/system/lib/matrices/matrix_bank.py index 59fe031..a1e045b 100644 --- a/system/lib/matrices/matrix_bank.py +++ b/system/lib/matrices/matrix_bank.py @@ -1,25 +1,23 @@ -from typing import List - from system.lib.matrices.color_transform import ColorTransform from system.lib.matrices.matrix2x3 import Matrix2x3 class MatrixBank: def __init__(self): - self.matrices: List[Matrix2x3] = [] - self.color_transforms: List[ColorTransform] = [] + self._matrices: list[Matrix2x3] = [] + self._color_transforms: list[ColorTransform] = [] def init(self, matrix_count: int, color_transform_count: int): - self.matrices = [] + self._matrices = [] for i in range(matrix_count): - self.matrices.append(Matrix2x3()) + self._matrices.append(Matrix2x3()) - self.color_transforms = [] + self._color_transforms = [] for i in range(color_transform_count): - self.color_transforms.append(ColorTransform()) + self._color_transforms.append(ColorTransform()) def get_matrix(self, index: int) -> Matrix2x3: - return self.matrices[index] + return self._matrices[index] def get_color_transform(self, index: int) -> ColorTransform: - return self.color_transforms[index] + return self._color_transforms[index] diff --git a/system/lib/menu.py b/system/lib/menu.py index 385b9c3..4eac6de 100644 --- a/system/lib/menu.py +++ b/system/lib/menu.py @@ -113,6 +113,3 @@ def choice(self): @staticmethod def _print_divider_line(console_width: int) -> None: print((console_width - 1) * "-") - - -menu = Menu() diff --git a/system/lib/objects/movie_clip.py b/system/lib/objects/movie_clip.py deleted file mode 100644 index 196ea9a..0000000 --- a/system/lib/objects/movie_clip.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -from math import ceil -from typing import TYPE_CHECKING, List, Tuple - -from PIL import Image - -from system.bytestream import Reader -from system.lib.helper import get_size -from system.lib.matrices.matrix_bank import MatrixBank -from system.lib.objects.shape import Shape - -if TYPE_CHECKING: - from system.lib.swf import SupercellSWF - - -CACHE = {} - - -class MovieClipFrame: - def __init__(self): - self._elements_count: int = 0 - self._label: str | None = None - - self._elements: List[Tuple[int, int, int]] = [] - - def load(self, reader: Reader) -> None: - self._elements_count = reader.read_short() - self._label = reader.read_string() - - def get_elements_count(self) -> int: - return self._elements_count - - def set_elements(self, elements: List[Tuple[int, int, int]]) -> None: - self._elements = elements - - def get_elements(self) -> List[Tuple[int, int, int]]: - return self._elements - - def get_element(self, index: int) -> Tuple[int, int, int]: - return self._elements[index] - - -class MovieClip: - def __init__(self): - super().__init__() - - self.id = -1 - self.export_name: str | None = None - self.fps: int = 30 - self.frames_count: int = 0 - self.frames: List[MovieClipFrame] = [] - self.frame_elements: List[Tuple[int, int, int]] = [] - self.blends: List[int] = [] - self.binds: List[int] = [] - self.matrix_bank_index: int = 0 - - def load(self, swf: SupercellSWF, tag: int): - self.id = swf.reader.read_ushort() - - self.fps = swf.reader.read_char() - self.frames_count = swf.reader.read_ushort() - - if tag in (3, 14): - pass - else: - if tag == 49: - swf.reader.read_char() # unknown - - transforms_count = swf.reader.read_uint() - - for i in range(transforms_count): - child_index = swf.reader.read_ushort() - matrix_index = swf.reader.read_ushort() - color_transform_index = swf.reader.read_ushort() - - self.frame_elements.append( - (child_index, matrix_index, color_transform_index) - ) - - binds_count = swf.reader.read_ushort() - - for i in range(binds_count): - bind_id = swf.reader.read_ushort() # bind_id - self.binds.append(bind_id) - - if tag in (12, 35, 49): - for i in range(binds_count): - blend = swf.reader.read_char() # blend - self.blends.append(blend) - - for i in range(binds_count): - swf.reader.read_string() # bind_name - - elements_used = 0 - - while True: - frame_tag = swf.reader.read_uchar() - frame_length = swf.reader.read_int() - - if frame_tag == 0: - break - - if frame_tag == 11: - frame = MovieClipFrame() - frame.load(swf.reader) - frame.set_elements( - self.frame_elements[ - elements_used : elements_used + frame.get_elements_count() - ] - ) - self.frames.append(frame) - - elements_used += frame.get_elements_count() - elif frame_tag == 41: - self.matrix_bank_index = swf.reader.read_uchar() - else: - swf.reader.read(frame_length) - - def render(self, swf: SupercellSWF, matrix=None) -> Image.Image: - if self in CACHE: - return CACHE[self].copy() - - matrix_bank = swf.get_matrix_bank(self.matrix_bank_index) - - # TODO: make it faster - left, top, right, bottom = self.get_sides(swf) - - width, height = get_size(left, top, right, bottom) - size = ceil(width), ceil(height) - image = Image.new("RGBA", size) - - frame = self.frames[0] - for child_index, matrix_index, _ in frame.get_elements(): - if matrix_index != 65535: - matrix = matrix_bank.get_matrix(matrix_index) - else: - matrix = None - - display_object = swf.get_display_object(self.binds[child_index]) - if isinstance(display_object, Shape): - rendered_shape = display_object.render(matrix) - - # TODO: fix position - position = display_object.get_position() - x = int(abs(left) + position[0]) - y = int(abs(top) + position[1]) - - image.paste(rendered_shape, (x, y), rendered_shape) - - CACHE[self] = image - - return image - - def get_sides(self, swf: SupercellSWF) -> Tuple[float, float, float, float]: - matrix_bank: MatrixBank = swf.get_matrix_bank(self.matrix_bank_index) - - left = 0 - top = 0 - right = 0 - bottom = 0 - - for frame in self.frames: - for child_index, matrix_index, _ in frame.get_elements(): - if matrix_index != 65535: - matrix = matrix_bank.get_matrix(matrix_index) - else: - matrix = None - - display_object = swf.get_display_object(self.binds[child_index]) - if isinstance(display_object, Shape): - display_object.apply_matrix(matrix) - - ( - shape_left, - shape_top, - shape_right, - shape_bottom, - ) = display_object.get_sides() - - left = min(left, shape_left) - top = min(top, shape_top) - right = max(right, shape_right) - bottom = max(bottom, shape_bottom) - - return left, top, right, bottom - - def get_position(self) -> Tuple[float, float]: - left, top, _, _ = self.get_sides() - return left, top diff --git a/system/lib/objects/movie_clip/__init__.py b/system/lib/objects/movie_clip/__init__.py new file mode 100644 index 0000000..bcb10e4 --- /dev/null +++ b/system/lib/objects/movie_clip/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["MovieClip", "MovieClipFrame"] + +from system.lib.objects.movie_clip.movie_clip import MovieClip +from system.lib.objects.movie_clip.movie_clip_frame import MovieClipFrame diff --git a/system/lib/objects/movie_clip/movie_clip.py b/system/lib/objects/movie_clip/movie_clip.py new file mode 100644 index 0000000..f1c495d --- /dev/null +++ b/system/lib/objects/movie_clip/movie_clip.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..plain_object import PlainObject +from .movie_clip_frame import MovieClipFrame + +if TYPE_CHECKING: + from system.lib.swf import SupercellSWF + + +CACHE = {} + + +class MovieClip(PlainObject): + def __init__(self): + super().__init__() + + self.id = -1 + self.export_name: str | None = None + self.fps: int = 30 + self.frame_count: int = 0 + self.frames: list[MovieClipFrame] = [] + self.frame_elements: list[tuple[int, int, int]] = [] + self.blends: list[int] = [] + self.binds: list[int] = [] + self.matrix_bank_index: int = 0 + + def load(self, swf: SupercellSWF, tag: int): + self.id = swf.reader.read_ushort() + + self.fps = swf.reader.read_char() + self.frame_count = swf.reader.read_ushort() + + if tag in (3, 14): + pass + else: + if tag == 49: + swf.reader.read_char() # unknown + + transforms_count = swf.reader.read_uint() + + for i in range(transforms_count): + child_index = swf.reader.read_ushort() + matrix_index = swf.reader.read_ushort() + color_transform_index = swf.reader.read_ushort() + + self.frame_elements.append( + (child_index, matrix_index, color_transform_index) + ) + + binds_count = swf.reader.read_ushort() + + for i in range(binds_count): + bind_id = swf.reader.read_ushort() # bind_id + self.binds.append(bind_id) + + if tag in (12, 35, 49): + for i in range(binds_count): + blend = swf.reader.read_char() # blend + self.blends.append(blend) + + for i in range(binds_count): + swf.reader.read_string() # bind_name + + elements_used = 0 + + while True: + frame_tag = swf.reader.read_uchar() + frame_length = swf.reader.read_int() + + if frame_tag == 0: + break + + if frame_tag == 11: + frame = MovieClipFrame() + frame.load(swf.reader) + frame.set_elements( + self.frame_elements[ + elements_used : elements_used + frame.get_elements_count() + ] + ) + self.frames.append(frame) + + elements_used += frame.get_elements_count() + elif frame_tag == 41: + self.matrix_bank_index = swf.reader.read_uchar() + else: + swf.reader.read(frame_length) diff --git a/system/lib/objects/movie_clip/movie_clip_frame.py b/system/lib/objects/movie_clip/movie_clip_frame.py new file mode 100644 index 0000000..0e92108 --- /dev/null +++ b/system/lib/objects/movie_clip/movie_clip_frame.py @@ -0,0 +1,25 @@ +from system.bytestream import Reader + + +class MovieClipFrame: + def __init__(self): + self._elements_count: int = 0 + self._label: str | None = None + + self._elements: list[tuple[int, int, int]] = [] + + def load(self, reader: Reader) -> None: + self._elements_count = reader.read_short() + self._label = reader.read_string() + + def get_elements_count(self) -> int: + return self._elements_count + + def set_elements(self, elements: list[tuple[int, int, int]]) -> None: + self._elements = elements + + def get_elements(self) -> list[tuple[int, int, int]]: + return self._elements + + def get_element(self, index: int) -> tuple[int, int, int]: + return self._elements[index] diff --git a/system/lib/objects/plain_object.py b/system/lib/objects/plain_object.py new file mode 100644 index 0000000..86d3e7b --- /dev/null +++ b/system/lib/objects/plain_object.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from system.lib.swf import SupercellSWF + + +class PlainObject(ABC): + @abstractmethod + def load(self, swf: SupercellSWF, tag: int): + ... diff --git a/system/lib/objects/renderable/__init__.py b/system/lib/objects/renderable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/system/lib/objects/renderable/display_object.py b/system/lib/objects/renderable/display_object.py new file mode 100644 index 0000000..c089e48 --- /dev/null +++ b/system/lib/objects/renderable/display_object.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from PIL import Image + +from system.lib.math.rect import Rect +from system.lib.matrices import ColorTransform, Matrix2x3 + + +class DisplayObject(ABC): + def __init__(self): + self._matrix = Matrix2x3() + self._color_transform = ColorTransform() + + @abstractmethod + def calculate_bounds(self, matrix: Matrix2x3) -> Rect: + ... + + @abstractmethod + def render(self, matrix: Matrix2x3) -> Image.Image: + ... + + def set_matrix(self, matrix: Matrix2x3): + self._matrix = matrix + + def set_color_transform(self, color_transform: ColorTransform): + self._color_transform = color_transform diff --git a/system/lib/objects/renderable/renderable_factory.py b/system/lib/objects/renderable/renderable_factory.py new file mode 100644 index 0000000..c351939 --- /dev/null +++ b/system/lib/objects/renderable/renderable_factory.py @@ -0,0 +1,29 @@ +from system.lib.objects import Shape +from system.lib.objects.movie_clip.movie_clip import MovieClip +from system.lib.objects.plain_object import PlainObject +from system.lib.objects.renderable.display_object import DisplayObject +from system.lib.objects.renderable.renderable_movie_clip import RenderableMovieClip +from system.lib.objects.renderable.renderable_shape import RenderableShape +from system.lib.swf import SupercellSWF + + +def create_renderable_from_plain( + swf: SupercellSWF, plain_object: PlainObject +) -> DisplayObject: + if isinstance(plain_object, Shape): + return RenderableShape(plain_object) + if isinstance(plain_object, MovieClip): + children = [] + + for bind_id in plain_object.binds: + bind_object = swf.get_display_object(bind_id) + + display_object = None + if bind_object is not None: + display_object = create_renderable_from_plain(swf, bind_object) + + children.append(display_object) + + return RenderableMovieClip.create_from_plain(swf, plain_object, children) + + raise Exception(f"Unsupported object type: {plain_object}") diff --git a/system/lib/objects/renderable/renderable_movie_clip.py b/system/lib/objects/renderable/renderable_movie_clip.py new file mode 100644 index 0000000..c8cdaeb --- /dev/null +++ b/system/lib/objects/renderable/renderable_movie_clip.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PIL import Image + +from system.lib.math.rect import Rect +from system.lib.matrices import ColorTransform, Matrix2x3, MatrixBank +from system.lib.objects.movie_clip.movie_clip import MovieClip +from system.lib.objects.movie_clip.movie_clip_frame import MovieClipFrame +from system.lib.objects.renderable.display_object import DisplayObject + +if TYPE_CHECKING: + from system.lib.swf import SupercellSWF + + +class RenderableMovieClip(DisplayObject): + def __init__(self): + super().__init__() + + self._id = -1 + self._export_name: str | None = None + self._fps: int = 30 + self._frame_count: int = 0 + self._frames: list[MovieClipFrame] = [] + self._frame_elements: list[tuple[int, int, int]] = [] + self._blends: list[int] = [] + self._binds: list[int] = [] + self._matrix_bank: MatrixBank | None = None + + self._children: list[DisplayObject] = [] + self._frame_children: list[DisplayObject] = [] + + @staticmethod + def create_from_plain( + swf: SupercellSWF, movie_clip: MovieClip, children: list[DisplayObject] + ) -> RenderableMovieClip: + clip = RenderableMovieClip() + + clip._id = movie_clip.id + clip._matrix_bank = swf.get_matrix_bank(movie_clip.matrix_bank_index) + + clip._export_name = movie_clip.export_name + clip._fps = movie_clip.fps + clip._frame_count = movie_clip.frame_count + clip._frames = movie_clip.frames + clip._frame_elements = movie_clip.frame_elements + clip._blends = movie_clip.blends + clip._binds = movie_clip.binds + clip._children = children + + clip.set_frame(0) + + return clip + + def render(self, matrix: Matrix2x3) -> Image.Image: + matrix_multiplied = Matrix2x3(self._matrix) + matrix_multiplied.multiply(matrix) + + bounds = self.calculate_bounds(matrix) + + image = Image.new("RGBA", (int(bounds.width), int(bounds.height))) + + for child in self._frame_children: + rendered_child = child.render(matrix_multiplied) + child_bounds = child.calculate_bounds(matrix_multiplied) + + x = int(child_bounds.left - bounds.left) + y = int(child_bounds.top - bounds.top) + + image.paste(rendered_child, (x, y), rendered_child) + + return image + + def calculate_bounds(self, matrix: Matrix2x3) -> Rect: + matrix_multiplied = Matrix2x3(self._matrix) + matrix_multiplied.multiply(matrix) + + rect = Rect() + + for child in self._frame_children: + rect.merge_bounds(child.calculate_bounds(matrix_multiplied)) + + return rect + + def set_frame(self, frame_index: int): + self._frame_children = [] + + frame = self._frames[frame_index] + for child_index, matrix_index, color_transform_index in frame.get_elements(): + matrix = Matrix2x3() + if matrix_index != 0xFFFF: + matrix = self._matrix_bank.get_matrix(matrix_index) + + color_transform = ColorTransform() + if color_transform_index != 0xFFFF: + color_transform = self._matrix_bank.get_color_transform( + color_transform_index + ) + + child = self._children[child_index] + if child is None: + continue + + child.set_matrix(matrix) + child.set_color_transform(color_transform) + + self._frame_children.append(child) diff --git a/system/lib/objects/renderable/renderable_shape.py b/system/lib/objects/renderable/renderable_shape.py new file mode 100644 index 0000000..598beec --- /dev/null +++ b/system/lib/objects/renderable/renderable_shape.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from PIL import Image + +from system.lib.math.rect import Rect +from system.lib.matrices import Matrix2x3 +from system.lib.objects import Shape +from system.lib.objects.renderable.display_object import DisplayObject + + +class RenderableShape(DisplayObject): + def __init__(self, shape: Shape): + super().__init__() + + self._id = shape.id + self._regions = shape.regions + + def render(self, matrix: Matrix2x3) -> Image.Image: + matrix_multiplied = Matrix2x3(self._matrix) + matrix_multiplied.multiply(matrix) + + bounds = self.calculate_bounds(matrix) + + image = Image.new("RGBA", (int(bounds.width), int(bounds.height))) + + for region in self._regions: + rendered_region = region.render(matrix_multiplied) + region_bounds = region.calculate_bounds(matrix_multiplied) + + x = int(region_bounds.left - bounds.left) + y = int(region_bounds.top - bounds.top) + + image.paste(rendered_region, (x, y), rendered_region) + + return image + + def calculate_bounds(self, matrix: Matrix2x3) -> Rect: + matrix_multiplied = Matrix2x3(self._matrix) + matrix_multiplied.multiply(matrix) + + rect = Rect() + + for region in self._regions: + rect.merge_bounds(region.calculate_bounds(matrix_multiplied)) + + return rect diff --git a/system/lib/objects/shape.py b/system/lib/objects/shape.py deleted file mode 100644 index 3949442..0000000 --- a/system/lib/objects/shape.py +++ /dev/null @@ -1,296 +0,0 @@ -from __future__ import annotations - -from math import atan2, ceil, degrees -from typing import TYPE_CHECKING, List, Optional, Tuple - -from PIL import Image, ImageDraw - -from system.lib.helper import get_sides, get_size -from system.lib.matrices.matrix2x3 import Matrix2x3 -from system.lib.objects.point import Point -from system.lib.objects.texture import SWFTexture - -if TYPE_CHECKING: - from system.lib.swf import SupercellSWF - - -class Shape: - def __init__(self): - self.id = 0 - self.regions: List[Region] = [] - - def load(self, swf: SupercellSWF, tag: int): - self.id = swf.reader.read_ushort() - - swf.reader.read_ushort() # regions_count - if tag == 18: - swf.reader.read_ushort() # point_count - - while True: - region_tag = swf.reader.read_char() - region_length = swf.reader.read_uint() - - if region_tag == 0: - return - elif region_tag in (4, 17, 22): - region = Region() - region.load(swf, region_tag) - self.regions.append(region) - else: - swf.reader.read(region_length) - - def render(self, matrix=None): - for region in self.regions: - region.apply_matrix(matrix) - - shape_left, shape_top, shape_right, shape_bottom = self.get_sides() - - width, height = get_size(shape_left, shape_top, shape_right, shape_bottom) - size = ceil(width), ceil(height) - - image = Image.new("RGBA", size) - - for region in self.regions: - rendered_region = region.render() - - region_left, region_top = region.get_position() - - x = int(abs(shape_left) + region_left) - y = int(abs(shape_top) + region_top) - - image.paste(rendered_region, (x, y), rendered_region) - - return image - - def apply_matrix(self, matrix: Optional[Matrix2x3] = None) -> None: - """Calls apply_matrix method for all regions. - - :param matrix: Affine matrix - """ - - for region in self.regions: - region.apply_matrix(matrix) - - def get_position(self) -> Tuple[float, float]: - left, top, _, _ = self.get_sides() - return left, top - - def get_sides(self) -> Tuple[float, float, float, float]: - left = 0 - top = 0 - right = 0 - bottom = 0 - for region in self.regions: - region_left, region_top, region_right, region_bottom = region.get_sides() - left = min(left, region_left) - right = max(right, region_right) - top = min(top, region_top) - bottom = max(bottom, region_bottom) - - return left, top, right, bottom - - -class Region: - def __init__(self): - self.texture_index = 0 - self.rotation = 0 - self.is_mirrored = 0 - - self._points_count = 0 - self._xy_points: List[Point] = [] - self._uv_points: List[Point] = [] - self._transformed_points: List[Point] = [] - - self.texture: SWFTexture - - def load(self, swf: SupercellSWF, tag: int): - self.texture_index = swf.reader.read_uchar() - - self.texture = swf.textures[self.texture_index] - - self._points_count = 4 - if tag != 4: - self._points_count = swf.reader.read_uchar() - - self._xy_points = [_class() for _class in [Point] * self._points_count] - self._uv_points = [_class() for _class in [Point] * self._points_count] - - multiplier = 0.5 if swf.use_lowres_texture else 1 - - for i in range(self._points_count): - self._xy_points[i].x = swf.reader.read_int() / 20 - self._xy_points[i].y = swf.reader.read_int() / 20 - for i in range(self._points_count): - u, v = ( - swf.reader.read_ushort() - * swf.textures[self.texture_index].width - / 0xFFFF - * multiplier, - swf.reader.read_ushort() - * swf.textures[self.texture_index].height - / 0xFFFF - * multiplier, - ) - u_rounded, v_rounded = map(ceil, (u, v)) - if int(u) == u_rounded: - u_rounded += 1 - if int(v) == v_rounded: - v_rounded += 1 - - self._uv_points[i].position = (u_rounded, v_rounded) - - def render(self, use_original_size: bool = False) -> Image.Image: - self.apply_matrix(None) - - left, top, right, bottom = self.get_sides() - width, height = get_size(left, top, right, bottom) - width, height = max(width, 1), max(height, 1) - - self.rotation, self.is_mirrored = self.calculate_rotation(True) - - rendered_region = self.get_image() - if sum(rendered_region.size) == 2: - fill_color = rendered_region.getpixel((0, 0)) - - # noinspection PyTypeChecker - rendered_polygon = Image.new(rendered_region.mode, (width, height)) - drawable_image = ImageDraw.Draw(rendered_polygon) - drawable_image.polygon( - [(point.x - left, point.y - top) for point in self._transformed_points], - fill=fill_color, - ) - return rendered_polygon - - rendered_region = rendered_region.rotate(-self.rotation, expand=True) - if self.is_mirrored: - rendered_region = rendered_region.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - if use_original_size: - return rendered_region - return rendered_region.resize((width, height), Image.Resampling.LANCZOS) - - def get_image(self) -> Image.Image: - left, top, right, bottom = get_sides(self._uv_points) - width, height = get_size(left, top, right, bottom) - width, height = max(width, 1), max(height, 1) - if width + height == 1: # The same speed as without this return - return Image.new( - "RGBA", - (width, height), - color=self.texture.image.get_pixel(left, top), # type: ignore - ) - - if width == 1: - right += 1 - - if height == 1: - bottom += 1 - - bbox = int(left), int(top), int(right), int(bottom) - - color = 255 - img_mask = Image.new("L", (self.texture.width, self.texture.height), 0) - ImageDraw.Draw(img_mask).polygon( - [point.position for point in self._uv_points], fill=color - ) - - rendered_region = Image.new("RGBA", (width, height)) - rendered_region.paste( - self.texture.image.crop(bbox), (0, 0), img_mask.crop(bbox) - ) - - return rendered_region - - def get_points_count(self): - return self._points_count - - def get_uv(self, index: int): - return self._uv_points[index] - - def get_u(self, index: int): - return self._uv_points[index].x - - def get_v(self, index: int): - return self._uv_points[index].y - - def get_xy(self, index: int): - return self._xy_points[index] - - def get_x(self, index: int): - return self._xy_points[index].x - - def get_y(self, index: int): - return self._xy_points[index].y - - def get_position(self) -> Tuple[float, float]: - left, top, _, _ = get_sides(self._transformed_points) - return left, top - - def get_sides(self) -> Tuple[float, float, float, float]: - return get_sides(self._transformed_points) - - def apply_matrix(self, matrix: Optional[Matrix2x3] = None) -> None: - """Applies affine matrix to shape (xy) points. - If matrix is none, copies the points. - - :param matrix: Affine matrix - """ - - self._transformed_points = self._xy_points - if matrix is not None: - self._transformed_points = [] - for point in self._xy_points: - self._transformed_points.append( - Point( - matrix.apply_x(point.x, point.y), - matrix.apply_y(point.x, point.y), - ) - ) - - def calculate_rotation( - self, - round_to_nearest: bool = False, - custom_points: Optional[List[Point]] = None, - ) -> tuple[int, bool]: - """Calculates rotation and if region is mirrored. - - :param round_to_nearest: should round to a multiple of 90 - :param custom_points: transformed points, replacement of self._xy_points - :return: rotation angle, is mirroring - """ - - def is_clockwise(points: List[Point]): - points_sum = 0 - for i in range(len(points)): - x1, y1 = points[(i + 1) % len(points)].position - x2, y2 = points[i].position - points_sum += (x1 - x2) * (y1 + y2) - return points_sum > 0 - - xy_points = self._xy_points - if custom_points is not None: - xy_points = custom_points - - is_uv_clockwise = is_clockwise(self._uv_points) - is_xy_clockwise = is_clockwise(xy_points) - - mirroring = not (is_uv_clockwise == is_xy_clockwise) - - dx = xy_points[1].x - xy_points[0].x - dy = xy_points[1].y - xy_points[0].y - du = self._uv_points[1].x - self._uv_points[0].x - dv = self._uv_points[1].y - self._uv_points[0].y - - angle_xy = degrees(atan2(dy, dx)) % 360 - angle_uv = degrees(atan2(dv, du)) % 360 - - angle = angle_xy - angle_uv - - if mirroring: - angle -= 180 - - angle = (angle + 360) % 360 - - if round_to_nearest: - angle = round(angle / 90) * 90 - - return int(angle), mirroring diff --git a/system/lib/objects/shape/__init__.py b/system/lib/objects/shape/__init__.py new file mode 100644 index 0000000..841f84e --- /dev/null +++ b/system/lib/objects/shape/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["Shape", "Region"] + +from system.lib.objects.shape.region import Region +from system.lib.objects.shape.shape import Shape diff --git a/system/lib/objects/shape/region.py b/system/lib/objects/shape/region.py new file mode 100644 index 0000000..6a3e8ad --- /dev/null +++ b/system/lib/objects/shape/region.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PIL import Image + +from system.lib.images import create_filled_polygon_image +from system.lib.math.point import Point +from system.lib.math.polygon import apply_matrix, compare_polygons, get_rect +from system.lib.math.rect import Rect +from system.lib.matrices import Matrix2x3 + +if TYPE_CHECKING: + from system.lib.objects import SWFTexture + from system.lib.swf import SupercellSWF + + +class Region: + def __init__(self): + self.texture_index: int = 0 + + self._texture: SWFTexture | None = None + self._point_count = 0 + self._xy_points: list[Point] = [] + self._uv_points: list[Point] = [] + + self._cache_image: Image.Image | None = None + + def load(self, swf: SupercellSWF, tag: int): + self.texture_index = swf.reader.read_uchar() + + self._texture = swf.textures[self.texture_index] + + self._point_count = 4 + if tag != 4: + self._point_count = swf.reader.read_uchar() + + self._xy_points: list[Point] = [Point() for _ in range(self._point_count)] + self._uv_points: list[Point] = [Point() for _ in range(self._point_count)] + + for i in range(self._point_count): + x = swf.reader.read_int() / 20 + y = swf.reader.read_int() / 20 + + self._xy_points[i] = Point(x, y) + + for i in range(self._point_count): + if tag == 4: + u = swf.reader.read_ushort() * 0xFFFF / self._texture.width + v = swf.reader.read_ushort() * 0xFFFF / self._texture.height + else: + u = swf.reader.read_ushort() * self._texture.width / 0xFFFF + v = swf.reader.read_ushort() * self._texture.height / 0xFFFF + + self._uv_points[i] = Point(u, v) * (0.5 if swf.use_lowres_texture else 1) + + def render(self, matrix: Matrix2x3) -> Image.Image: + transformed_points = apply_matrix(self._xy_points, matrix) + + rect = get_rect(transformed_points) + width, height = max(int(rect.width), 1), max(int(rect.height), 1) + + rendered_region = self.get_image() + if rendered_region.width + rendered_region.height <= 2: + fill_color: int = rendered_region.getpixel((0, 0)) # type: ignore + + return create_filled_polygon_image( + rendered_region.mode, width, height, transformed_points, fill_color + ) + + self.rotation, self.is_mirrored = compare_polygons( + transformed_points, self._uv_points + ) + + rendered_region = rendered_region.rotate(-self.rotation, expand=True) + if self.is_mirrored: + rendered_region = rendered_region.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + + return rendered_region.resize((width, height), Image.Resampling.BILINEAR) + + def get_image(self) -> Image.Image: + # Note: it's 100% safe and very helpful for rendering movie clips + if self._cache_image is not None: + return self._cache_image + + rect = get_rect(self._uv_points) + + width = max(int(rect.width), 1) + height = max(int(rect.height), 1) + if width + height <= 2: # The same speed as without this return + return Image.new( + "RGBA", + (1, 1), + color=self._texture.image.getpixel((int(rect.left), int(rect.top))), + ) + + mask_image = create_filled_polygon_image( + "L", self._texture.width, self._texture.height, self._uv_points, 0xFF + ) + + rendered_region = Image.new("RGBA", (width, height)) + rendered_region.paste( + self._texture.image.crop(rect.as_tuple()), + (0, 0), + mask_image.crop(rect.as_tuple()), + ) + + self._cache_image = rendered_region + + return rendered_region + + def get_point_count(self): + return self._point_count + + def get_uv(self, index: int): + return self._uv_points[index] + + def get_u(self, index: int): + return self._uv_points[index].x + + def get_v(self, index: int): + return self._uv_points[index].y + + def get_xy(self, index: int): + return self._xy_points[index] + + def get_x(self, index: int): + return self._xy_points[index].x + + def get_y(self, index: int): + return self._xy_points[index].y + + def calculate_bounds(self, matrix: Matrix2x3 | None = None) -> Rect: + return get_rect(apply_matrix(self._xy_points, matrix)) diff --git a/system/lib/objects/shape/shape.py b/system/lib/objects/shape/shape.py new file mode 100644 index 0000000..2d5dc4d --- /dev/null +++ b/system/lib/objects/shape/shape.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from system.lib.objects.plain_object import PlainObject +from system.lib.objects.shape import Region + +if TYPE_CHECKING: + from system.lib.swf import SupercellSWF + + +class Shape(PlainObject): + def __init__(self): + super().__init__() + + self.id = 0 + self.regions: list[Region] = [] + + def load(self, swf: SupercellSWF, tag: int): + self.id = swf.reader.read_ushort() + + swf.reader.read_ushort() # regions_count + if tag == 18: + swf.reader.read_ushort() # point_count + + while True: + region_tag = swf.reader.read_char() + region_length = swf.reader.read_uint() + + if region_tag == 0: + return + elif region_tag in (4, 17, 22): + region = Region() + region.load(swf, region_tag) + self.regions.append(region) + else: + swf.reader.read(region_length) diff --git a/system/lib/objects/texture.py b/system/lib/objects/texture.py index a3b2a56..c2cc499 100644 --- a/system/lib/objects/texture.py +++ b/system/lib/objects/texture.py @@ -40,31 +40,34 @@ def load(self, swf: SupercellSWF, tag: int, has_texture: bool): if not has_texture: return + khronos_texture_data = None if tag == 45: # noinspection PyUnboundLocalVariable khronos_texture_data = swf.reader.read(khronos_texture_length) - self.image = get_image_from_ktx_data(khronos_texture_data) - return elif tag == 47: with open( swf.filepath.parent / self.khronos_texture_filename, "rb" ) as file: decompressor = zstandard.ZstdDecompressor() - decompressed = decompressor.decompress(file.read()) - self.image = get_image_from_ktx_data(decompressed) + khronos_texture_data = decompressor.decompress(file.read()) + + if khronos_texture_data is not None: + self.image = get_image_from_ktx_data(khronos_texture_data).resize( + (self.width, self.height), Image.Resampling.LANCZOS + ) return - img = Image.new( + image = Image.new( get_format_by_pixel_type(self.pixel_type), (self.width, self.height) ) - load_texture(swf.reader, self.pixel_type, img) + load_texture(swf.reader, self.pixel_type, image) if tag in (27, 28, 29): - join_image(img) + join_image(image) else: - load_image_from_buffer(img) + load_image_from_buffer(image) os.remove("pixel_buffer") - self.image = img + self.image = image diff --git a/system/lib/swf.py b/system/lib/swf.py index 9645b61..c203c28 100644 --- a/system/lib/swf.py +++ b/system/lib/swf.py @@ -1,13 +1,14 @@ import os from pathlib import Path -from typing import List, Tuple from loguru import logger +from sc_compression import Signatures from system.bytestream import Reader, Writer from system.lib.features.files import open_sc from system.lib.matrices.matrix_bank import MatrixBank from system.lib.objects import MovieClip, Shape, SWFTexture +from system.lib.objects.plain_object import PlainObject from system.localization import locale DEFAULT_HIGHRES_SUFFIX = "_highres" @@ -27,9 +28,9 @@ def __init__(self): self.use_lowres_texture: bool = False - self.shapes: List[Shape] = [] - self.movie_clips: List[MovieClip] = [] - self.textures: List[SWFTexture] = [] + self.shapes: list[Shape] = [] + self.movie_clips: list[MovieClip] = [] + self.textures: list[SWFTexture] = [] self.xcod_writer = Writer("big") @@ -47,38 +48,43 @@ def __init__(self): self._text_field_count: int = 0 self._export_count: int = 0 - self._export_ids: List[int] = [] - self._export_names: List[str] = [] + self._export_ids: list[int] = [] + self._export_names: list[str] = [] - self._matrix_banks: List[MatrixBank] = [] + self._matrix_banks: list[MatrixBank] = [] self._matrix_bank: MatrixBank | None = None - def load(self, filepath: str | os.PathLike) -> Tuple[bool, bool]: + def load(self, filepath: str | os.PathLike) -> tuple[bool, Signatures]: self._filepath = Path(filepath) - texture_loaded, use_lzham = self._load_internal( + texture_loaded, signature = self._load_internal( self._filepath, self._filepath.name.endswith("_tex.sc") ) if not texture_loaded: if self._use_uncommon_texture: - texture_loaded, use_lzham = self._load_internal( + texture_loaded, signature = self._load_internal( self._uncommon_texture_path, True ) else: texture_path = str(self._filepath)[:-3] + SupercellSWF.TEXTURE_EXTENSION - texture_loaded, use_lzham = self._load_internal(texture_path, True) + texture_loaded, signature = self._load_internal(texture_path, True) - return texture_loaded, use_lzham + return texture_loaded, signature def _load_internal( self, filepath: str | os.PathLike, is_texture_file: bool - ) -> Tuple[bool, bool]: + ) -> tuple[bool, Signatures]: self.filename = os.path.basename(filepath) logger.info(locale.collecting_inf % self.filename) - decompressed_data, use_lzham = open_sc(filepath) + decompressed_data, signature = open_sc(filepath) + + if signature.name != Signatures.NONE: + logger.info(locale.detected_comp % signature.name.upper()) + print() + self.reader = Reader(decompressed_data) del decompressed_data @@ -127,7 +133,7 @@ def _load_internal( if isinstance(movie_clip, MovieClip): movie_clip.export_name = export_name - return loaded, use_lzham + return loaded, signature def _load_tags(self, is_texture_file: bool) -> bool: has_texture = True @@ -163,6 +169,7 @@ def _load_tags(self, is_texture_file: bool) -> bool: texture.height, ) ) + print() self.xcod_writer.write_ubyte(tag) self.xcod_writer.write_ubyte(texture.pixel_type) @@ -213,7 +220,7 @@ def _load_tags(self, is_texture_file: bool) -> bool: def get_display_object( self, target_id: int, name: str | None = None, *, raise_error: bool = False - ) -> Shape | MovieClip | None: + ) -> PlainObject | None: for shape in self.shapes: if shape.id == target_id: return shape diff --git a/system/lib/xcod.py b/system/lib/xcod.py index 79b07c8..1b27806 100644 --- a/system/lib/xcod.py +++ b/system/lib/xcod.py @@ -3,11 +3,12 @@ import os from dataclasses import dataclass from pathlib import Path -from typing import Tuple from loguru import logger +from sc_compression import Signatures from system.bytestream import Reader +from system.lib.math.point import Point from system.localization import locale @@ -15,7 +16,7 @@ class SheetInfo: file_type: int pixel_type: int - size: Tuple[int, int] + size: tuple[int, int] @property def width(self) -> int: @@ -29,9 +30,7 @@ def height(self) -> int: @dataclass class RegionInfo: texture_id: int - points: list[tuple[int, int]] - is_mirrored: bool - rotation: int + points: list[Point] @dataclass @@ -43,7 +42,8 @@ class ShapeInfo: @dataclass class FileInfo: name: str - use_lzham: bool + signature: Signatures + signature_version: int | None sheets: list[SheetInfo] shapes: list[ShapeInfo] @@ -57,7 +57,9 @@ def parse_info(metadata_file_path: Path, has_detailed_info: bool) -> FileInfo: ensure_magic_known(reader) - file_info = FileInfo(os.path.splitext(metadata_file_path.name)[0], False, [], []) + file_info = FileInfo( + os.path.splitext(metadata_file_path.name)[0], Signatures.NONE, None, [], [] + ) parse_base_info(file_info, reader) if has_detailed_info: @@ -67,8 +69,9 @@ def parse_info(metadata_file_path: Path, has_detailed_info: bool) -> FileInfo: def parse_base_info(file_info: FileInfo, reader: Reader) -> None: - use_lzham = reader.read_uchar() == 1 - file_info.use_lzham = use_lzham + file_info.signature = Signatures.SC + file_info.signature_version = 1 if reader.read_string() == "LZMA" else 3 + sheets_count = reader.read_uchar() for i in range(sheets_count): file_type = reader.read_uchar() @@ -91,13 +94,11 @@ def parse_detailed_info(file_info: FileInfo, reader: Reader) -> None: texture_id, points_count = reader.read_uchar(), reader.read_uchar() points = [ - (reader.read_ushort(), reader.read_ushort()) + Point(reader.read_ushort(), reader.read_ushort()) for _ in range(points_count) ] - is_mirrored, rotation = reader.read_uchar() == 1, reader.read_char() * 90 - - regions.append(RegionInfo(texture_id, points, is_mirrored, rotation)) + regions.append(RegionInfo(texture_id, points)) file_info.shapes.append(ShapeInfo(shape_id, regions)) diff --git a/system/localization.py b/system/localization.py index 3249633..1837014 100644 --- a/system/localization.py +++ b/system/localization.py @@ -1,6 +1,8 @@ import json import os +from system.lib.config import config + DEFAULT_STRING = "NO LOCALE" @@ -76,7 +78,6 @@ def __init__(self): self.resizing: str = DEFAULT_STRING self.split_pic: str = DEFAULT_STRING self.writing_pic: str = DEFAULT_STRING - self.header_done: str = DEFAULT_STRING self.compressing_with: str = DEFAULT_STRING self.compression_error: str = DEFAULT_STRING self.compression_done: str = DEFAULT_STRING @@ -144,3 +145,4 @@ def change(self): locale = Locale() +locale.load(config.language) diff --git a/tests/polygon_test.py b/tests/polygon_test.py new file mode 100644 index 0000000..c15120a --- /dev/null +++ b/tests/polygon_test.py @@ -0,0 +1,59 @@ +from system.lib.math.point import Point +from system.lib.math.polygon import PointOrder, Polygon, get_polygon_point_order + + +def create_polygon_from_tuple(*polygon: tuple[float, float]) -> Polygon: + return [Point(x, y) for x, y in polygon] + + +def assert_equals(expected, existing) -> None: + assert expected == existing, f"Got {existing}, while {expected=}" + + +def test1(): + polygon = create_polygon_from_tuple( + (4.0, 4.0), (5.0, -2.0), (-1.0, -4.0), (-6.0, 0.0), (-2.0, 5.0), (0.0, 1.0) + ) + + assert_equals(PointOrder.CLOCKWISE, get_polygon_point_order(polygon)) + polygon = create_polygon_from_tuple((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)) + + assert_equals(PointOrder.CLOCKWISE, get_polygon_point_order(polygon)) + + polygon = create_polygon_from_tuple( + (160.0, -73.0), + (89.0, -73.0), + (89.0, -10.0), + (143.0, 10.0), + (156.0, 10.0), + (160.0, -46.0), + ) + + assert_equals(PointOrder.CLOCKWISE, get_polygon_point_order(polygon)) + + polygon = create_polygon_from_tuple( + (5.0, 0.0), (6.0, 4.0), (4.0, 5.0), (1.0, 5.0), (1.0, 0.0) + ) + + assert_equals(PointOrder.COUNTER_CLOCKWISE, get_polygon_point_order(polygon)) + + polygon = create_polygon_from_tuple( + (0.0, 0.0), (11.0, 0.0), (0.0, 10.0), (10.0, 10.0) + ) + + assert_equals(PointOrder.COUNTER_CLOCKWISE, get_polygon_point_order(polygon)) + + polygon = create_polygon_from_tuple( + (20.0, -73.0), + (91.0, -73.0), + (91.0, -10.0), + (37.0, 10.0), + (24.0, 10.0), + (20.0, -46.0), + ) + + assert_equals(PointOrder.COUNTER_CLOCKWISE, get_polygon_point_order(polygon)) + + +if __name__ == "__main__": + test1()