From f40efb0467fdf01922cf285169355207ff8ac43f Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 24 Jul 2025 13:59:34 +0800 Subject: [PATCH] chore: finish icons builder - finish icons builder in scripts. --- scripts/bme.py | 76 ++++++++++++++- scripts/build_icons.py | 74 +++++++------- scripts/common.py | 63 ------------ scripts/validate_json.py | 206 ++++++++++----------------------------- 4 files changed, 164 insertions(+), 255 deletions(-) diff --git a/scripts/bme.py b/scripts/bme.py index 27e6509..e211644 100644 --- a/scripts/bme.py +++ b/scripts/bme.py @@ -1,3 +1,77 @@ import enum -from typing import Optional, Self +from typing import Optional from pydantic import BaseModel, RootModel, Field, model_validator, ValidationError + + +class ShowcaseType(enum.StrEnum): + Nothing = 'none' + Floor = 'floor' + Rail = 'Rail' + Wood = 'wood' + + +class ShowcaseCfgType(enum.StrEnum): + Float = 'float' + Int = 'int' + Bool = 'bool' + Face = 'face' + + +class ShowcaseCfg(BaseModel): + field: str = Field(frozen=True, strict=True) + type: ShowcaseCfgType = Field(frozen=True) + title: str = Field(frozen=True, strict=True) + desc: str = Field(frozen=True, strict=True) + default: str = Field(frozen=True, strict=True) + + +class Showcase(BaseModel): + title: str = Field(frozen=True, strict=True) + icon: str = Field(frozen=True, strict=True) + type: ShowcaseType = Field(frozen=True) + cfgs: list[ShowcaseCfg] = Field(frozen=True, strict=True) + + +class Param(BaseModel): + field: str = Field(frozen=True, strict=True) + data: str = Field(frozen=True, strict=True) + + +class Var(BaseModel): + field: str = Field(frozen=True, strict=True) + data: str = Field(frozen=True, strict=True) + + +class Vertex(BaseModel): + skip: str = Field(frozen=True, strict=True) + data: str = Field(frozen=True, strict=True) + + +class Face(BaseModel): + skip: str = Field(frozen=True, strict=True) + texture: str = Field(frozen=True, strict=True) + indices: list[int] = Field(frozen=True, strict=True) + uvs: list[str] = Field(frozen=True, strict=True) + normals: Optional[list[str]] = Field(frozen=True, strict=True) + + +class Instance(BaseModel): + identifier: str = Field(frozen=True, strict=True) + skip: str = Field(frozen=True, strict=True) + params: dict[str, str] = Field(frozen=True, strict=True) + transform: str = Field(frozen=True, strict=True) + + +class Prototype(BaseModel): + identifier: str = Field(frozen=True, strict=True) + showcase: Optional[Showcase] = Field(frozen=True, strict=True) + params: list[Param] = Field(frozen=True, strict=True) + skip: str = Field(frozen=True, strict=True) + vars: list[Var] = Field(frozen=True, strict=True) + vertices: list[Vertex] = Field(frozen=True, strict=True) + faces: list[Face] = Field(frozen=True, strict=True) + instances: list[Instance] = Field(frozen=True, strict=True) + + +class Prototypes(RootModel): + root: list[Prototype] = Field(frozen=True, strict=True) diff --git a/scripts/build_icons.py b/scripts/build_icons.py index 7d8726e..4fae1fb 100644 --- a/scripts/build_icons.py +++ b/scripts/build_icons.py @@ -1,51 +1,53 @@ -import logging +import logging, os from pathlib import Path import common +from common import AssetKind import PIL, PIL.Image -# the config for thumbnail +# The HW size of thumbnail THUMBNAIL_SIZE: int = 16 -class ThumbnailBuilder(): - def __init__(self): - pass +def _create_thumbnail(src_file: Path, dst_file: Path) -> None: + # open image + src_image: PIL.Image.Image = PIL.Image.open(src_file) + # create thumbnail + src_image.thumbnail((THUMBNAIL_SIZE, THUMBNAIL_SIZE)) + # save to new file + src_image.save(dst_file) - def build_thumbnails(self) -> None: - # get folder path - root_folder = common.get_root_folder() - # prepare handler - def folder_handler(rel_name: str, src_folder: Path, dst_folder: Path) -> None: - # just create folder - logging.info(f'Creating Folder: {src_folder} -> {dst_folder}') - dst_folder.mkdir(parents=False, exist_ok=True) - def file_handler(rel_name: str, src_file: Path, dst_file: Path) -> None: - # skip non-image - if src_file.suffix != '.png': return - # call thumbnail func - logging.info(f'Building Thumbnail: {src_file} -> {dst_file}') - self.__resize_image(src_file, dst_file) +def build_icons() -> None: + raw_icons_dir = common.get_raw_assets_folder(AssetKind.Icons) + plg_icons_dir = common.get_plugin_assets_folder(AssetKind.Icons) - # call common processor - common.common_file_migrator( - root_folder / 'raw_icons', - root_folder / 'icons', - folder_handler, - file_handler - ) + # TODO: If we have Python 3.12, use Path.walk instead of current polyfill. - logging.info('Building thumbnail done.') + # Icon assets has subdirectory, so we need use another way to process. + for root, dirs, files in os.walk(raw_icons_dir): + root = Path(root) + + # Iterate folders + for name in dirs: + # Fetch directory path + raw_icon_subdir = root / name + plg_icon_subdir = plg_icons_dir / raw_icon_subdir.relative_to(raw_icons_dir) + # Show message + logging.info(f'Creating Folder: {raw_icon_subdir} -> {plg_icon_subdir}') + # Create directory + plg_icon_subdir.mkdir(parents=True, exist_ok=True) + + # Iterate files + for name in files: + # Fetch file path + raw_icon_file = root / name + plg_icon_file = plg_icons_dir / raw_icon_file.relative_to(raw_icons_dir) + # Show message + logging.info(f'Building Thumbnail: {raw_icon_file} -> {plg_icon_file}') + # Create thumbnail + _create_thumbnail(raw_icon_file, plg_icon_file) - def __resize_image(self, src_file: Path, dst_file: Path) -> None: - # open image - src_image: PIL.Image.Image = PIL.Image.open(src_file) - # create thumbnail - src_image.thumbnail((THUMBNAIL_SIZE, THUMBNAIL_SIZE)) - # save to new file - src_image.save(dst_file) if __name__ == '__main__': common.setup_logging() - thumbnail_builder = ThumbnailBuilder() - thumbnail_builder.build_thumbnails() + build_icons() diff --git a/scripts/common.py b/scripts/common.py index 8402abd..2f2ccd7 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -35,69 +35,6 @@ def get_plugin_assets_folder(kind: AssetKind) -> Path: return get_root_folder() / 'bbp_ng' / str(kind) -# def relative_to_folder(abs_path: Path, src_parent: Path, dst_parent: Path) -> Path: -# """ -# Rebase one path to another path. - -# Give a absolute file path and folder path, and compute the relative path of given file to given folder. -# Then applied the computed relative path to another given folder path. -# Thus it seems like the file was rebased to from a folder to another folder with keeping the folder hierarchy. - -# For example, given `/path/to/file` and `/path`, it will compute relative path `to/file`. -# Then it was applied to another folder path `/new` and got `/new/to/file`. - -# :param abs_path: The absolute path to a folder or file. -# :param src_parent: The absolute path to folder which the `abs_path` will have relative path to. -# :param dst_parent: The absolute path to folder which the relative path will be applied to. -# """ -# return dst_parent / (abs_path.relative_to(src_parent)) - - -# def common_file_migrator(from_folder: Path, to_folder: Path, fct_proc_folder: typing.Callable[[str, Path, Path], None], -# fct_proc_file: typing.Callable[[str, Path, Path], None]) -> None: -# """ -# Common file migrator used by some build script. - -# This function receive 2 absolute folder path. `from_folder` indicate the file migrated out, -# and `to_folder` indicate the file migrated in. -# `fct_proc_folder` is a function pointer from caller which handle folder migration in detail. -# `fct_proc_file` is same but handle file migration. - -# `fct_proc_folder` will receive 3 args. -# First is the name of this folder which can be shown for end user. -# Second is the source folder and third is expected dest folder. -# `fct_proc_file` is same, but receive the file path instead. -# Both of these function pointer should do the migration in detail. This function will only just iterate -# folder and give essential args and will not do any migration operations such as copying or moving. - -# :param from_folder: The folder need to be migrated. -# :param to_folder: The folder will be migrated to. -# :param fct_proc_folder: Folder migration detail handler. -# :param fct_proc_file: File migration detail handler. -# """ -# # TODO: If we have Python 3.12, use Path.walk instead of current polyfill. - -# # iterate from_folder folder -# for root, dirs, files in os.walk(from_folder, topdown=True): -# root = Path(root) - -# # iterate folders -# for name in dirs: -# # prepare handler args -# src_folder = root / name -# dst_folder = relative_to_folder(src_folder, from_folder, to_folder) -# # call handler -# fct_proc_folder(name, src_folder, dst_folder) - -# # iterate files -# for name in files: -# # prepare handler args -# src_file = root / name -# dst_file = relative_to_folder(src_file, from_folder, to_folder) -# # call handler -# fct_proc_file(name, src_file, dst_file) - - def setup_logging() -> None: """ Setup uniform style for logging module. diff --git a/scripts/validate_json.py b/scripts/validate_json.py index 4c24d49..369809c 100644 --- a/scripts/validate_json.py +++ b/scripts/validate_json.py @@ -1,174 +1,70 @@ -import enum -import json -import logging -import ast -from typing import Optional, Self -from pydantic import BaseModel, RootModel, Field, model_validator, ValidationError -import common +import json, logging, ast, typing +import pydantic +import common, bme +from common import AssetKind +#region Assistant Validator -def validate_programmable_str(probe: str) -> None: +def _validate_programmable_field(probe: str) -> None: try: ast.parse(probe) except SyntaxError: - raise ValueError( - f'String {probe} may not be a valid Python statement which is not suit for programmable field.') + logging.error(f'String {probe} may not be a valid Python statement which is not suit for programmable field.') -class ShowcaseType(enum.StrEnum): - Nothing = 'none' - Floor = 'floor' - Rail = 'Rail' - Wood = 'wood' +def _validate_showcase_icon(icon_name: str) -> None: + icon_path = common.get_raw_assets_folder(AssetKind.Icons) / 'bme' / f'{icon_name}.png' + if not icon_path.is_file(): + logging.error(f'Icon value {icon_name} may not be valid because it do not existing.') + +#endregion + +#region Core Validator + +def _validate_prototype(prototype: bme.Prototype) -> None: + pass -class ShowcaseCfgType(enum.StrEnum): - Float = 'float' - Int = 'int' - Bool = 'bool' - Face = 'face' - - -class ShowcaseCfg(BaseModel): - field: str = Field(frozen=True, strict=True) - type: ShowcaseCfgType = Field(frozen=True) - title: str = Field(frozen=True, strict=True) - desc: str = Field(frozen=True, strict=True) - default: str = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.default) - return self - - -class Showcase(BaseModel): - title: str = Field(frozen=True, strict=True) - icon: str = Field(frozen=True, strict=True) - type: ShowcaseType = Field(frozen=True) - cfgs: list[ShowcaseCfg] = Field(frozen=True, strict=True) - - -class Param(BaseModel): - field: str = Field(frozen=True, strict=True) - data: str = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.data) - return self - - -class Var(BaseModel): - field: str = Field(frozen=True, strict=True) - data: str = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.data) - return self - - -class Vertex(BaseModel): - skip: str = Field(frozen=True, strict=True) - data: str = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.skip) - validate_programmable_str(self.data) - return self - - -class Face(BaseModel): - skip: str = Field(frozen=True, strict=True) - texture: str = Field(frozen=True, strict=True) - indices: list[int] = Field(frozen=True, strict=True) - uvs: list[str] = Field(frozen=True, strict=True) - normals: Optional[list[str]] = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_count(self) -> Self: - expected_count = len(self.indices) - if len(self.uvs) != expected_count: - raise ValueError('The length of uv array is not matched with indices.') - if (self.normals is not None) and (len(self.normals) != expected_count): - raise ValueError('The length of normal array is not matched with indices.') - return self - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.skip) - validate_programmable_str(self.texture) - for i in self.uvs: - validate_programmable_str(i) - if self.normals is not None: - for i in self.normals: - validate_programmable_str(i) - return self - - -class Instance(BaseModel): - identifier: str = Field(frozen=True, strict=True) - skip: str = Field(frozen=True, strict=True) - params: dict[str, str] = Field(frozen=True, strict=True) - transform: str = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.skip) - for v in self.params.values(): - validate_programmable_str(v) - validate_programmable_str(self.transform) - return self - - -IDENTIFIERS: set[str] = set() - - -class Prototype(BaseModel): - identifier: str = Field(frozen=True, strict=True) - showcase: Optional[Showcase] = Field(frozen=True, strict=True) - params: list[Param] = Field(frozen=True, strict=True) - skip: str = Field(frozen=True, strict=True) - vars: list[Var] = Field(frozen=True, strict=True) - vertices: list[Vertex] = Field(frozen=True, strict=True) - faces: list[Face] = Field(frozen=True, strict=True) - instances: list[Instance] = Field(frozen=True, strict=True) - - @model_validator(mode='after') - def verify_identifier(self) -> Self: - global IDENTIFIERS - if self.identifier in IDENTIFIERS: - raise ValueError(f'Identifier {self.identifier} is already registered.') - else: - IDENTIFIERS.add(self.identifier) - return self - - @model_validator(mode='after') - def verify_prog_field(self) -> Self: - validate_programmable_str(self.skip) - return self - - -class Prototypes(RootModel): - root: list[Prototype] = Field(frozen=True, strict=True) - +#endregion def validate_json() -> None: - raw_json_folder = common.get_root_folder() / 'raw_jsons' + raw_jsons_dir = common.get_raw_assets_folder(AssetKind.Jsons) - for json_file in raw_json_folder.rglob('*.json'): - logging.info(f'Validating {json_file} ...') + # Load all prototypes and check their basic format + prototypes: list[bme.Prototype] = [] + for raw_json_file in raw_jsons_dir.glob('*.json'): + # Skip non-file + if not raw_json_file.is_file(): + continue + + # Show info + logging.info(f'Loading {raw_json_file}') + + # Load prototypes try: - with open(json_file, 'r', encoding='utf-8') as f: + with open(raw_json_file, 'r', encoding='utf-8') as f: docuement = json.load(f) - Prototypes.model_validate(docuement) + file_prototypes = bme.Prototypes.model_validate(docuement) except json.JSONDecodeError as e: - logging.error(f'Can not load file {json_file}. It may not a valid JSON file. Reason: {e}') - except ValidationError as e: - logging.error(f'File {json_file} is not correct. Reason: {e}') + logging.error(f'File {raw_json_file} is not a valid JSON file. Reason: {e}') + except pydantic.ValidationError as e: + logging.error(f'JSON file {raw_json_file} lose essential fields. Detail: {e}') + # Append all prototypes into list + prototypes += file_prototypes.root + + # Collect identifier and check identifier first. + identifiers: set[str] = set() + for prototype in prototypes: + identifier = prototype.identifier + if prototype.identifier in identifiers: + logging.error(f'Identifier {identifier} is registered more than once.') + else: + identifiers.add(identifier) + + # Start custom validation + for protype in prototypes: + _validate_prototype(prototype) if __name__ == '__main__': common.setup_logging()