chore: finish icons builder

- finish icons builder in scripts.
This commit is contained in:
2025-07-24 13:59:34 +08:00
parent 10de948a79
commit f40efb0467
4 changed files with 164 additions and 255 deletions

View File

@ -1,3 +1,77 @@
import enum import enum
from typing import Optional, Self from typing import Optional
from pydantic import BaseModel, RootModel, Field, model_validator, ValidationError 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)

View File

@ -1,51 +1,53 @@
import logging import logging, os
from pathlib import Path from pathlib import Path
import common import common
from common import AssetKind
import PIL, PIL.Image import PIL, PIL.Image
# the config for thumbnail # The HW size of thumbnail
THUMBNAIL_SIZE: int = 16 THUMBNAIL_SIZE: int = 16
class ThumbnailBuilder():
def __init__(self): def _create_thumbnail(src_file: Path, dst_file: Path) -> None:
pass # 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 build_icons() -> None:
def folder_handler(rel_name: str, src_folder: Path, dst_folder: Path) -> None: raw_icons_dir = common.get_raw_assets_folder(AssetKind.Icons)
# just create folder plg_icons_dir = common.get_plugin_assets_folder(AssetKind.Icons)
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)
# call common processor # TODO: If we have Python 3.12, use Path.walk instead of current polyfill.
common.common_file_migrator(
root_folder / 'raw_icons',
root_folder / 'icons',
folder_handler,
file_handler
)
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__': if __name__ == '__main__':
common.setup_logging() common.setup_logging()
thumbnail_builder = ThumbnailBuilder() build_icons()
thumbnail_builder.build_thumbnails()

View File

@ -35,69 +35,6 @@ def get_plugin_assets_folder(kind: AssetKind) -> Path:
return get_root_folder() / 'bbp_ng' / str(kind) 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: def setup_logging() -> None:
""" """
Setup uniform style for logging module. Setup uniform style for logging module.

View File

@ -1,174 +1,70 @@
import enum import json, logging, ast, typing
import json import pydantic
import logging import common, bme
import ast from common import AssetKind
from typing import Optional, Self
from pydantic import BaseModel, RootModel, Field, model_validator, ValidationError
import common
#region Assistant Validator
def validate_programmable_str(probe: str) -> None: def _validate_programmable_field(probe: str) -> None:
try: try:
ast.parse(probe) ast.parse(probe)
except SyntaxError: except SyntaxError:
raise ValueError( logging.error(f'String {probe} may not be a valid Python statement which is not suit for programmable field.')
f'String {probe} may not be a valid Python statement which is not suit for programmable field.')
class ShowcaseType(enum.StrEnum): def _validate_showcase_icon(icon_name: str) -> None:
Nothing = 'none' icon_path = common.get_raw_assets_folder(AssetKind.Icons) / 'bme' / f'{icon_name}.png'
Floor = 'floor' if not icon_path.is_file():
Rail = 'Rail' logging.error(f'Icon value {icon_name} may not be valid because it do not existing.')
Wood = 'wood'
#endregion
#region Core Validator
def _validate_prototype(prototype: bme.Prototype) -> None:
pass
class ShowcaseCfgType(enum.StrEnum): #endregion
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)
def validate_json() -> None: 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'): # Load all prototypes and check their basic format
logging.info(f'Validating {json_file} ...') 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: 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) docuement = json.load(f)
Prototypes.model_validate(docuement) file_prototypes = bme.Prototypes.model_validate(docuement)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logging.error(f'Can not load file {json_file}. It may not a valid JSON file. Reason: {e}') logging.error(f'File {raw_json_file} is not a valid JSON file. Reason: {e}')
except ValidationError as e: except pydantic.ValidationError as e:
logging.error(f'File {json_file} is not correct. Reason: {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__': if __name__ == '__main__':
common.setup_logging() common.setup_logging()