diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4b308e9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +# The zlib/libpng License + +This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software. +Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required. +Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +This notice may not be removed or altered from any source distribution. diff --git a/README.md b/README.md index b4f6e43..2820a73 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,8 @@ -# LNXSDK +# Leenkx - 3D Game Engine +## [Download](https://leenkx.com/engine/download) • [Release Notes](https://leenkx.com/engine/notes) • [Manual](https://github.com/leenkx3d/leenkx/wiki) • [API](https://api.leenkx.com) • [Community](https://leenkx.com/engine/community) +Leenkx is an open-source 3D game engine focused on portability, minimal footprint and performance. The renderer is fully scriptable with deferred and forward paths supported out of the box. + +Leenkx as a Blender add-on provides a full Blender integration, turning it into a complete game development tool and a unified workflow from start to finish. + +![](http://leenkx.com/git/img1.jpg) diff --git a/changes.md b/changes.md new file mode 100644 index 0000000..ceed911 --- /dev/null +++ b/changes.md @@ -0,0 +1,7 @@ +* 2024-12-02: Submodules have been moved in to this repository (previously contained in https://github.com/leenkx3d/lnxsdk). +* 2019-06-30: Return value of `PhysicsWorld.rayCast()` changed, see https://github.com/leenkx3d/leenkx/commit/dfb7609a28cebf3a520e6a25a8563d01c32f2b01. +* 2019-04-06: Use voxelao instead of voxelgi, gi will be reworked into raytracing. +* 2019-01-13: If you are using Leenkx Updater, get Leenkx 0.6beta from https://leenkx.itch.io/leenkx3d first (or clone the sdk from https://github.com/leenkx3d/lnxsdk). +* 2018-08-28: `LampObject` and `LampData` is now `LightObject` and `LightData` +* 2018-06-01: 'Not Equal' has been removed from the Gate logic node. Use 'Equal' and 'False' output socket instead. +* 2017-11-20: Use `leenkx.trait.physics.*` instead of `leenkx.trait.internal.*` to access physics traits. diff --git a/checkstyle.json b/checkstyle.json new file mode 100644 index 0000000..d3e91df --- /dev/null +++ b/checkstyle.json @@ -0,0 +1,253 @@ +{ + "defaultSeverity": "INFO", + "checks": [ + { + "type": "CodeSimilarity" + }, + { + "type": "DefaultComesLast" + }, + { + "type": "DocCommentStyle" + }, + { + "type": "ERegLiteral" + }, + { + "props": { + "tokens": [ + "CLASS_DEF", + "ENUM_DEF", + "ABSTRACT_DEF", + "TYPEDEF_DEF", + "INTERFACE_DEF", + "OBJECT_DECL", + "FUNCTION", + "FOR", + "IF", + "WHILE", + "SWITCH", + "TRY", + "CATCH" + ], + "option": "empty" + }, + "type": "EmptyBlock" + }, + { + "props": { + "max": 1 + }, + "type": "EmptyLines" + }, + { + "props": { + "option": "lowerCase" + }, + "type": "HexadecimalLiteral" + }, + { + "type": "InnerAssignment" + }, + { + "props": { + "modifiers": [ + "MACRO", + "OVERRIDE", + "PUBLIC_PRIVATE", + "STATIC", + "INLINE", + "DYNAMIC" + ] + }, + "type": "ModifierOrder" + }, + { + "type": "MultipleVariableDeclarations" + }, + { + "props": { + "allowSingleLineStatement": true, + "tokens": [ + "FOR", + "IF", + "ELSE_IF", + "WHILE", + "DO_WHILE" + ] + }, + "type": "NeedBraces" + }, + { + "props": { + "assignOpPolicy": "around", + "unaryOpPolicy": "none", + "ternaryOpPolicy": "around", + "arithmeticOpPolicy": "around", + "compareOpPolicy": "around", + "bitwiseOpPolicy": "around", + "boolOpPolicy": "around", + "intervalOpPolicy": "none", + "arrowPolicy": "none", + "oldFunctionTypePolicy": "none", + "newFunctionTypePolicy": "none", + "arrowFunctionPolicy": "around" + }, + "type": "OperatorWhitespace" + }, + { + "props": { + "tokens": [ + "=", + "*", + "/", + "%", + ">", + "<", + ">=", + "<=", + "==", + "!=", + "&", + "|", + "^", + "<<", + ">>", + ">>>", + "+=", + "-=", + "*=", + "/=", + "%=", + "<<=", + ">>=", + ">>>=", + "|=", + "&=", + "^=", + "...", + "=>", + "++", + "--", + "+", + "-", + "&&", + "||" + ], + "option": "eol" + }, + "type": "OperatorWrap" + }, + { + "type": "RedundantModifier" + }, + { + "type": "RedundantAllowMeta" + }, + { + "type": "RedundantAccessMeta" + }, + { + "props": { + "allowEmptyReturn": true, + "enforceReturnType": false + }, + "type": "Return" + }, + { + "props": { + "dotPolicy": "none", + "commaPolicy": "after", + "semicolonPolicy": "after" + }, + "type": "SeparatorWhitespace" + }, + { + "props": { + "tokens": [ + "," + ], + "option": "eol" + }, + "type": "SeparatorWrap" + }, + { + "props": { + "spaceIfCondition": "should", + "spaceAroundBinop": true, + "spaceForLoop": "should", + "ignoreRangeOperator": true, + "spaceWhileLoop": "should", + "spaceCatch": "should", + "spaceSwitchCase": "should", + "noSpaceAroundUnop": true + }, + "type": "Spacing" + }, + { + "props": { + "allowException": true, + "policy": "doubleAndInterpolation" + }, + "type": "StringLiteral" + }, + { + "type": "TrailingWhitespace" + }, + { + "type": "UnusedImport" + }, + { + "type": "UnusedLocalVar" + }, + { + "props": { + "tokens": [ + ",", + ";", + ":" + ] + }, + "type": "WhitespaceAfter" + }, + { + "props": { + "tokens": [ + "=", + "+", + "-", + "*", + "/", + "%", + ">", + "<", + ">=", + "<=", + "==", + "!=", + "&", + "|", + "^", + "&&", + "||", + "<<", + ">>", + ">>>", + "+=", + "-=", + "*=", + "/=", + "%=", + "<<=", + ">>=", + ">>>=", + "|=", + "&=", + "^=", + "=>" + ] + }, + "type": "WhitespaceAround" + } + ] +} diff --git a/leenkx.py b/leenkx.py new file mode 100644 index 0000000..e5a3cf5 --- /dev/null +++ b/leenkx.py @@ -0,0 +1,954 @@ +# Leenkx Full Stack SDK +# https://leenkx.com/ +bl_info = { + "name": "Leenkx", + "category": "Software Development", + "location": "Properties -> Render -> Leenkx Player", + "description": "Full Stack SDK", + "author": "Leenkx.com", + "version": (1, 0, 8), + "blender": (4, 2, 1), + "doc_url": "https://leenkx.com/", + "tracker_url": "https://leenkx.com/support" +} +from enum import IntEnum +import os +from pathlib import Path +import platform +import re +import shutil +import stat +import subprocess +import sys +import textwrap +import threading +import traceback +import typing +from typing import Callable, Optional +import webbrowser + +import bpy +from bpy.app.handlers import persistent +from bpy.props import * +from bpy.types import Operator, AddonPreferences + + +class SDKSource(IntEnum): + PREFS = 0 + LOCAL = 1 + ENV_VAR = 2 + + +# Keep the value of these globals after addon reload +if "is_running" not in locals(): + is_running = False + last_sdk_path = "" + last_scripts_path = "" + sdk_source = SDKSource.PREFS + +update_error_msg = '' + + +def get_os(): + s = platform.system() + if s == 'Windows': + return 'win' + elif s == 'Darwin': + return 'mac' + else: + return 'linux' + + +def detect_sdk_path(): + """Auto-detect the SDK path after Leenkx installation.""" + # Do not overwrite the SDK path (this method gets + # called after each registration, not after + # installation only) + preferences = bpy.context.preferences + addon_prefs = preferences.addons["leenkx"].preferences + if addon_prefs.sdk_path != "": + return + + win = bpy.context.window_manager.windows[0] + area = win.screen.areas[0] + area_type = area.type + area.type = "INFO" + with bpy.context.temp_override(window=win, screen=win.screen, area=area): + bpy.ops.info.select_all(action='SELECT') + bpy.ops.info.report_copy() + area.type = area_type + clipboard = bpy.context.window_manager.clipboard + + # If leenkx was installed multiple times in this session, + # use the latest log entry. + match = re.findall(r"^Modules Installed .* from '(.*leenkx.py)' into", clipboard, re.MULTILINE) + if match: + addon_prefs.sdk_path = os.path.dirname(match[-1]) + +def get_link_web_server(self): + return self.get('link_web_server', 'http://localhost/') + +def set_link_web_server(self, value): + regex = re.compile( + r'^(?:http|ftp)s?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' #domain... + r'localhost|' #localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + if re.match(regex, value) is not None: + self['link_web_server'] = value + + +class LeenkxAddonPreferences(AddonPreferences): + bl_idname = __name__ + + def sdk_path_update(self, context): + if self.skip_update: + return + self.skip_update = True + self.sdk_path = bpy.path.reduce_dirs([bpy.path.abspath(self.sdk_path)])[0] + '/' + restart_leenkx(context) + + def ide_bin_update(self, context): + if self.skip_update: + return + self.skip_update = True + self.ide_bin = bpy.path.reduce_dirs([bpy.path.abspath(self.ide_bin)])[0] + + def ffmpeg_path_update(self, context): + if self.skip_update or self.ffmpeg_path == '': + return + self.skip_update = True + self.ffmpeg_path = bpy.path.reduce_dirs([bpy.path.abspath(self.ffmpeg_path)])[0] + + def renderdoc_path_update(self, context): + if self.skip_update or self.renderdoc_path == '': + return + self.skip_update = True + self.renderdoc_path = bpy.path.reduce_dirs([bpy.path.abspath(self.renderdoc_path)])[0] + + def android_sdk_path_update(self, context): + if self.skip_update: + return + self.skip_update = True + self.android_sdk_root_path = bpy.path.reduce_dirs([bpy.path.abspath(self.android_sdk_root_path)])[0] + + def android_apk_copy_update(self, context): + if self.skip_update: + return + self.skip_update = True + self.android_apk_copy_path = bpy.path.reduce_dirs([bpy.path.abspath(self.android_apk_copy_path)])[0] + + def html5_copy_path_update(self, context): + if self.skip_update: + return + self.skip_update = True + self.html5_copy_path = bpy.path.reduce_dirs([bpy.path.abspath(self.html5_copy_path)])[0] + + sdk_path: StringProperty(name="SDK Path", subtype="FILE_PATH", update=sdk_path_update, default="") + update_submodules: BoolProperty( + name="Update Submodules", default=True, description=( + "If enabled, update the submodules to their most current commit after downloading the SDK." + " Otherwise, the submodules are checked out at whatever commit the latest SDK references." + ) + ) + + show_advanced: BoolProperty(name="Show Advanced", default=False) + tabs: EnumProperty( + items=[('general', 'General', 'General Settings'), + ('build', 'Build Preferences', 'Settings related to building the game'), + ('debugconsole', 'Debug Console', 'Settings related to the in-game debug console'), + ('dev', 'Developer Settings', 'Settings for Leenkx developers')], + name='Tabs', default='general', description='Choose the settings page you want to see') + + ide_bin: StringProperty(name="Code Editor Executable", subtype="FILE_PATH", update=ide_bin_update, default="", description="Path to your editor's executable file") + code_editor: EnumProperty( + items = [('default', 'System Default', 'System Default'), + ('kodestudio', 'VS Code | Kode Studio', 'Visual Studio Code or Kode Studio'), + ('sublime', 'Sublime Text', 'Sublime Text'), + ('custom', "Custom", "Use a Custom Code Editor")], + name="Code Editor", default='default', description='Use this editor for editing scripts') + ui_scale: FloatProperty(name='UI Scale', description='Adjust UI scale for Leenkx tools', default=1.0, min=1.0, max=4.0) + khamake_threads: IntProperty(name='Khamake Processes', description='Allow Khamake to spawn multiple processes for faster builds', default=4, min=1) + khamake_threads_use_auto: BoolProperty(name='Auto', description='Let Khamake choose the number of processes automatically', default=False) + compilation_server: BoolProperty(name='Compilation Server', description='Allow Haxe to create a local compilation server for faster builds', default=True) + renderdoc_path: StringProperty(name="RenderDoc Path", description="Binary path", subtype="FILE_PATH", update=renderdoc_path_update, default="") + ffmpeg_path: StringProperty(name="FFMPEG Path", description="Binary path", subtype="FILE_PATH", update=ffmpeg_path_update, default="") + save_on_build: BoolProperty(name="Save on Build", description="Save .blend", default=False) + open_build_directory: BoolProperty(name="Open Build Directory After Publishing", description="Open the build directory after successfully publishing the project", default=False) + cmft_use_opencl: BoolProperty( + name="CMFT: Use OpenCL", default=True, + description=( + "Whether to use OpenCL processing to generate radiance maps with CMFT." + " If you experience extremely long build times caused by CMFT, try disabling this option." + " For more information see https://github.com/leenkx3d/leenkx/issues/2760" + )) + legacy_shaders: BoolProperty(name="Legacy Shaders", description="Attempt to compile shaders runnable on older hardware, use this for WebGL1 or GLES2 support in mobile render path", default=False) + relative_paths: BoolProperty(name="Generate Relative Paths", description="Write relative paths in khafile", default=False) + viewport_controls: EnumProperty( + items=[('qwerty', 'qwerty', 'qwerty'), + ('azerty', 'azerty', 'azerty')], + name="Viewport Controls", default='qwerty', description='Viewport camera mode controls') + skip_update: BoolProperty(name="", default=False) + # Debug Console + debug_console_auto: BoolProperty(name="Enable Debug Console for new project", description="Enable Debug Console for new project", default=False) + # Shortcuts + items_enum_keyboard = [ ('192', '~', 'TILDE'), + ('219', '[', 'OPEN BRACKET'), + ('221', ']', 'CLOSE BRACKET'), + ('192', '`', 'BACK QUOTE'), + ('57', '(', 'OPEN BRACKET'), + ('48', ')', 'CLOSE BRACKET'), + ('56', '*', 'MULTIPLY'), + ('190', '.', 'PERIOD'), + ('188', ',', 'COMMA', ), + ('191', '/', 'SLASH'), + ('65', 'A', 'A'), + ('66', 'B', 'B'), + ('67', 'C', 'C'), + ('68', 'D', 'D'), + ('69', 'E', 'E'), + ('70', 'F', 'F'), + ('71', 'G', 'G'), + ('72', 'H', 'H'), + ('73', 'I', 'I'), + ('74', 'J', 'J'), + ('75', 'K', 'K'), + ('76', 'L', 'L'), + ('77', 'M', 'M'), + ('78', 'N', 'N'), + ('79', 'O', 'O'), + ('80', 'P', 'P'), + ('81', 'Q', 'Q'), + ('82', 'R', 'R'), + ('83', 'S', 'S'), + ('84', 'T', 'T'), + ('85', 'U', 'U'), + ('86', 'V', 'V'), + ('87', 'W', 'W'), + ('88', 'X', 'X'), + ('89', 'Y', 'Y'), + ('90', 'Z', 'Z'), + ('48', '0', '0'), + ('49', '1', '1'), + ('50', '2', '2'), + ('51', '3', '3'), + ('52', '4', '4'), + ('53', '5', '5'), + ('54', '6', '6'), + ('55', '7', '7'), + ('56', '8', '8'), + ('57', '9', '9'), + ('32', 'SPACE', 'SPACE'), + ('8', 'BACKSPACE', 'BACKSPACE'), + ('9', 'TAB', 'TAB'), + ('13', 'ENTER', 'ENTER'), + ('16', 'SHIFT', 'SHIFT'), + ('17', 'CONTROL', 'CONTROL'), + ('18', 'ALT', 'ALT'), + ('27', 'ESCAPE', 'ESCAPE'), + ('46', 'DELETE', 'DELETE'), + ('33', 'PAGE UP', 'PAGE UP'), + ('34', 'PAGE DOWN', 'PAGE DOWN'), + ('38', 'UP', 'UP'), + ('39', 'RIGHT', 'RIGHT'), + ('37', 'LEFT', 'LEFT'), + ('40', 'DOWN', 'DOWN'), + ('96', 'NUMPAD 0', 'NUMPAD 0'), + ('97', 'NUMPAD 1', 'NUMPAD 1'), + ('98', 'NUMPAD 2', 'NUMPAD 2'), + ('99', 'NUMPAD 3', 'NUMPAD 3'), + ('100', 'NUMPAD 4', 'NUMPAD 4'), + ('101', 'NUMPAD 5', 'NUMPAD 5'), + ('102', 'NUMPAD 6', 'NUMPAD 6'), + ('103', 'NUMPAD 7', 'NUMPAD 7'), + ('104', 'NUMPAD 8', 'NUMPAD 8'), + ('106', 'NUMPAD *', 'NUMPAD *'), + ('110', 'NUMPAD /', 'NUMPAD /'), + ('107', 'NUMPAD +', 'NUMPAD +'), + ('108', 'NUMPAD -', 'NUMPAD -'), + ('109', 'NUMPAD .', 'NUMPAD .')] + debug_console_visible_sc: EnumProperty(items = items_enum_keyboard, + name="Visible / Invisible Shortcut", description="Shortcut to display the console", default='192') + debug_console_scale_in_sc: EnumProperty(items = items_enum_keyboard, + name="Scale In Shortcut", description="Shortcut to scale in on the console", default='219') + debug_console_scale_out_sc: EnumProperty(items = items_enum_keyboard, + name="Scale Out Shortcut", description="Shortcut to scale out on the console", default='221') + # Android Settings + android_sdk_root_path: StringProperty(name="Android SDK Path", description="Path to the Android SDK installation directory", default="", subtype="FILE_PATH", update=android_sdk_path_update) + android_open_build_apk_directory: BoolProperty(name="Open Build APK Directory", description="Open the build APK directory after successfully assemble", default=False) + android_apk_copy_path: StringProperty(name="Copy APK To Folder", description="Copy the APK file to the folder after build", default="", subtype="FILE_PATH", update=android_apk_copy_update) + android_apk_copy_open_directory: BoolProperty(name="Open Directory After Copy", description="Open the directory after copy the APK file", default=False) + # HTML5 Settings + html5_copy_path: StringProperty(name="HTML5 Copy Path", description="Path to copy project after successfully publish", default="", subtype="FILE_PATH", update=html5_copy_path_update) + link_web_server: StringProperty(name="Url To Web Server", description="Url to the web server that runs the local server", default="http://localhost/", set=set_link_web_server, get=get_link_web_server) + html5_server_port: IntProperty(name="Web Server Port", description="The port number of the local web server", default=8040, min=1024, max=65535) + html5_server_log: BoolProperty(name="Enable Http Log", description="Enable logging of http requests to local web server", default=True) + + # Developer options + profile_exporter: BoolProperty( + name="Exporter Profiling", default=False, + description="Run profiling when exporting the scene. A file named 'profile_exporter.prof' with the results will" + " be saved into the SDK directory and can be opened with tools such as SnakeViz") + khamake_debug: BoolProperty( + name="Set Khamake Flag: --debug", default=False, + description="Set the --debug flag when running Khamake. Useful for debugging HLSL shaders with RenderDoc") + haxe_times: BoolProperty( + name="Set Haxe Flag: --times", default=False, + description="Set the --times flag when running Haxe.") + use_leenkx_py_symlink: BoolProperty( + name="Symlink leenkx.py", default=False, + description=("Automatically symlink the registered leenkx.py with the original leenkx.py from the SDK for faster" + " development. Warning: this will invalidate the installation if the SDK is removed"), + update=lambda self, context: update_leenkx_py(get_sdk_path(context)), + ) + + def draw(self, context): + self.skip_update = False + layout = self.layout + layout.label(text="Welcome to Leenkx!") + + # Compare version Blender and Leenkx (major, minor) + if bpy.app.version[0] != 3 or bpy.app.version[1] != 6: + box = layout.box().column() + box.label(text="Warning: For Leenkx to work correctly, you need Blender 3.6 LTS.") + + layout.prop(self, "sdk_path") + sdk_path = get_sdk_path(context) + if os.path.exists(sdk_path + '/leenkx') or os.path.exists(sdk_path + '/leenkx_backup'): + sdk_exists = True + else: + sdk_exists = False + if not sdk_exists: + layout.label(text="The directory will be created.") + elif sdk_source != SDKSource.PREFS: + row = layout.row() + row.alert = True + if sdk_source == SDKSource.LOCAL: + row.label(text=f'Using local SDK from {sdk_path}') + elif sdk_source == SDKSource.ENV_VAR: + row.label(text=f'Using SDK from "LNXSDK" environment variable: {sdk_path}') + + box = layout.box().column() + + row = box.row(align=True) + row.label(text="Leenkx SDK Manager") + col = row.column() + col.alignment = "RIGHT" + col.operator("lnx_addon.help", icon="URL") + + box.label(text="Note: Development version may run unstable!") + box.separator() + + box.prop(self, "update_submodules") + + row = box.row(align=True) + row.alignment = 'EXPAND' + row.operator("lnx_addon.print_version_info", icon="INFO") + if sdk_exists: + row.operator("lnx_addon.update", icon="FILE_REFRESH") + else: + row.operator("lnx_addon.install", icon="IMPORT") + row.operator("lnx_addon.restore", icon="LOOP_BACK") + + if update_error_msg != '': + col = box.column(align=True) + col.scale_y = 0.8 + col.alignment = 'EXPAND' + col.alert = True + + # Roughly estimate how much text fits in the current region's + # width (approximation of box width) + textwrap_width = int(bpy.context.region.width / 6) + + col.label(text='An error occured:') + lines = textwrap.wrap(update_error_msg, width=textwrap_width, break_long_words=True, initial_indent=' ', subsequent_indent=' ') + for line in lines: + col.label(text=line) + + box.label(text="Check console for download progress. Please restart Blender after successful SDK update.") + + col = layout.column(align=(not self.show_advanced)) + col.prop(self, "show_advanced") + if self.show_advanced: + box_main = col.box() + + # Use a row to expand the prop horizontally + row = box_main.row() + row.scale_y = 1.2 + row.ui_units_y = 1.4 + row.prop(self, "tabs", expand=True) + + box = box_main.column() + + if self.tabs == "general": + box.prop(self, "code_editor") + if self.code_editor != "default": + box.prop(self, "ide_bin") + box.prop(self, "renderdoc_path") + box.prop(self, "ffmpeg_path") + box.prop(self, "viewport_controls") + box.prop(self, "ui_scale") + box.prop(self, "legacy_shaders") + box.prop(self, "relative_paths") + + elif self.tabs == "build": + box.label(text="Build Preferences") + row = box.split(factor=0.8, align=True) + _col = row.column(align=True) + _col.enabled = not self.khamake_threads_use_auto + _col.prop(self, "khamake_threads") + row.prop(self, "khamake_threads_use_auto", toggle=True) + box.prop(self, "compilation_server") + box.prop(self, "open_build_directory") + box.prop(self, "save_on_build") + + box.separator() + box.prop(self, "cmft_use_opencl") + + box = box_main.column() + box.label(text="Android Settings") + box.prop(self, "android_sdk_root_path") + box.prop(self, "android_open_build_apk_directory") + box.prop(self, "android_apk_copy_path") + box.prop(self, "android_apk_copy_open_directory") + + box = box_main.column() + box.label(text="HTML5 Settings") + box.prop(self, "html5_copy_path") + box.prop(self, "link_web_server") + box.prop(self, "html5_server_port") + box.prop(self, "html5_server_log") + + elif self.tabs == "debugconsole": + box.label(text="Debug Console") + box.prop(self, "debug_console_auto") + box.label(text="Note: The following settings will be applied if Debug Console is enabled in the project settings") + box.prop(self, "debug_console_visible_sc") + box.prop(self, "debug_console_scale_in_sc") + box.prop(self, "debug_console_scale_out_sc") + + elif self.tabs == "dev": + col = box.column(align=True) + col.label(icon="ERROR", text="Warning: The following settings are meant for Leenkx developers and might slow") + col.label(icon="BLANK1", text="down Leenkx or make it unstable. Only change them if you know what you are doing.") + box.separator() + + col = box.column(align=True) + col.prop(self, "profile_exporter") + col.prop(self, "khamake_debug") + col.prop(self, "haxe_times") + + col = box.column(align=True) + col.prop(self, "use_leenkx_py_symlink") + + @staticmethod + def get_prefs() -> 'LeenkxAddonPreferences': + preferences = bpy.context.preferences + return typing.cast(LeenkxAddonPreferences, preferences.addons["leenkx"].preferences) + + +def get_fp(): + if bpy.data.filepath == '': + return '' + s = bpy.data.filepath.split(os.path.sep) + s.pop() + return os.path.sep.join(s) + + +def same_path(path1: str, path2: str) -> bool: + """Compare whether two paths point to the same location.""" + if os.path.exists(path1) and os.path.exists(path2): + return os.path.samefile(path1, path2) + + p1 = os.path.realpath(os.path.normpath(os.path.normcase(path1))) + p2 = os.path.realpath(os.path.normpath(os.path.normcase(path2))) + return p1 == p2 + + +def get_sdk_path(context: bpy.context) -> str: + """Returns the absolute path of the currently set Leenkx SDK. + + The path is read from the following sources in that priority (the + topmost source is used if valid): + 1. Environment variable 'LNXSDK' (must be an absolute path). + Useful to temporarily override the SDK path, e.g. when + running from the command line. + 2. Local SDK in /lnxsdk relative to the current file. + 3. The SDK path specified in the add-on preferences. + """ + global sdk_source + + sdk_envvar = os.environ.get('LNXSDK') + if sdk_envvar is not None and os.path.isabs(sdk_envvar) and os.path.isdir(sdk_envvar) and os.path.exists(sdk_envvar): + sdk_source = SDKSource.ENV_VAR + return sdk_envvar + + fp = get_fp() + if fp != '': # blend file is not saved + local_sdk = os.path.join(fp, 'lnxsdk') + if os.path.exists(local_sdk): + sdk_source = SDKSource.LOCAL + return local_sdk + + sdk_source = SDKSource.PREFS + preferences = context.preferences + addon_prefs = preferences.addons["leenkx"].preferences + return addon_prefs.sdk_path + +def apply_unix_permissions(sdk): + """Apply permissions to executable files in Linux and macOS + + The .zip format does not preserve file permissions and will + cause every subprocess of Leenkx3D to not work at all. This + workaround fixes the issue so Leenkx releases will work. + """ + if get_os() == 'linux': + paths=[ + os.path.join(sdk, "lib/leenkx_tools/cmft/cmft-linux64"), + os.path.join(sdk, "Krom/Krom"), + # NodeJS + os.path.join(sdk, "nodejs/node-linux32"), + os.path.join(sdk, "nodejs/node-linux64"), + os.path.join(sdk, "nodejs/node-linuxarm"), + # Kha tools x64 + os.path.join(sdk, "Kha/Tools/linux_x64/haxe"), + os.path.join(sdk, "Kha/Tools/linux_x64/lame"), + os.path.join(sdk, "Kha/Tools/linux_x64/oggenc"), + # Kha tools arm64 + os.path.join(sdk, "Kha/Tools/linux_arm64/haxe"), + os.path.join(sdk, "Kha/Tools/linux_arm64/lame"), + os.path.join(sdk, "Kha/Tools/linux_arm64/oggenc"), + # Kinc tools x64 + os.path.join(sdk, "Kha/Kinc/Tools/linux_x64/kmake"), + os.path.join(sdk, "Kha/Kinc/Tools/linux_x64/kraffiti"), + os.path.join(sdk, "Kha/Kinc/Tools/linux_x64/krafix"), + # Kinc tools arm + os.path.join(sdk, "Kha/Kinc/Tools/linux_arm/kmake"), + os.path.join(sdk, "Kha/Kinc/Tools/linux_arm/kraffiti"), + os.path.join(sdk, "Kha/Kinc/Tools/linux_arm/krafix"), + # Kinc tools arm64 + os.path.join(sdk, "Kha/Kinc/Tools/linux_arm64/kmake"), + os.path.join(sdk, "Kha/Kinc/Tools/linux_arm64/kraffiti"), + os.path.join(sdk, "Kha/Kinc/Tools/linux_arm64/krafix"), + ] + for path in paths: + os.chmod(path, 0o777) + + if get_os() == 'mac': + paths=[ + os.path.join(sdk, "lib/leenkx_tools/cmft/cmft-osx"), + os.path.join(sdk, "nodejs/node-osx"), + os.path.join(sdk, "Krom/Krom.app/Contents/MacOS/Krom"), + # Kha tools + os.path.join(sdk, "Kha/Tools/macos/haxe"), + os.path.join(sdk, "Kha/Tools/macos/lame"), + os.path.join(sdk, "Kha/Tools/macos/oggenc"), + # Kinc tools + os.path.join(sdk, "Kha/Kinc/Tools/macos/kmake"), + os.path.join(sdk, "Kha/Kinc/Tools/macos/kraffiti"), + os.path.join(sdk, "Kha/Kinc/Tools/macos/krafix"), + ] + for path in paths: + os.chmod(path, 0o777) + +def remove_readonly(func, path, excinfo): + os.chmod(path, stat.S_IWRITE) + func(path) + + +def run_proc(cmd: list[str], done: Optional[Callable[[bool], None]] = None): + def fn(p, done): + p.wait() + if done is not None: + done(False) + p = None + + try: + p = subprocess.Popen(cmd) + except OSError as err: + if done is not None: + done(True) + print("Running command:", *cmd, "\n") + if err.errno == 12: + print("Make sure there is enough space for the SDK (at least 500mb)") + elif err.errno == 13: + print("Permission denied, try modifying the permission of the sdk folder") + else: + print("error: " + str(err)) + except Exception as err: + if done is not None: + done(True) + print("Running command:", *cmd, "\n") + print("error:", str(err), "\n") + else: + threading.Thread(target=fn, args=(p, done)).start() + + return p + + +def set_update_error(msg: str, err: Exception): + traceback.print_exception(type(err), err, err.__traceback__) + + global update_error_msg + update_error_msg = msg + ' The full error message has been printed to the console.' + + +def try_os_call(func: Callable, *args, **kwargs) -> bool: + try: + func(*args, **kwargs) + except OSError as err: + if hasattr(err, 'winerror'): + if err.winerror == 32: # file is used by another process (ERROR_SHARING_VIOLATION) + set_update_error(( + f'The file/path {err.filename} is used by at least one other process (ERROR_SHARING_VIOLATION).' + ' Please close all processes that reference it and try again.' + ), err) + return False + + set_update_error('There was an unknown error while updating/restoring the Leenkx SDK.', err) + return False + except Exception as err: + set_update_error('There was an unknown error while updating/restoring the Leenkx SDK.', err) + return False + + return True + + +def git_clone(done: Callable[[bool], None], rootdir: str, gitn: str, subdir: str, recursive=False): + print('Download SDK manually from https://leenkx.com...') + return False + rootdir = os.path.normpath(rootdir) + path = rootdir + '/' + subdir if subdir != '' else rootdir + + if os.path.exists(path) and not os.path.exists(path + '_backup'): + if not try_os_call(os.rename, path, path + '_backup'): + return + if os.path.exists(path): + shutil.rmtree(path, onerror=remove_readonly) + + if recursive: + run_proc(['git', 'clone', '--recursive', 'https://github.com/' + gitn, path, '--depth', '1', '--shallow-submodules', '--jobs', '4'], done) + else: + run_proc(['git', 'clone', 'https://github.com/' + gitn, path, '--depth', '1'], done) + + +def git_test(self: bpy.types.Operator, required_version=None): + print('Download SDK manually from https://leenkx.com...') + return False + try: + p = subprocess.Popen(['git', '--version'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output, _ = p.communicate() + except (OSError, Exception) as exception: + print(str(exception)) + else: + matched = re.match("git version ([0-9]+).([0-9]+).([0-9]+)", output.decode('utf-8')) + if matched: + if required_version is not None: + matched_version = (int(matched.group(1)), int(matched.group(2)), int(matched.group(3))) + if matched_version < required_version: + msg = f"Installed git version {matched_version} is too old, please update git to version {required_version} or above" + print(msg) + self.report({"ERROR"}, msg) + return False + + print('Git test succeeded.') + return True + + msg = "Git test failed. Make sure git is installed (https://git-scm.com/downloads) or is working correctly." + print(msg) + self.report({"ERROR"}, msg) + return False + + +def restore_repo(rootdir: str, subdir: str): + rootdir = os.path.normpath(rootdir) + path = rootdir + '/' + subdir if subdir != '' else rootdir + if os.path.exists(path + '_backup'): + if os.path.exists(path): + if not try_os_call(shutil.rmtree, path, onerror=remove_readonly): + return + if not try_os_call(os.rename, path + '_backup', path): + # TODO: if rmtree() succeeds but rename fails the SDK needs + # manual cleanup by the user, add message to UI + return + + +class LnxAddonPrintVersionInfoButton(bpy.types.Operator): + bl_idname = "lnx_addon.print_version_info" + bl_label = "Print Version Info" + bl_description = "Print detailed information about the used SDK version to the console. This requires Git" + + def execute(self, context): + sdk_path = get_sdk_path(context) + if sdk_path == "": + self.report({"ERROR"}, "Configure Leenkx SDK path first") + return {"CANCELLED"} + + if not git_test(self): + return {"CANCELLED"} + + if not os.path.exists(f'{sdk_path}/.git'): + msg=f"{sdk_path}/.git not found" + self.report({"ERROR"}, msg) + return {"CANCELLED"} + + def print_version_info(): + print("==============================") + print("| SDK: Current commit |") + print("==============================") + subprocess.check_call(["git", "branch", "-v"], cwd=sdk_path) + + print("==============================") + print("| Submodules: Current commit |") + print("==============================") + subprocess.check_call(["git", "submodule", "status", "--recursive"], cwd=sdk_path) + + print("==============================") + print("| SDK: Modified files |") + print("==============================") + subprocess.check_call(["git", "status", "--short"], cwd=sdk_path) + + print("==============================") + print("| Submodules: Modified files |") + print("==============================") + subprocess.check_call(["git", "submodule", "foreach", "--recursive", "git status --short"], cwd=sdk_path) + + print("Done.") + + # Don't block UI + threading.Thread(target=print_version_info).start() + + return {"FINISHED"} + + +class LnxAddonInstallButton(bpy.types.Operator): + """Download and set up Leenkx SDK""" + bl_idname = "lnx_addon.install" + bl_label = "Download and set up SDK" + bl_description = "Download and set up the latest development version" + + def execute(self, context): + download_sdk(self, context) + return {"FINISHED"} + + +class LnxAddonUpdateButton(bpy.types.Operator): + """Update Leenkx SDK""" + bl_idname = "lnx_addon.update" + bl_label = "Update SDK" + bl_description = "Update to the latest development version" + + def execute(self, context): + download_sdk(self, context) + return {"FINISHED"} + + +def download_sdk(self: bpy.types.Operator, context): + global update_error_msg + update_error_msg = '' + + sdk_path = get_sdk_path(context) + if sdk_path == "": + self.report({"ERROR"}, "Configure Leenkx SDK path first") + return {"CANCELLED"} + + self.report({'INFO'}, 'Downloading Leenkx SDK, check console for details.') + print('Leenkx (current add-on version' + str(bl_info['version']) + '): Cloning SDK repository recursively') + if not os.path.exists(sdk_path): + os.makedirs(sdk_path) + if not git_test(self): + return {"CANCELLED"} + + preferences = context.preferences + addon_prefs = preferences.addons["leenkx"].preferences + + def done(failed: bool): + if failed: + self.report({"ERROR"}, "Failed updating the SDK submodules, check console for details.") + else: + update_leenkx_py(sdk_path) + print('Leenkx SDK download completed, please restart Blender..') + + def done_clone(failed: bool): + if failed: + self.report({"ERROR"}, "Failed downloading Leenkx SDK, check console for details.") + return + + if addon_prefs.update_submodules: + if not git_test(self, (2, 34, 0)): + # For some unknown (and seemingly not fixable) reason, git submodule update --remote + # fails in earlier versions of Git with "fatal: Needed a single revision" + done(True) + else: + prev_cwd = os.getcwd() + os.chdir(sdk_path) + run_proc(['git', 'submodule', 'update', '--remote', '--depth', '1', '--jobs', '4'], done) + os.chdir(prev_cwd) + else: + done(False) + + git_clone(done_clone, sdk_path, 'leenkx3d/lnxsdk', '', recursive=True) + + +class LnxAddonRestoreButton(bpy.types.Operator): + """Update Leenkx SDK""" + bl_idname = "lnx_addon.restore" + bl_label = "Restore SDK" + bl_description = "Restore stable version" + + def execute(self, context): + sdk_path = get_sdk_path(context) + if sdk_path == "": + self.report({"ERROR"}, "Configure Leenkx SDK path first") + return {"CANCELLED"} + restore_repo(sdk_path, '') + self.report({'INFO'}, 'Restored stable version') + return {"FINISHED"} + + +class LnxAddonHelpButton(bpy.types.Operator): + """Updater help""" + bl_idname = "lnx_addon.help" + bl_label = "Help" + bl_description = "Leenkx Updater is currently disabled. Download SDK manually from https://leenkx.com" + + def execute(self, context): + webbrowser.open('https://leenkx.com/support') + return {"FINISHED"} + + +def update_leenkx_py(sdk_path: str, force_relink=False): + """Ensure that leenkx.py is up to date by copying it from the + current SDK path (if 'use_leenkx_py_symlink' is true, a symlink is + created instead). Note that because the current version of leenkx.py + is already loaded as a Python module, this change lags one add-on + reload behind. + """ + addon_prefs = LeenkxAddonPreferences.get_prefs() + lnx_module_file = Path(sys.modules['leenkx'].__file__) + + if addon_prefs.use_leenkx_py_symlink: + if not lnx_module_file.is_symlink() or force_relink: + lnx_module_file.unlink(missing_ok=True) + try: + lnx_module_file.symlink_to(Path(sdk_path) / 'leenkx.py') + except OSError as err: + if hasattr(err, 'winerror'): + if err.winerror == 1314: # ERROR_PRIVILEGE_NOT_HELD + # Manually copy the file to "simulate" symlink + shutil.copy(Path(sdk_path) / 'leenkx.py', lnx_module_file) + else: + raise err + else: + raise err + else: + lnx_module_file.unlink(missing_ok=True) + shutil.copy(Path(sdk_path) / 'leenkx.py', lnx_module_file) + + +def start_leenkx(sdk_path: str): + global is_running + global last_scripts_path + global last_sdk_path + + if sdk_path == "": + return + + leenkx_path = os.path.join(sdk_path, "leenkx") + if not os.path.exists(leenkx_path): + print("Leenkx load error: 'leenkx' folder not found in SDK path." + " Please make sure the SDK path is correct or that the SDK" + " was downloaded correctly.") + return + + apply_unix_permissions(sdk_path) + + scripts_path = os.path.join(leenkx_path, "blender") + sys.path.append(scripts_path) + last_scripts_path = scripts_path + + update_leenkx_py(sdk_path, force_relink=True) + + import start + if last_sdk_path != "": + import importlib + start = importlib.reload(start) + + use_local_sdk = (sdk_source == SDKSource.LOCAL) + start.register(local_sdk=use_local_sdk) + + last_sdk_path = sdk_path + is_running = True + + print(f'Running Leenkx SDK from {sdk_path}') + + +def stop_leenkx(): + global is_running + + if not is_running: + return + + import start + start.unregister() + + sys.path.remove(last_scripts_path) + is_running = False + + +def restart_leenkx(context): + old_sdk_source = sdk_source + sdk_path = get_sdk_path(context) + + if sdk_path == "": + if not is_running: + print("Configure Leenkx SDK path first") + stop_leenkx() + return + + # Only restart Leenkx when the SDK path changed or it isn't running, + # otherwise we can keep the currently running instance + if not same_path(last_sdk_path, sdk_path) or sdk_source != old_sdk_source or not is_running: + stop_leenkx() + assert not is_running + start_leenkx(sdk_path) + + +@persistent +def on_load_post(context): + restart_leenkx(bpy.context) # context is None, use bpy.context instead + + +def on_register_post(): + detect_sdk_path() + restart_leenkx(bpy.context) + + +def register(): + bpy.utils.register_class(LeenkxAddonPreferences) + bpy.utils.register_class(LnxAddonPrintVersionInfoButton) + bpy.utils.register_class(LnxAddonInstallButton) + bpy.utils.register_class(LnxAddonUpdateButton) + bpy.utils.register_class(LnxAddonRestoreButton) + bpy.utils.register_class(LnxAddonHelpButton) + bpy.app.handlers.load_post.append(on_load_post) + + # Hack to avoid _RestrictContext + bpy.app.timers.register(on_register_post, first_interval=0.01) + + +def unregister(): + stop_leenkx() + bpy.utils.unregister_class(LeenkxAddonPreferences) + bpy.utils.unregister_class(LnxAddonInstallButton) + bpy.utils.unregister_class(LnxAddonPrintVersionInfoButton) + bpy.utils.unregister_class(LnxAddonUpdateButton) + bpy.utils.unregister_class(LnxAddonRestoreButton) + bpy.utils.unregister_class(LnxAddonHelpButton) + bpy.app.handlers.load_post.remove(on_load_post) + + +if __name__ == "__main__": + register()