from enum import Enum, unique import glob import itertools import json import locale import os import platform import random import re import shlex import shutil import subprocess from typing import Any, Dict, Iterable, List, Optional, Tuple, Union import webbrowser import numpy as np import bpy import lnx.lib.lnxpack from lnx.lib.lz4 import LZ4 import lnx.log as log import lnx.make_state as state import lnx.props_renderpath if lnx.is_reload(__name__): lnx.lib.lnxpack = lnx.reload_module(lnx.lib.lnxpack) lnx.lib.lz4 = lnx.reload_module(lnx.lib.lz4) from lnx.lib.lz4 import LZ4 log = lnx.reload_module(log) state = lnx.reload_module(state) lnx.props_renderpath = lnx.reload_module(lnx.props_renderpath) else: lnx.enable_reload(__name__) class NumpyEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, np.ndarray): return obj.tolist() return json.JSONEncoder.default(self, obj) class WorkingDir: """Context manager for safely changing the current working directory.""" def __init__(self, cwd: str): self.cwd = cwd self.prev_cwd = os.getcwd() def __enter__(self): os.chdir(self.cwd) def __exit__(self, exc_type, exc_val, exc_tb): os.chdir(self.prev_cwd) def write_lnx(filepath, output): if filepath.endswith('.lz4'): with open(filepath, 'wb') as f: packed = lnx.lib.lnxpack.packb(output) # Prepend packed data size for decoding. Haxe can't unpack # an unsigned int64 so we use a signed int64 here f.write(np.int64(LZ4.encode_bound(len(packed))).tobytes()) f.write(LZ4.encode(packed)) else: if bpy.data.worlds['Lnx'].lnx_minimize: with open(filepath, 'wb') as f: f.write(lnx.lib.lnxpack.packb(output)) else: filepath_json = filepath.split('.lnx')[0] + '.json' with open(filepath_json, 'w') as f: f.write(json.dumps(output, sort_keys=True, indent=4, cls=NumpyEncoder)) def unpack_image(image, path, file_format='JPEG'): print('Leenkx Info: Unpacking to ' + path) image.filepath_raw = path image.file_format = file_format image.save() def convert_image(image, path, file_format='JPEG'): # Convert image to compatible format print('Leenkx Info: Converting to ' + path) ren = bpy.context.scene.render orig_quality = ren.image_settings.quality orig_file_format = ren.image_settings.file_format orig_color_mode = ren.image_settings.color_mode ren.image_settings.quality = get_texture_quality_percentage() ren.image_settings.file_format = file_format if file_format == 'PNG': ren.image_settings.color_mode = 'RGBA' orig_image_colorspace = image.colorspace_settings.name image.colorspace_settings.name = 'Non-Color' image.save_render(path, scene=bpy.context.scene) image.colorspace_settings.name = orig_image_colorspace ren.image_settings.quality = orig_quality ren.image_settings.file_format = orig_file_format ren.image_settings.color_mode = orig_color_mode def get_random_color_rgb() -> list[float]: """Return a random RGB color with values in range [0, 1].""" return [random.random(), random.random(), random.random()] def is_livepatch_enabled(): """Returns whether live patch is enabled and can be used.""" wrd = bpy.data.worlds['Lnx'] # If the game is published, the target is krom-[OS] and not krom, # so there is no live patch when publishing return wrd.lnx_live_patch and state.target == 'krom' def blend_name(): return bpy.path.basename(bpy.context.blend_data.filepath).rsplit('.', 1)[0] def build_dir(): return 'build_' + safestr(blend_name()) def get_fp() -> str: wrd = bpy.data.worlds['Lnx'] if wrd.lnx_project_root != '': return bpy.path.abspath(wrd.lnx_project_root) else: s = None if use_local_sdk and bpy.data.filepath == '': s = os.getcwd() else: s = bpy.data.filepath.split(os.path.sep) s.pop() s = os.path.sep.join(s) if get_os_is_windows() and len(s) == 2 and s[1] == ':': # If the project is located at a drive root (C:/ for example), # then s = "C:". If joined later with another path, no path # separator is added by default because C:some_path is valid # Windows path syntax (some_path is then relative to the CWD on the # C drive). We prevent this by manually adding the path separator # in these cases. Please refer to the Python doc of os.path.join() # for more details. s += os.path.sep return s def get_fp_build(): return os.path.join(get_fp(), build_dir()) def to_absolute_path(path: str, from_library: Optional[bpy.types.Library] = None) -> str: """Convert the given absolute or relative path into an absolute path. - If `from_library` is not set (default), a given relative path will be interpreted as relative to the project directory. - If `from_library` is set, a given relative path will be interpreted as relative to the filepath of the specified library. """ return os.path.normpath(bpy.path.abspath(path, start=get_fp(), library=from_library)) def get_os() -> str: s = platform.system() if s == 'Windows': return 'win' elif s == 'Darwin': return 'mac' else: return 'linux' def get_os_is_windows() -> bool: return get_os() == 'win' def get_os_is_windows_64() -> bool: if platform.machine().endswith('64'): return True # Checks if Python (32 bit) is running on Windows (64 bit) if 'PROCESSOR_ARCHITEW6432' in os.environ: return True if os.environ['PROCESSOR_ARCHITECTURE'].endswith('64'): return True if 'PROGRAMFILES(X86)' in os.environ: if os.environ['PROGRAMW6432'] is not None: return True else: return False def get_gapi(): wrd = bpy.data.worlds['Lnx'] if state.is_export: item = wrd.lnx_exporterlist[wrd.lnx_exporterlist_index] return getattr(item, target_to_gapi(item.lnx_project_target)) if wrd.lnx_runtime == 'Browser': return 'webgl' return 'direct3d11' if get_os() == 'win' else 'opengl' def is_gapi_gl_es() -> bool: """Return whether the currently targeted graphics API is using OpenGL ES.""" wrd = bpy.data.worlds['Lnx'] if state.is_export: item_exporter = wrd.lnx_exporterlist[wrd.lnx_exporterlist_index] # See Khamake's ShaderCompiler.findType() and krafix::Target.es in krafix.cpp ("target.es") if state.target == 'android-hl': return item_exporter.lnx_gapi_android == 'opengl' if state.target == 'ios-hl': return item_exporter.lnx_gapi_ios == 'opengl' elif state.target == 'html5': return True return False else: return wrd.lnx_runtime == 'Browser' def get_rp() -> lnx.props_renderpath.LnxRPListItem: wrd = bpy.data.worlds['Lnx'] if not state.is_export and wrd.lnx_play_renderpath != '': return lnx.props_renderpath.LnxRPListItem.get_by_name(wrd.lnx_play_renderpath) else: return wrd.lnx_rplist[wrd.lnx_rplist_index] # Passed by load_post handler when lnxsdk is found in project folder use_local_sdk = False def get_sdk_path(): addon_prefs = get_lnx_preferences() if use_local_sdk: return os.path.normpath(get_fp() + '/lnxsdk/') else: return os.path.normpath(addon_prefs.sdk_path) def get_last_commit(): p = get_sdk_path() + 'leenkx/.git/refs/heads/main' try: file = open(p, 'r') commit = file.readline() except: commit = '' return commit def get_lnx_preferences() -> bpy.types.AddonPreferences: preferences = bpy.context.preferences return preferences.addons["leenkx"].preferences def get_ide_bin(): addon_prefs = get_lnx_preferences() return '' if not hasattr(addon_prefs, 'ide_bin') else addon_prefs.ide_bin def get_ffmpeg_path(): path = get_lnx_preferences().ffmpeg_path if path == "": path = shutil.which("ffmpeg") return path def get_renderdoc_path(): p = get_lnx_preferences().renderdoc_path if p == '' and get_os() == 'win': pdefault = 'C:\\Program Files\\RenderDoc\\qrenderdoc.exe' if os.path.exists(pdefault): p = pdefault return p def get_code_editor(): addon_prefs = get_lnx_preferences() return 'kodestudio' if not hasattr(addon_prefs, 'code_editor') else addon_prefs.code_editor def get_ui_scale(): addon_prefs = get_lnx_preferences() return 1.0 if not hasattr(addon_prefs, 'ui_scale') else addon_prefs.ui_scale def get_khamake_threads() -> int: addon_prefs = get_lnx_preferences() if hasattr(addon_prefs, 'khamake_threads_use_auto') and addon_prefs.khamake_threads_use_auto: return -1 return 1 if not hasattr(addon_prefs, 'khamake_threads') else addon_prefs.khamake_threads def get_compilation_server(): addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'compilation_server') else addon_prefs.compilation_server def get_save_on_build(): addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'save_on_build') else addon_prefs.save_on_build def get_debug_console_auto(): addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'debug_console_auto') else addon_prefs.debug_console_auto def get_debug_console_visible_sc(): addon_prefs = get_lnx_preferences() return 192 if not hasattr(addon_prefs, 'debug_console_visible_sc') else addon_prefs.debug_console_visible_sc def get_debug_console_scale_in_sc(): addon_prefs = get_lnx_preferences() return 219 if not hasattr(addon_prefs, 'debug_console_scale_in_sc') else addon_prefs.debug_console_scale_in_sc def get_debug_console_scale_out_sc(): addon_prefs = get_lnx_preferences() return 221 if not hasattr(addon_prefs, 'debug_console_scale_out_sc') else addon_prefs.debug_console_scale_out_sc def get_viewport_controls(): addon_prefs = get_lnx_preferences() return 'qwerty' if not hasattr(addon_prefs, 'viewport_controls') else addon_prefs.viewport_controls def get_legacy_shaders(): addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'legacy_shaders') else addon_prefs.legacy_shaders def get_relative_paths(): """Whether to convert absolute paths to relative""" addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'relative_paths') else addon_prefs.relative_paths def get_pref_or_default(prop_name: str, default: Any) -> Any: """Return the preference setting for prop_name, or the value given as default if the property does not exist.""" addon_prefs = get_lnx_preferences() return getattr(addon_prefs, prop_name, default) def get_node_path(): if get_os() == 'win': return get_sdk_path() + '/nodejs/node.exe' elif get_os() == 'mac': return get_sdk_path() + '/nodejs/node-osx' else: return get_sdk_path() + '/nodejs/node-linux64' def get_kha_path(): if os.path.exists('Kha'): return 'Kha' return get_sdk_path() + '/Kha' def get_haxe_path(): if get_os() == 'win': return get_kha_path() + '/Tools/windows_x64/haxe.exe' elif get_os() == 'mac': return get_kha_path() + '/Tools/macos/haxe' else: return get_kha_path() + '/Tools/linux_x64/haxe' def get_khamake_path(): return get_kha_path() + '/make' def krom_paths(): sdk_path = get_sdk_path() if lnx.utils.get_os() == 'win': krom_location = sdk_path + '/Krom' krom_path = krom_location + '/Krom.exe' elif lnx.utils.get_os() == 'mac': krom_location = sdk_path + '/Krom/Krom.app/Contents/MacOS' krom_path = krom_location + '/Krom' else: krom_location = sdk_path + '/Krom' krom_path = krom_location + '/Krom' return krom_location, krom_path def fetch_bundled_script_names(): wrd = bpy.data.worlds['Lnx'] wrd.lnx_bundled_scripts_list.clear() with WorkingDir(get_sdk_path() + '/leenkx/Sources/leenkx/trait'): for file in glob.glob('*.hx'): wrd.lnx_bundled_scripts_list.add().name = file.rsplit('.', 1)[0] script_props = {} script_props_defaults = {} script_warnings: Dict[str, List[Tuple[str, str]]] = {} # Script name -> List of (identifier, warning message) # See https://regex101.com/r/bbrCzN/8 RX_MODIFIERS = r'(?P(?:public\s+|private\s+|static\s+|inline\s+|final\s+)*)?' # Optional modifiers RX_IDENTIFIER = r'(?P[_$a-z]+[_a-z0-9]*)' # Variable name, follow Haxe rules RX_TYPE = r'(?:\s*:\s*(?P[_a-z]+[\._a-z0-9]*))?' # Optional type annotation RX_VALUE = r'(?:\s*=\s*(?P(?:\".*\")|(?:[^;]+)|))?' # Optional default value PROP_REGEX_RAW = fr'@prop\s+{RX_MODIFIERS}(?Pvar|final)\s+{RX_IDENTIFIER}{RX_TYPE}{RX_VALUE};' PROP_REGEX = re.compile(PROP_REGEX_RAW, re.IGNORECASE) def fetch_script_props(filename: str): """Parses @prop declarations from the given Haxe script.""" with open(filename, 'r', encoding='utf-8') as sourcefile: source = sourcefile.read() if source == '': return name = filename.rsplit('.', 1)[0] # Convert the name into a package path relative to the "Sources" dir if 'Sources' in name: name = name[name.index('Sources') + 8:] if '/' in name: name = name.replace('/', '.') if '\\' in filename: name = name.replace('\\', '.') script_props[name] = [] script_props_defaults[name] = [] script_warnings[name] = [] for match in re.finditer(PROP_REGEX, source): p_modifiers: Optional[str] = match.group('modifiers') p_identifier: str = match.group('identifier') p_type: Optional[str] = match.group('type') p_default_val: Optional[str] = match.group('value') if p_modifiers is not None: if 'static' in p_modifiers: script_warnings[name].append((p_identifier, '`static` modifier might cause unwanted behaviour!')) if 'inline' in p_modifiers: script_warnings[name].append((p_identifier, '`inline` modifier is not supported!')) continue if 'final' in p_modifiers or match.group('attr_type') == 'final': script_warnings[name].append((p_identifier, '`final` properties are not supported!')) continue # Property type is annotated if p_type is not None: if p_type.startswith("iron.object."): p_type = p_type[12:] elif p_type.startswith("iron.math."): p_type = p_type[10:] type_default_val = get_type_default_value(p_type) if type_default_val is None: script_warnings[name].append((p_identifier, f'unsupported type `{p_type}`!')) continue # Default value exists if p_default_val is not None: # Remove string quotes p_default_val = p_default_val.replace('\'', '').replace('"', '') else: p_default_val = type_default_val # Default value is given instead, try to infer the properties type from it elif p_default_val is not None: p_type = get_prop_type_from_value(p_default_val) # Type is not recognized if p_type is None: script_warnings[name].append((p_identifier, 'could not infer property type from given value!')) continue if p_type == "String": p_default_val = p_default_val.replace('\'', '').replace('"', '') else: script_warnings[name].append((p_identifier, 'missing type or default value!')) continue # Register prop prop = (p_identifier, p_type) script_props[name].append(prop) script_props_defaults[name].append(p_default_val) def get_prop_type_from_value(value: str): """ Returns the property type based on its representation in the code. If the type is not supported, `None` is returned. """ # Maybe ast.literal_eval() is better here? try: int(value) return "Int" except ValueError: try: float(value) return "Float" except ValueError: # "" is required, " alone will not work if len(value) > 1 and value.startswith(("\"", "'")) and value.endswith(("\"", "'")): return "String" if value in ("true", "false"): return "Bool" if value.startswith("new "): value = value.split()[1].split("(")[0] if value.startswith("Vec"): return value if value.startswith("iron.math.Vec"): return value[10:] return None def get_type_default_value(prop_type: str): """ Returns the default value of the given Haxe type. If the type is not supported, `None` is returned: """ if prop_type == "Int": return 0 if prop_type == "Float": return 0.0 if prop_type == "String" or prop_type in ( "Object", "CameraObject", "LightObject", "MeshObject", "SpeakerObject"): return "" if prop_type == "Bool": return False if prop_type == "Vec2": return [0.0, 0.0] if prop_type == "Vec3": return [0.0, 0.0, 0.0] if prop_type == "Vec4": return [0.0, 0.0, 0.0, 0.0] return None def fetch_script_names(): if bpy.data.filepath == "": return wrd = bpy.data.worlds['Lnx'] # Sources wrd.lnx_scripts_list.clear() sources_path = os.path.join(get_fp(), 'Sources', safestr(wrd.lnx_project_package)) if os.path.isdir(sources_path): with WorkingDir(sources_path): # Glob supports recursive search since python 3.5 so it should cover both blender 2.79 and 2.8 integrated python for file in glob.glob('**/*.hx', recursive=True): mod = file.rsplit('.', 1)[0] mod = mod.replace('\\', '/') mod_parts = mod.rsplit('/') if re.match('^[A-Z][A-Za-z0-9_]*$', mod_parts[-1]): wrd.lnx_scripts_list.add().name = mod.replace('/', '.') fetch_script_props(file) # Canvas wrd.lnx_canvas_list.clear() canvas_path = get_fp() + '/Bundled/canvas' if os.path.isdir(canvas_path): with WorkingDir(canvas_path): for file in glob.glob('*.json'): if file == "_themes.json": continue wrd.lnx_canvas_list.add().name = file.rsplit('.', 1)[0] def fetch_wasm_names(): if bpy.data.filepath == "": return wrd = bpy.data.worlds['Lnx'] # WASM modules wrd.lnx_wasm_list.clear() sources_path = get_fp() + '/Bundled' if os.path.isdir(sources_path): with WorkingDir(sources_path): for file in glob.glob('*.wasm'): name = file.rsplit('.', 1)[0] wrd.lnx_wasm_list.add().name = name def fetch_trait_props(): for o in bpy.data.objects: if o.override_library is None: # We can't update the list of trait properties for linked # objects because Blender doesn't allow to remove items from # overridden lists fetch_prop(o) for s in bpy.data.scenes: fetch_prop(s) def fetch_prop(o: Union[bpy.types.Object, bpy.types.Scene]): for item in o.lnx_traitlist: if item.type_prop == 'Bundled Script': name = 'leenkx.trait.' + item.name else: name = item.name if name not in script_props: continue props = script_props[name] defaults = script_props_defaults[name] warnings = script_warnings[name] # Remove old props for i in range(len(item.lnx_traitpropslist) - 1, -1, -1): ip = item.lnx_traitpropslist[i] if ip.name not in [p[0] for p in props]: item.lnx_traitpropslist.remove(i) # Add new props for index, p in enumerate(props): found_prop = False for i_prop in item.lnx_traitpropslist: if i_prop.name == p[0]: if i_prop.type == p[1]: found_prop = i_prop else: item.lnx_traitpropslist.remove(item.lnx_traitpropslist.find(i_prop.name)) break # Not in list if not found_prop: prop = item.lnx_traitpropslist.add() prop.name = p[0] prop.type = p[1] prop.set_value(defaults[index]) if found_prop: prop = item.lnx_traitpropslist[found_prop.name] # Default value added and current value is blank (no override) if (found_prop.get_value() is None or found_prop.get_value() == "") and defaults[index]: prop.set_value(defaults[index]) # Type has changed, update displayed name if len(found_prop.name) == 1 or (len(found_prop.name) > 1 and found_prop.name[1] != p[1]): prop.name = p[0] prop.type = p[1] item.lnx_traitpropswarnings.clear() for warning in warnings: entry = item.lnx_traitpropswarnings.add() entry.propName = warning[0] entry.warning = warning[1] def fetch_bundled_trait_props(): # Bundled script props for o in bpy.data.objects: for t in o.lnx_traitlist: if t.type_prop == 'Bundled Script': file_path = get_sdk_path() + '/leenkx/Sources/leenkx/trait/' + t.name + '.hx' if os.path.exists(file_path): fetch_script_props(file_path) fetch_prop(o) def update_trait_collections(): for col in bpy.data.collections: if col.name.startswith('Trait|'): bpy.data.collections.remove(col) for o in bpy.data.objects: for t in o.lnx_traitlist: if 'Trait|' + t.name not in bpy.data.collections: col = bpy.data.collections.new('Trait|' + t.name) else: col = bpy.data.collections['Trait|' + t.name] col.objects.link(o) def to_hex(val): return '#%02x%02x%02x%02x' % (int(val[3] * 255), int(val[0] * 255), int(val[1] * 255), int(val[2] * 255)) def color_to_int(val) -> int: # Clamp values, otherwise the return value might not fit in 32 bit # (and later cause problems, e.g. in the .arm file reader) val = [max(0.0, min(v, 1.0)) for v in val] return (int(val[3] * 255) << 24) + (int(val[0] * 255) << 16) + (int(val[1] * 255) << 8) + int(val[2] * 255) def unique_name_in_lists(item_lists: Iterable[list], name_attr: str, wanted_name: str, ignore_item: Optional[Any] = None) -> str: """Creates a unique name that no item in the given lists already has. The format follows Blender's behaviour when handling duplicate object names. @param item_lists An iterable of item lists (any type). @param name_attr The attribute of the items that holds the name. @param wanted_name The name that should be preferably returned, if no name collision occurs. @param ignore_item (Optional) Ignore this item in the list when comparing names. """ def _has_collision(name: str) -> bool: for item in itertools.chain(*item_lists): if item == ignore_item: continue if getattr(item, name_attr) == name: return True return False # Check this once at the beginning to make sure the user can use # a wanted name like "XY.001" if they want, even if "XY" alone does # not collide if not _has_collision(wanted_name): return wanted_name # Get base name without numeric suffix base_name = wanted_name dot_pos = base_name.rfind('.') if dot_pos != -1: if base_name[dot_pos + 1:].isdecimal(): base_name = base_name[:dot_pos] num_collisions = 0 out_name = base_name while _has_collision(out_name): num_collisions += 1 out_name = f'{base_name}.{num_collisions:03d}' return out_name def merge_into_collection(col_src, col_dst, clear_dst=True): """Merges the items of the `col_src` collection property into the `col_dst` collection property. If `clear_dst` is true, the destination collection is cleared before merging. Otherwise, new items are added on top of the existing items in `col_dst`. There is no check for duplicates. """ if clear_dst: col_dst.clear() for item_src in col_src: item_dst = col_dst.add() # collect names of writable properties prop_names = [p.identifier for p in item_src.bl_rna.properties if not p.is_readonly] # copy those properties for prop_name in prop_names: setattr(item_dst, prop_name, getattr(item_src, prop_name)) def safesrc(s): s = safestr(s).replace('.', '_').replace('-', '_').replace(' ', '') if s[0].isdigit(): s = '_' + s return s def safestr(s: str) -> str: """Outputs a string where special characters have been replaced with '_', which can be safely used in file and path names.""" for c in r'''[]/\;,><&*:ยง$%=+@!#^()|?^'"''': s = s.replace(c, '_') return ''.join([i if ord(i) < 128 else '_' for i in s]) def get_haxe_json_string(d: dict) -> str: s = str(d) s = s.replace('True', 'true') s = s.replace('False', 'false') s = s.replace("'", '"') return s def asset_name(bdata): if bdata == None: return None s = bdata.name # Append library name if linked if bdata.library is not None: s += '_' + bdata.library.name return s def asset_path(s): """Remove leading '//'""" return s[2:] if s[:2] == '//' else s def extract_filename(s): return os.path.basename(asset_path(s)) def get_render_resolution(scene): render = scene.render scale = render.resolution_percentage / 100 return int(render.resolution_x * scale), int(render.resolution_y * scale) def get_texture_quality_percentage() -> int: return int(bpy.data.worlds['Lnx'].lnx_texture_quality * 100) def get_project_scene_name(): return get_active_scene().name def get_active_scene() -> bpy.types.Scene: wrd = bpy.data.worlds['Lnx'] if not state.is_export: if wrd.lnx_play_scene is None: return bpy.context.scene return wrd.lnx_play_scene else: item = wrd.lnx_exporterlist[wrd.lnx_exporterlist_index] return item.lnx_project_scene def logic_editor_space(context_screen=None): if context_screen == None: context_screen = bpy.context.screen if context_screen != None: areas = context_screen.areas for area in areas: for space in area.spaces: if space.type == 'NODE_EDITOR': if space.node_tree != None and space.node_tree.bl_idname == 'LnxLogicTreeType': return space return None def voxel_support(): # macos does not support opengl 4.5, needs metal return state.target != 'html5' and get_os() != 'mac' def get_cascade_size(rpdat): cascade_size = int(rpdat.rp_shadowmap_cascade) # Clamp to 4096 per cascade if int(rpdat.rp_shadowmap_cascades) > 1 and cascade_size > 4096: cascade_size = 4096 return cascade_size def check_blender_version(op: bpy.types.Operator): """Check whether the Blender version is supported by Leenkx, if not, report in UI. """ if bpy.app.version[0] != 3 or bpy.app.version[1] != 6: op.report({'INFO'}, 'INFO: For Leenkx to work correctly, use a Blender LTS version such as 4.2 | 3.6 | 3.3') def check_saved(self): if bpy.data.filepath == "": msg = "Save blend file first" self.report({"ERROR"}, msg) if self is not None else log.warn(msg) return False return True def check_path(s): for c in r'[];><&*%=+@!#^()|?^': if c in s: return False for c in s: if ord(c) > 127: return False return True def check_sdkpath(self): s = get_sdk_path() if not check_path(s): msg = f"SDK path '{s}' contains special characters. Please move SDK to different path for now." self.report({"ERROR"}, msg) if self is not None else log.warn(msg) return False else: return True def check_projectpath(self): s = get_fp() if not check_path(s): msg = f"Project path '{s}' contains special characters, build process may fail." self.report({"ERROR"}, msg) if self is not None else log.warn(msg) return False else: return True def disp_enabled(target): rpdat = get_rp() if rpdat.lnx_rp_displacement == 'Tessellation': return target == 'krom' or target == 'native' return rpdat.lnx_rp_displacement != 'Off' def is_object_animation_enabled(bobject): # Checks if animation is present and enabled if bobject.lnx_animation_enabled == False or bobject.type == 'BONE' or bobject.type == 'ARMATURE': return False if bobject.animation_data and bobject.animation_data.action: return True return False def is_bone_animation_enabled(bobject): # Checks if animation is present and enabled for parented armature if bobject.parent and bobject.parent.type == 'ARMATURE': if bobject.parent.lnx_animation_enabled == False: return False # Check for present actions adata = bobject.parent.animation_data has_actions = adata != None and adata.action != None if not has_actions and adata != None: if hasattr(adata, 'nla_tracks') and adata.nla_tracks != None: for track in adata.nla_tracks: if track.strips == None: continue for strip in track.strips: if strip.action == None: continue has_actions = True break if has_actions: break if adata != None and has_actions: return True return False def export_bone_data(bobject: bpy.types.Object) -> bool: """Returns whether the bone data of the given object should be exported.""" return bobject.find_armature() and is_bone_animation_enabled(bobject) and get_rp().lnx_skin == 'On' def export_morph_targets(bobject: bpy.types.Object) -> bool: if get_rp().lnx_morph_target != 'On': return False if not hasattr(bobject.data, 'shape_keys'): return False shape_keys = bobject.data.shape_keys if not shape_keys: return False if len(shape_keys.key_blocks) < 2: return False for shape_key in shape_keys.key_blocks[1:]: if(not shape_key.mute): return True return False def export_vcols(bobject: bpy.types.Object) -> bool: for material in bobject.data.materials: if material is not None and material.export_vcols: return True return False def open_editor(hx_path=None): ide_bin = get_ide_bin() if hx_path is None: hx_path = lnx.utils.get_fp() if get_code_editor() == 'default': # Get editor environment variables # https://unix.stackexchange.com/q/4859 env_v_editor = os.environ.get('VISUAL') env_editor = os.environ.get('EDITOR') if env_v_editor is not None: ide_bin = env_v_editor elif env_editor is not None: ide_bin = env_editor # No environment variables set -> Let the system decide how to # open the file else: webbrowser.open('file://' + hx_path) return if os.path.exists(ide_bin): args = [ide_bin, lnx.utils.get_fp()] # Sublime Text if get_code_editor() == 'sublime': project_name = lnx.utils.safestr(bpy.data.worlds['Lnx'].lnx_project_name) subl_project_path = lnx.utils.get_fp() + f'/{project_name}.sublime-project' if not os.path.exists(subl_project_path): generate_sublime_project(subl_project_path) args += ['--project', subl_project_path] args.append('--add') args.append(hx_path) if lnx.utils.get_os() == 'mac': argstr = "" for arg in args: if not (arg.startswith('-') or arg.startswith('--')): argstr += '"' + arg + '"' argstr += ' ' subprocess.Popen(argstr[:-1], shell=True) else: subprocess.Popen(args) else: raise FileNotFoundError(f'Code editor executable not found: {ide_bin}. You can change the path in the Leenkx preferences.') def open_folder(folder_path: str): if lnx.utils.get_os() == 'win': subprocess.run(['explorer', folder_path]) elif lnx.utils.get_os() == 'mac': subprocess.run(['open', folder_path]) elif lnx.utils.get_os() == 'linux': subprocess.run(['xdg-open', folder_path]) else: webbrowser.open('file://' + folder_path) def generate_sublime_project(subl_project_path): """Generates a [project_name].sublime-project file.""" print('Generating Sublime Text project file') project_data = { "folders": [ { "path": ".", "file_exclude_patterns": ["*.blend*", "*.lnx"] }, ], } with open(subl_project_path, 'w', encoding='utf-8') as project_file: json.dump(project_data, project_file, ensure_ascii=False, indent=4) def def_strings_to_array(strdefs): defs = strdefs.split('_') defs = defs[1:] defs = ['_' + d for d in defs] # Restore _ return defs def get_kha_target(target_name): # TODO: remove if target_name == 'macos-hl': return 'osx-hl' elif target_name.startswith('krom'): # krom-windows return 'krom' elif target_name == 'custom': return '' return target_name def target_to_gapi(lnx_project_target: str) -> str: # TODO: align target names if lnx_project_target == 'krom': return 'lnx_gapi_' + lnx.utils.get_os() elif lnx_project_target == 'krom-windows': return 'lnx_gapi_win' elif lnx_project_target == 'windows-hl': return 'lnx_gapi_win' elif lnx_project_target == 'krom-linux': return 'lnx_gapi_linux' elif lnx_project_target == 'linux-hl': return 'lnx_gapi_linux' elif lnx_project_target == 'krom-macos': return 'lnx_gapi_mac' elif lnx_project_target == 'macos-hl': return 'lnx_gapi_mac' elif lnx_project_target == 'android-hl': return 'lnx_gapi_android' elif lnx_project_target == 'ios-hl': return 'lnx_gapi_ios' elif lnx_project_target == 'node': return 'lnx_gapi_html5' else: # html5, custom return 'lnx_gapi_' + lnx_project_target def check_default_props(): wrd = bpy.data.worlds['Lnx'] if len(wrd.lnx_rplist) == 0: wrd.lnx_rplist.add() wrd.lnx_rplist_index = 0 if wrd.lnx_project_name == '': # Take blend file name wrd.lnx_project_name = lnx.utils.blend_name() # Enum Permissions Name class PermissionName(Enum): ACCESS_COARSE_LOCATION = 'ACCESS_COARSE_LOCATION' ACCESS_NETWORK_STATE = 'ACCESS_NETWORK_STATE' ACCESS_FINE_LOCATION = 'ACCESS_FINE_LOCATION' ACCESS_WIFI_STATE = 'ACCESS_WIFI_STATE' BLUETOOTH = 'BLUETOOTH' BLUETOOTH_ADMIN = 'BLUETOOTH_ADMIN' CAMERA = 'CAMERA' EXPAND_STATUS_BAR = 'EXPAND_STATUS_BAR' FOREGROUND_SERVICE = 'FOREGROUND_SERVICE' GET_ACCOUNTS = 'GET_ACCOUNTS' INTERNET = 'INTERNET' READ_EXTERNAL_STORAGE = 'READ_EXTERNAL_STORAGE' VIBRATE = 'VIBRATE' WRITE_EXTERNAL_STORAGE = 'WRITE_EXTERNAL_STORAGE' # Add permission for target android def add_permission_target_android(permission_name_enum): wrd = bpy.data.worlds['Lnx'] check = False for item in wrd.lnx_exporter_android_permission_list: if (item.lnx_android_permissions.upper() == str(permission_name_enum.value).upper()): check = True break if not check: wrd.lnx_exporter_android_permission_list.add() wrd.lnx_exporter_android_permission_list[len(wrd.lnx_exporter_android_permission_list) - 1].lnx_android_permissions = str(permission_name_enum.value).upper() def get_project_android_build_apk(): wrd = bpy.data.worlds['Lnx'] return wrd.lnx_project_android_build_apk def get_android_sdk_root_path(): if os.getenv('ANDROID_SDK_ROOT') == None: addon_prefs = get_lnx_preferences() return '' if not hasattr(addon_prefs, 'android_sdk_root_path') else addon_prefs.android_sdk_root_path else: return os.getenv('ANDROID_SDK_ROOT') def get_android_apk_copy_path(): addon_prefs = get_lnx_preferences() return '' if not hasattr(addon_prefs, 'android_apk_copy_path') else addon_prefs.android_apk_copy_path def get_android_apk_copy_open_directory(): addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'android_apk_copy_open_directory') else addon_prefs.android_apk_copy_open_directory def get_android_emulators_list(): err = '' items = [] path_file = get_android_emulator_file() if len(path_file) > 0: cmd = path_file + " -list-avds" if get_os_is_windows(): process = subprocess.Popen(cmd, stdout=subprocess.PIPE) else: process = subprocess.Popen(shlex.split(cmd), stdout=subprocess.PIPE) while True: output = process.stdout.readline().decode("utf-8") if len(output.strip()) == 0 and process.poll() is not None: break if output: items.append(output.strip()) else: err = 'File "'+ path_file +'" not found.' return items, err def get_android_emulator_path(): return os.path.join(get_android_sdk_root_path(), "emulator") def get_android_emulator_file(): path_file = '' if get_os_is_windows(): path_file = os.path.join(get_android_emulator_path(), "emulator.exe") else: path_file = os.path.join(get_android_emulator_path(), "emulator") # File Exists return '' if not os.path.isfile(path_file) else path_file def get_android_emulator_name(): wrd = bpy.data.worlds['Lnx'] return '' if not len(wrd.lnx_project_android_list_avd.strip()) > 0 else wrd.lnx_project_android_list_avd.strip() def get_android_open_build_apk_directory(): addon_prefs = get_lnx_preferences() return False if not hasattr(addon_prefs, 'android_open_build_apk_directory') else addon_prefs.android_open_build_apk_directory def get_html5_copy_path(): addon_prefs = get_lnx_preferences() return '' if not hasattr(addon_prefs, 'html5_copy_path') else addon_prefs.html5_copy_path def get_link_web_server(): addon_prefs = get_lnx_preferences() return '' if not hasattr(addon_prefs, 'link_web_server') else addon_prefs.link_web_server def get_file_lnx_version_tuple() -> tuple[int]: wrd = bpy.data.worlds['Lnx'] return tuple(map(int, wrd.lnx_version.split('.'))) def type_name_to_type(name: str) -> bpy.types.bpy_struct: """Return the Blender type given by its name, if registered.""" return bpy.types.bpy_struct.bl_rna_get_subclass_py(name) def change_version_project(version: str) -> str: ver = version.strip().replace(' ', '').split('.') v_i = int(ver[len(ver) - 1]) + 1 ver[len(ver) - 1] = str(v_i) version = '' for i in ver: if len(version) > 0: version += '.' version += i return version def cpu_count(*, physical_only=False) -> Optional[int]: """Returns the number of logical (default) or physical CPUs. The result can be `None` if `os.cpu_count()` was not able to get the correct count of logical CPUs. """ if not physical_only: return os.cpu_count() err_reason = '' command = [] _os = get_os() try: if _os == 'win': sysroot = os.environ.get("SYSTEMROOT", default="C:\\WINDOWS") command = [f'{sysroot}\\System32\\wbem\\wmic.exe', 'cpu', 'get', 'NumberOfCores'] result = subprocess.check_output(command) result = result.decode('utf-8').splitlines() result = int(result[2]) if result > 0: return result elif _os == 'linux': command = ["grep -P '^core id' /proc/cpuinfo | sort -u | wc -l"] result = subprocess.check_output(command[0], shell=True) result = result.decode('utf-8').splitlines() result = int(result[0]) if result > 0: return result # macOS else: command = ['sysctl', '-n', 'hw.physicalcpu'] return int(subprocess.check_output(command)) except subprocess.CalledProcessError as e: err_reason = f'Reason: command {command} exited with code {e.returncode}.' except FileNotFoundError as e: err_reason = f'Reason: couldn\'t open file from command {command} ({e.errno=}).' # Last resort even though it can be wrong log.warn("Could not retrieve count of physical CPUs, using logical CPU count instead.\n\t" + err_reason) return os.cpu_count() def register(local_sdk=False): global use_local_sdk use_local_sdk = local_sdk def unregister(): pass