import bpy import typing, enum, re from . import UTIL_functions from . import PROP_virtools_group #region Rename Error Reporter class _RenameErrorType(enum.IntEnum): ERROR = enum.auto() WARNING = enum.auto() INFO = enum.auto() class _RenameErrorItem(): mErrType: _RenameErrorType mDescription: str def __init__(self, err_t: _RenameErrorType, description: str): self.mErrType = err_t self.mDescription = description class RenameErrorReporter(): """ A basic 'rename error report' using simple prints in console. This object can be used as a context manager. It supports multiple levels of 'substeps' - you shall always enter at least one substep (because level 0 has only one single step, representing the whole 'area' of the progress stuff). You should give the object renaming of substeps each time you enter a new one. Leaving a substep automatically steps by one the parent level. ``` with RenameErrorReporter() as reporter: progress.enter_object(obj) # process for object with reporter reporter.add_error('fork!') progress.leave_object() ``` """ mAllObjCounter: int mFailedObjCounter: int mErrList: list[_RenameErrorItem] mOldName: str mHasError: bool def __init__(self): self.mAllObjCounter = 0 self.mFailedObjCounter = 0 self.mErrList = [] self.mOldName = "" self.mHasError = False def add_error(self, description: str): self.mHasError = True self.mErrList.append(_RenameErrorItem(_RenameErrorType.ERROR, description)) def add_warning(self, description: str): self.mErrList.append(_RenameErrorItem(_RenameErrorType.WARNING, description)) def add_info(self, description: str): self.mErrList.append(_RenameErrorItem(_RenameErrorType.INFO, description)) def get_all_objs_count(self) -> int: return self.mAllObjCounter def get_failed_objs_count(self) -> int: return self.mFailedObjCounter def __enter__(self): # print console report header print('============') print('Rename Report') print('------------') # return self as context return self def __exit__(self, exc_type, exc_value, traceback): # print console report tail print('------------') print(f'All / Failed - {self.mAllObjCounter} / {self.mFailedObjCounter}') print('============') # reset variables self.mAllObjCounter = 0 self.mFailedObjCounter = 0 def enter_object(self, obj: bpy.types.Object) -> None: # inc all counter self.mAllObjCounter += 1 # assign old name self.mOldName = obj.name def leave_object(self, obj:bpy.types.Object) -> None: # if error list is empty, no need to report if len(self.mErrList) == 0: return # inc failed if necessary if self.mHasError: self.mFailedObjCounter += 1 # output header # if new name is different with old name, output both of them new_name: str = obj.name if self.mOldName == new_name: print(f'For object "{new_name}"') else: print(f'For object "{new_name}" (Old name: "{self.mOldName}")') # output error list with indent for item in self.mErrList: print('\t' + RenameErrorReporter.__erritem_to_string(item)) # clear error list for next object self.mErrList.clear() self.mHasError = False @staticmethod def __errtype_to_string(err_v: _RenameErrorType) -> str: match(err_v): case _RenameErrorType.ERROR: return 'ERROR' case _RenameErrorType.WARNING: return 'WARN' case _RenameErrorType.INFO: return 'INFO' case _: raise UTIL_functions.BBPException("Unknown error type.") @staticmethod def __erritem_to_string(item: _RenameErrorItem) -> str: return f'[{RenameErrorReporter.__errtype_to_string(item.mErrType)}]\t{item.mDescription}' #endregion #region Naming Convention Used Types class BallanceObjectType(enum.IntEnum): COMPONENT = enum.auto() FLOOR = enum.auto() RAIL = enum.auto() WOOD = enum.auto() STOPPER = enum.auto() LEVEL_START = enum.auto() LEVEL_END = enum.auto() CHECKPOINT = enum.auto() RESETPOINT = enum.auto() DEPTH_CUBE = enum.auto() SKYLAYER = enum.auto() DECORATION = enum.auto() class BallanceObjectInfo(): mBasicType: BallanceObjectType ## Only available for COMPONENT basic type mComponentType: str | None ## Only available for COMPONENT, CHECKPOINT, RESETPOINT basic type # For COMPONENT, it indicate which sector this component belong to. # For CHECKPOINT, RESETPOINT, it indicate the index of this object. # In CHECKPOINT, RESETPOINT mode, the sector actually is the suffix number of these objects' name. So checkpoint starts with 1, not 0. mSector: int | None def __init__(self, basic_type: BallanceObjectType): self.mBasicType = basic_type @classmethod def create_from_component(cls, comp_type: str, sector: int): inst = cls(BallanceObjectType.COMPONENT) inst.mComponentType = comp_type inst.mSector = sector return inst @classmethod def create_from_checkpoint(cls, sector: int): inst = cls(BallanceObjectType.CHECKPOINT) inst.mSector = sector return inst @classmethod def create_from_resetpoint(cls, sector: int): inst = cls(BallanceObjectType.RESETPOINT) inst.mSector = sector return inst @classmethod def create_from_others(cls, basic_type: BallanceObjectType): return cls(basic_type) #endregion #region Naming Convention Declaration _g_BlcNormalComponents: set[str] = set(( "P_Extra_Life", "P_Extra_Point", "P_Trafo_Paper", "P_Trafo_Stone", "P_Trafo_Wood", "P_Ball_Paper", "P_Ball_Stone", "P_Ball_Wood", "P_Box", "P_Dome", "P_Modul_01", "P_Modul_03", "P_Modul_08", "P_Modul_17", "P_Modul_18", "P_Modul_19", "P_Modul_25", "P_Modul_26", "P_Modul_29", "P_Modul_30", "P_Modul_34", "P_Modul_37", "P_Modul_41" )) _g_BlcUniqueComponents: set[str] = set(( "PS_Levelstart", "PE_Levelende", "PC_Checkpoints", "PR_Resetpoints" )) _g_BlcFloor: set[str] = set(( "Sound_HitID_01", "Sound_RollID_01" )) _g_BlcWood: set[str] = set(( "Sound_HitID_02", "Sound_RollID_02" )) class VirtoolsGroupConvention(): cRegexGroupSector: typing.ClassVar[re.Pattern] = re.compile('^Sector_(0[1-8]|[1-9][0-9]{1,2}|9)$') cRegexComponent: typing.ClassVar[re.Pattern] = re.compile('^(' + '|'.join(_g_BlcNormalComponents) + ')_(0[1-9]|[1-9][0-9])_.*$') cRegexPC: typing.ClassVar[re.Pattern] = re.compile('^PC_TwoFlames_(0[1-7])$') cRegexPR: typing.ClassVar[re.Pattern] = re.compile('^PR_Resetpoint_(0[1-8])$') @staticmethod def __get_pcpr_from_name(name: str, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None: regex_result = VirtoolsGroupConvention.cRegexPC.match(name) if regex_result is not None: return BallanceObjectInfo.create_from_checkpoint( int(regex_result.group(1)) ) regex_result = VirtoolsGroupConvention.cRegexPR.match(name) if regex_result is not None: return BallanceObjectInfo.create_from_resetpoint( int(regex_result.group(1)) ) if reporter: reporter.add_error("PC_Checkpoints or PR_Resetpoints detected. But couldn't get sector from name.") return None @staticmethod def __get_sector_from_groups(gps: typing.Iterator[str]) -> int | None: # this counter is served for stupid # multi-sector-grouping accident. counter: int = 0 last_matched_sector: int = 0 for i in gps: regex_result = VirtoolsGroupConvention.cRegexGroupSector.match(i) if regex_result is not None: last_matched_sector = int(regex_result.group(1)) counter += 1 if counter != 1: return None else: return last_matched_sector @staticmethod def parse_from_object(obj: bpy.types.Object, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None: # create visitor with PROP_virtools_group.VirtoolsGroupsHelper(obj) as gp: # if no group, we should consider it is decoration or skylayer if gp.get_count() == 0: if obj.name == 'SkyLayer': return BallanceObjectInfo.create_from_others(BallanceObjectType.SKYLAYER) else: return BallanceObjectInfo.create_from_others(BallanceObjectType.DECORATION) # try to filter unique elements first inter_gps: set[str] = gp.intersect_groups(_g_BlcUniqueComponents) if len(inter_gps) == 1: # get it match((tuple(inter_gps))[0]): case 'PS_Levelstart': return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_START) case 'PE_Levelende': return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_END) case 'PC_Checkpoints' | 'PR_Resetpoints': # these type's data should be gotten from its name return VirtoolsGroupConvention.__get_pcpr_from_name(obj.name, reporter) case _: if reporter: reporter.add_error("The match of Unique Component lost.") return None elif len(inter_gps) != 0: if reporter: reporter.add_error("A Multi-grouping Unique Component.") return None # distinguish normal elements inter_gps = gp.intersect_groups(_g_BlcNormalComponents) if len(inter_gps) == 1: # get it # now try get its sector gotten_elements: str = (tuple(inter_gps))[0] gotten_sector: int | None = VirtoolsGroupConvention.__get_sector_from_groups(gp.iterate_groups()) if gotten_sector is None: # fail to get sector if reporter: reporter.add_error("Component detected. But couldn't get sector from CKGroup data.") return None return BallanceObjectInfo.create_from_component( gotten_elements, gotten_sector ) elif len(inter_gps) != 0: # must be a weird grouping, report it if reporter: reporter.add_error("A Multi-grouping Component.") return None # distinguish road if gp.contain_group('Phys_FloorRails'): # rail return BallanceObjectInfo.create_from_others(BallanceObjectType.RAIL) elif gp.contain_group('Phys_Floors'): # distinguish it between Floor and Wood floor_result = gp.intersect_groups(_g_BlcFloor) rail_result = gp.intersect_groups(_g_BlcWood) if len(floor_result) > 0 and len(rail_result) == 0: return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR) elif len(floor_result) == 0 and len(rail_result) > 0: return BallanceObjectInfo.create_from_others(BallanceObjectType.WOOD) else: if reporter: reporter.add_warning("Can't distinguish object between Floors and Rails. Suppose it is Floors.") return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR) elif gp.contain_group('Phys_FloorStopper'): return BallanceObjectInfo.create_from_others(BallanceObjectType.STOPPER) elif gp.contain_group('DepthTestCubes'): return BallanceObjectInfo.create_from_others(BallanceObjectType.DEPTH_CUBE) # no matched if reporter: reporter.add_error("Group match lost.") return None @staticmethod def set_to_object(obj: bpy.types.Object, info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> bool: # create visitor with PROP_virtools_group.VirtoolsGroupsHelper(obj) as gp: # match by basic type match(info.mBasicType): case BallanceObjectType.DECORATION: pass # decoration do not need group case BallanceObjectType.SKYLAYER: pass # sky layer do not need group case BallanceObjectType.LEVEL_START: gp.add_group('PS_Levelstart') case BallanceObjectType.LEVEL_END: gp.add_group('PE_Levelende') case BallanceObjectType.CHECKPOINT: gp.add_group('PC_Checkpoints') case BallanceObjectType.RESETPOINT: gp.add_group('PR_Resetpoints') case BallanceObjectType.DEPTH_CUBE: gp.add_group('PE_Levelende') case BallanceObjectType.FLOOR: gp.add_group('Phys_Floors') gp.add_group('Sound_HitID_01') gp.add_group('Sound_RollID_01') case BallanceObjectType.RAIL: gp.add_group('Phys_FloorRails') gp.add_group('Sound_HitID_02') gp.add_group('Sound_RollID_02') case BallanceObjectType.WOOD: gp.add_group('Phys_Floors') gp.add_group('Sound_HitID_03') gp.add_group('Sound_RollID_03') case BallanceObjectType.STOPPER: gp.add_group('Phys_FloorStopper') case BallanceObjectType.COMPONENT: # group into component type gp.add_group(info.mComponentType) # group to sector if info.mSector == 9: gp.add_group('Sector_9') else: gp.add_group(f'Sector_{info.mSector:0>2d}') case _: if reporter is not None: reporter.add_error('No matched info.') return False return True class YYCToolchainConvention(): @staticmethod def parse_from_name(name: str, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None: # check component first regex_result = VirtoolsGroupConvention.cRegexComponent.match(name) # use vt one because they are same if regex_result is not None: return BallanceObjectInfo.create_from_component( regex_result.group(1), int(regex_result.group(2)) ) # check PC PR elements regex_result = VirtoolsGroupConvention.cRegexPC.match(name) # use vt one because they are same if regex_result is not None: return BallanceObjectInfo.create_from_checkpoint( int(regex_result.group(1)) ) regex_result = VirtoolsGroupConvention.cRegexPR.match(name) # use vt one because they are same if regex_result is not None: return BallanceObjectInfo.create_from_resetpoint( int(regex_result.group(1)) ) # check other unique elements if name == "PS_FourFlames_01": return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_START) if name == "PE_Balloon_01": return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_END) # process floors if name.startswith("A_Floor"): return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR) if name.startswith("A_Rail"): return BallanceObjectInfo.create_from_others(BallanceObjectType.RAIL) if name.startswith("A_Wood"): return BallanceObjectInfo.create_from_others(BallanceObjectType.WOOD) if name.startswith("A_Stopper"): return BallanceObjectInfo.create_from_others(BallanceObjectType.STOPPER) # process others if name.startswith("DepthCubes"): return BallanceObjectInfo.create_from_others(BallanceObjectType.DEPTH_CUBE) if name.startswith("D_"): return BallanceObjectInfo.create_from_others(BallanceObjectType.DECORATION) if name == 'SkyLayer': return BallanceObjectInfo.create_from_others(BallanceObjectType.SKYLAYER) if reporter is not None: reporter.add_error("Name match lost.") return None @staticmethod def parse_from_object(obj: bpy.types.Object, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None: return YYCToolchainConvention.parse_from_name(obj.name, reporter) @staticmethod def set_to_name(info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> str | None: match(info.mBasicType): case BallanceObjectType.DECORATION: return 'D_' case BallanceObjectType.SKYLAYER: return 'SkyLayer' case BallanceObjectType.LEVEL_START: return 'PS_FourFlames_01' case BallanceObjectType.LEVEL_END: return 'PE_Balloon_01' case BallanceObjectType.CHECKPOINT: return f'PR_Resetpoint_{info.mSector:0>2d}' case BallanceObjectType.RESETPOINT: return f'PC_TwoFlames_{info.mSector:0>2d}' case BallanceObjectType.DEPTH_CUBE: return 'DepthCubes_' case BallanceObjectType.FLOOR: return 'A_Floor_' case BallanceObjectType.RAIL: return 'A_Wood_' case BallanceObjectType.WOOD: return 'A_Rail_' case BallanceObjectType.STOPPER: return 'A_Stopper_' case BallanceObjectType.COMPONENT: return '{}_{:0>2d}_'.format( info.mComponentType, info.mSector) case _: if reporter is not None: reporter.add_error('No matched info.') return None @staticmethod def set_to_object(obj: bpy.types.Object, info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> bool: expect_name: str | None = YYCToolchainConvention.set_to_name(info, reporter) if expect_name is None: return False obj.name = expect_name return True class ImengyuConvention(): cRegexComponent: typing.ClassVar[re.Pattern] = re.compile('^(' + '|'.join(_g_BlcNormalComponents) + '):[^:]*:([1-9]|[1-9][0-9])$') cRegexPC: typing.ClassVar[re.Pattern] = re.compile('^PC_CheckPoint:([0-9]+)$') cRegexPR: typing.ClassVar[re.Pattern] = re.compile('^PR_ResetPoint:([0-9]+)$') @staticmethod def parse_from_name(name: str, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None: # check component first regex_result = ImengyuConvention.cRegexComponent.match(name) if regex_result is not None: return BallanceObjectInfo.create_from_component( regex_result.group(1), int(regex_result.group(2)) ) # check PC PR elements regex_result = ImengyuConvention.cRegexPC.match(name) if regex_result is not None: return BallanceObjectInfo.create_from_checkpoint( int(regex_result.group(1)) ) regex_result = ImengyuConvention.cRegexPR.match(name) if regex_result is not None: return BallanceObjectInfo.create_from_resetpoint( int(regex_result.group(1)) ) # check other unique elements if name == "PS_LevelStart": return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_START) if name == "PE_LevelEnd": return BallanceObjectInfo.create_from_others(BallanceObjectType.LEVEL_END) # process floors if name.startswith("S_Floors"): return BallanceObjectInfo.create_from_others(BallanceObjectType.FLOOR) if name.startswith("S_FloorRails"): return BallanceObjectInfo.create_from_others(BallanceObjectType.RAIL) if name.startswith("S_FloorWoods"): return BallanceObjectInfo.create_from_others(BallanceObjectType.WOOD) if name.startswith("S_FloorStopper"): return BallanceObjectInfo.create_from_others(BallanceObjectType.STOPPER) # process others if name.startswith("DepthTestCubes"): return BallanceObjectInfo.create_from_others(BallanceObjectType.DEPTH_CUBE) if name.startswith("O_"): return BallanceObjectInfo.create_from_others(BallanceObjectType.DECORATION) if name == 'SkyLayer': return BallanceObjectInfo.create_from_others(BallanceObjectType.SKYLAYER) if reporter is not None: reporter.add_error("Name match lost.") return None @staticmethod def parse_from_object(obj: bpy.types.Object, reporter: RenameErrorReporter | None) -> BallanceObjectInfo | None: return ImengyuConvention.parse_from_name(obj.name, reporter) @staticmethod def set_to_name(info: BallanceObjectInfo, oldname: str | None, reporter: RenameErrorReporter | None) -> str | None: match(info.mBasicType): case BallanceObjectType.DECORATION: return 'O_' case BallanceObjectType.SKYLAYER: return 'SkyLayer' case BallanceObjectType.LEVEL_START: return 'PS_LevelStart' case BallanceObjectType.LEVEL_END: return 'PE_LevelEnd' case BallanceObjectType.CHECKPOINT: return f'PR_ResetPoint:{info.mSector:d}' case BallanceObjectType.RESETPOINT: return f'PC_CheckPoint:{info.mSector:d}' case BallanceObjectType.DEPTH_CUBE: return 'DepthTestCubes' case BallanceObjectType.FLOOR: return 'S_Floors' case BallanceObjectType.RAIL: return 'S_FloorWoods' case BallanceObjectType.WOOD: return 'S_FloorRails' case BallanceObjectType.STOPPER: return 'S_FloorStopper' case BallanceObjectType.COMPONENT: return '{}:{}:{:d}'.format( info.mComponentType, oldname.replace(':', '_') if oldname is not None else '', info.mSector ) case _: if reporter is not None: reporter.add_error('No matched info.') return None @staticmethod def set_to_object(obj: bpy.types.Object, info: BallanceObjectInfo, reporter: RenameErrorReporter | None) -> bool: expect_name: str | None = ImengyuConvention.set_to_name(info, obj.name, reporter) if expect_name is None: return False obj.name = expect_name return True #endregion