Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
17 changes: 15 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ current_version = "2.15.0"
message = "Bump version to {new_version}"
commit = true
tag = true
parse = '(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)'
serialize = ["{major}.{minor}.{patch}"]

[[tool.bumpversion.files]]
filename = "src/compas/__init__.py"
Expand All @@ -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"
Expand Down
310 changes: 308 additions & 2 deletions src/compas_blender/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,51 @@
# 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


__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"
Expand Down Expand Up @@ -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)
13 changes: 13 additions & 0 deletions src/compas_blender/blender_manifest.toml
Original file line number Diff line number Diff line change
@@ -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 <tom.v.mele@gmail.com> 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"]
Loading
Loading