diff --git a/CHANGELOG.md b/CHANGELOG.md index 99790add62c..1a3f0bb2e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* Fixed Blender installation process to support the new "Extensions" mechanism in Blender 4.2+. + ### Removed diff --git a/pyproject.toml b/pyproject.toml index ae06d4dc998..b5ec463114e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,8 @@ current_version = "2.15.0" message = "Bump version to {new_version}" commit = true tag = true +parse = '(?P\d+)\.(?P\d+)\.(?P\d+)' +serialize = ["{major}.{minor}.{patch}"] [[tool.bumpversion.files]] filename = "src/compas/__init__.py" @@ -97,8 +99,19 @@ replace = "{new_version}" [[tool.bumpversion.files]] filename = "src/compas_blender/__init__.py" -search = "{current_version}" -replace = "{new_version}" +search = '__version__ = "{current_version}"' +replace = '__version__ = "{new_version}"' + +[[tool.bumpversion.files]] +filename = "src/compas_blender/__init__.py" +search = '"version": \(\d+, \d+, \d+\),' +replace = '"version": ({new_major}, {new_minor}, {new_patch}),' +regex = true + +[[tool.bumpversion.files]] +filename = "src/compas_blender/blender_manifest.toml" +search = 'version = "{current_version}"' +replace = 'version = "{new_version}"' [[tool.bumpversion.files]] filename = "src/compas_ghpython/__init__.py" diff --git a/src/compas_blender/__init__.py b/src/compas_blender/__init__.py index 935d6abe545..f19567a6fd1 100644 --- a/src/compas_blender/__init__.py +++ b/src/compas_blender/__init__.py @@ -1,12 +1,31 @@ # type: ignore import io import os -import compas +import sys +import site +import subprocess +import importlib.util +import threading +import queue + +# Ensure user site packages are in sys.path so we can find installed dependencies +# This is crucial because we install with --user +if site.USER_SITE not in sys.path: + sys.path.append(site.USER_SITE) + +# Check if dependencies are installed +try: + import compas +except ImportError: + compas = None try: import bpy - import compas_blender.data +except ImportError: + bpy = None +try: + import compas_blender.data except ImportError: pass @@ -14,6 +33,19 @@ __version__ = "2.15.0" +bl_info = { + "name": "COMPAS", + "author": "Tom Van Mele et al", + "version": (2, 15, 0), + "blender": (4, 2, 0), + "location": "Console", + "description": "The COMPAS framework for Blender", + "warning": "", + "doc_url": "https://compas.dev", + "category": "Development", +} + + INSTALLABLE_PACKAGES = ["compas", "compas_blender"] SUPPORTED_VERSIONS = ["3.3", "3.6", "4.2"] DEFAULT_VERSION = "4.2" @@ -250,3 +282,277 @@ def _get_default_blender_sitepackages_path_windows(version): def _get_default_blender_sitepackages_path_linux(version): raise NotImplementedError + + +def _get_python_exe(): + # Determine python executable + python_exe = sys.executable + + # If sys.executable is Blender, we need to find the python binary + if os.path.basename(sys.executable).lower().startswith("blender"): + if sys.platform == "darwin": + # macOS: Resources/X.X/python/bin/python3.X + # sys.prefix usually points to Resources/X.X/python + python_dir = os.path.join(sys.prefix, "bin") + # Find the python executable + found = False + if os.path.exists(python_dir): + for name in os.listdir(python_dir): + if name.startswith("python3") and not name.endswith("config"): + python_exe = os.path.join(python_dir, name) + found = True + break + if not found: + print("COMPAS: Could not locate python executable in {}".format(python_dir)) + elif sys.platform == "win32": + # Windows: python/bin/python.exe + python_exe = os.path.join(sys.prefix, "bin", "python.exe") + if not os.path.exists(python_exe): + python_exe = os.path.join(sys.prefix, "python.exe") + return python_exe + + +def _run_command(cmd, env=None, log_func=None): + try: + # Create a startupinfo object to hide the console window on Windows + startupinfo = None + if os.name == 'nt': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + # Prepare environment + process_env = os.environ.copy() + if env: + process_env.update(env) + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + startupinfo=startupinfo, + env=process_env, + bufsize=1, + universal_newlines=True + ) + + output_lines = [] + while True: + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if line: + line = line.rstrip() + output_lines.append(line) + if log_func: + log_func(line) + + stdout = "\n".join(output_lines) + + if process.returncode != 0: + return False, stdout + return True, stdout + except Exception as e: + return False, str(e) + + +def _log_to_text_block(message): + log_name = "COMPAS_INSTALL_LOG" + if log_name not in bpy.data.texts: + bpy.data.texts.new(log_name) + log = bpy.data.texts[log_name] + log.write(message + "\n") + # Try to switch an area to text editor to show log? No, that's too intrusive. + + +def _install_dependencies(log_func=None, progress_func=None): + if log_func is None: + log_func = _log_to_text_block + + def update_progress(val): + if progress_func: + progress_func(val) + + update_progress(0) + print("COMPAS: Installing dependencies...") + log_func("--- Starting Installation ---") + + python_exe = _get_python_exe() + log_func("Using python executable: {}".format(python_exe)) + + # We install to the user site packages to avoid permission issues (especially on Windows) + # and to avoid modifying the Blender installation itself. + log_func("Installing to user site packages (requires no admin rights).") + + wheels_dir = os.path.join(os.path.dirname(__file__), "wheels") + + # Ensure pip is installed + update_progress(10) + log_func("Checking for pip...") + # Check if pip is already available + pip_check, _ = _run_command([python_exe, "-m", "pip", "--version"]) + + if not pip_check: + log_func("pip not found. Attempting to install pip via ensurepip...") + # Try ensurepip. We try with --user to avoid permission errors if possible. + success, output = _run_command([python_exe, "-m", "ensurepip", "--upgrade", "--user"], log_func=log_func) + if not success: + # Fallback: try without --user (maybe we are admin?) + log_func("ensurepip --user failed. Trying default ensurepip...") + success, output = _run_command([python_exe, "-m", "ensurepip", "--upgrade"], log_func=log_func) + if not success: + return False, "Failed to install pip. See COMPAS_INSTALL_LOG for details." + else: + log_func("pip is already installed.") + + update_progress(30) + + # Install wheels + if os.path.exists(wheels_dir): + # Find the compas wheel + compas_wheels = [f for f in os.listdir(wheels_dir) if f.startswith("compas-") and f.endswith(".whl")] + if not compas_wheels: + return False, "Could not find compas wheel in {}".format(wheels_dir) + + compas_wheel_path = os.path.join(wheels_dir, compas_wheels[0]) + log_func("Installing compas from {}".format(compas_wheel_path)) + + # Install with --user + # We use --upgrade to ensure we get the latest compatible dependencies. + # We do NOT use --force-reinstall to avoid forcing a reinstall of numpy if it's in system. + success, output = _run_command([ + python_exe, "-m", "pip", "install", + compas_wheel_path, + "--upgrade", + "--user" + ], log_func=log_func) + if not success: + return False, "Failed to install compas. See COMPAS_INSTALL_LOG for details." + + update_progress(100) + log_func("Dependencies installed successfully.") + + # Ensure user site is in sys.path immediately + if site.USER_SITE not in sys.path: + sys.path.append(site.USER_SITE) + + return True, "Success" + + +# Global queue for thread communication +install_queue = queue.Queue() + +def _log_to_queue(message): + install_queue.put(("LOG", message)) + +def _progress_to_queue(value): + install_queue.put(("PROGRESS", value)) + +def _install_thread_target(): + try: + success, msg = _install_dependencies(log_func=_log_to_queue, progress_func=_progress_to_queue) + install_queue.put(("RESULT", (success, msg))) + except Exception as e: + install_queue.put(("RESULT", (False, str(e)))) + +if bpy is not None: + class COMPAS_OT_install_dependencies(bpy.types.Operator): + bl_idname = "compas.install_dependencies" + bl_label = "Install Dependencies" + bl_description = "Install COMPAS and required dependencies (scipy, etc.)" + + _timer = None + _thread = None + + def modal(self, context, event): + if event.type == 'TIMER': + while not install_queue.empty(): + try: + msg_type, data = install_queue.get_nowait() + except queue.Empty: + break + + if msg_type == "LOG": + _log_to_text_block(data) + context.workspace.status_text_set(data) + elif msg_type == "RESULT": + success, msg = data + + # Clear status text + context.workspace.status_text_set(None) + + if success: + self.report({'INFO'}, "COMPAS dependencies installed. Please restart Blender.") + # Invalidate import caches so find_spec works immediately + importlib.invalidate_caches() + else: + self.report({'ERROR'}, msg) + self.report({'WARNING'}, "Check the 'COMPAS_INSTALL_LOG' in the Text Editor for details.") + + context.window_manager.event_timer_remove(self._timer) + + # Force redraw of the preferences area if possible + if context.area: + context.area.tag_redraw() + + return {'FINISHED'} + + return {'PASS_THROUGH'} + + def execute(self, context): + self.report({'INFO'}, "Installation started. Check COMPAS_INSTALL_LOG for progress...") + + # Start thread + self._thread = threading.Thread(target=_install_thread_target) + self._thread.start() + + self._timer = context.window_manager.event_timer_add(0.1, window=context.window) + context.window_manager.modal_handler_add(self) + return {'RUNNING_MODAL'} + + class COMPAS_PT_preferences(bpy.types.AddonPreferences): + bl_idname = __package__ + + def draw(self, context): + layout = self.layout + + compas_spec = importlib.util.find_spec("compas") + + if compas_spec: + if compas: + layout.label(text="COMPAS {} is installed.".format(compas.__version__), icon='CHECKMARK') + else: + layout.label(text="COMPAS is installed (Restart Blender to load).", icon='CHECKMARK') + layout.operator("compas.install_dependencies", text="Reinstall / Update Dependencies") + else: + layout.label(text="COMPAS is NOT installed.", icon='ERROR') + layout.operator("compas.install_dependencies", text="Install Dependencies") + + if importlib.util.find_spec("scipy"): + layout.label(text="Dependencies are installed.", icon='CHECKMARK') + else: + layout.label(text="Dependencies are NOT installed.", icon='ERROR') + + + def register(): + if "bpy" in sys.modules: + print("COMPAS: Registering classes...") + try: + bpy.utils.register_class(COMPAS_OT_install_dependencies) + bpy.utils.register_class(COMPAS_PT_preferences) + print("COMPAS: Classes registered.") + except Exception as e: + print("COMPAS: Failed to register classes: {}".format(e)) + import traceback + traceback.print_exc() + + # Auto-install is disabled to prevent UI blocking/crashing. + # User should use the button in Preferences. + if compas is None or importlib.util.find_spec("scipy") is None: + print("COMPAS: Dependencies missing. Please use the 'Install Dependencies' button in the COMPAS preferences.") + + + def unregister(): + if "bpy" in sys.modules: + bpy.utils.unregister_class(COMPAS_OT_install_dependencies) + bpy.utils.unregister_class(COMPAS_PT_preferences) diff --git a/src/compas_blender/blender_manifest.toml b/src/compas_blender/blender_manifest.toml new file mode 100644 index 00000000000..3c1533b64ff --- /dev/null +++ b/src/compas_blender/blender_manifest.toml @@ -0,0 +1,13 @@ +schema_version = "1.0.0" +id = "compas_blender" +version = "2.15.0" +name = "COMPAS" +tagline = "The COMPAS framework for Blender" +maintainer = "Tom Van Mele et al" +type = "add-on" +website = "https://compas.dev" +tags = ["Development", "Object", "Pipeline"] +blender_version_min = "4.2.0" +license = ["MIT"] +copyright = ["2025 COMPAS Association"] +permissions = ["network", "files"] diff --git a/tasks.py b/tasks.py index 7286febdb08..9af022dc3f8 100644 --- a/tasks.py +++ b/tasks.py @@ -1,14 +1,82 @@ from __future__ import print_function import os +import shutil +import sys from compas_invocations2 import build from compas_invocations2 import docs from compas_invocations2 import style from compas_invocations2 import tests from compas_invocations2 import grasshopper +from invoke import task from invoke.collection import Collection + +@task +def build_blender_addon(ctx, version="2.15.0", destination="dist"): + """Build the COMPAS Blender addon.""" + + # Define paths + root_dir = os.path.dirname(os.path.abspath(__file__)) + build_dir = os.path.join(root_dir, "temp_build_blender") + addon_dir = os.path.join(build_dir, "compas_blender") + wheels_dir = os.path.join(addon_dir, "wheels") + + # Clean previous build + if os.path.exists(build_dir): + shutil.rmtree(build_dir) + + print("Building Blender addon version {}...".format(version)) + + # Copy compas_blender source + src_compas_blender = os.path.join(root_dir, "src", "compas_blender") + shutil.copytree(src_compas_blender, addon_dir) + + # Create wheels directory + os.makedirs(wheels_dir) + + # Build compas wheel + print("Building compas wheel...") + ctx.run("{} -m pip wheel . --no-deps -w {}".format(sys.executable, wheels_dir)) + + # Note: We do NOT bundle dependencies (networkx, jsonschema, watchdog, scipy). + # We let pip install them from PyPI on the user's machine to ensure: + # 1. Cross-platform compatibility (getting the correct binary wheels for their OS). + # 2. Smaller addon size. + + # Clean up __pycache__ + print("Cleaning up...") + for root, dirs, files in os.walk(build_dir): + for d in dirs: + if d == "__pycache__": + shutil.rmtree(os.path.join(root, d)) + for f in files: + if f.endswith(".pyc"): + os.remove(os.path.join(root, f)) + + # Clean up __pycache__ + print("Cleaning up...") + for root, dirs, files in os.walk(build_dir): + for d in dirs: + if d == "__pycache__": + shutil.rmtree(os.path.join(root, d)) + for f in files: + if f.endswith(".pyc"): + os.remove(os.path.join(root, f)) + + # Create zip + if not os.path.exists(destination): + os.makedirs(destination) + zip_name = os.path.join(destination, "compas_blender-{}".format(version)) + shutil.make_archive(zip_name, 'zip', build_dir) + + print("Addon created at {}.zip".format(zip_name)) + + # Cleanup build dir + shutil.rmtree(build_dir) + + ns = Collection( docs.help, style.check, @@ -27,6 +95,7 @@ grasshopper.yakerize, grasshopper.publish_yak, grasshopper.update_gh_header, + build_blender_addon, ) ns.configure( {