hallr 0.1.19

An experimental Blender addon
Documentation
#!/usr/bin/env python3
"""
SPDX-License-Identifier: AGPL-3.0-or-later
Copyright (c) 2023 lacklustr@protonmail.com https://github.com/eadf
This file is part of the hallr crate.
"""

import subprocess
import time
import os
import platform
import shutil
import glob
import argparse
import sys


def clear_directory(dir_path, delete_root=False):
    """Remove all files from a directory while keeping the directory itself."""
    if not os.path.exists(dir_path):
        return
    for filename in os.listdir(dir_path):
        file_path = os.path.join(dir_path, filename)
        try:
            if os.path.isdir(file_path):
                clear_directory(file_path)
            elif os.path.isfile(file_path):
                os.chmod(file_path, 0o666)
                os.remove(file_path)
        except Exception as e:
            print(f"Failed to delete {file_path}: {e}")
    if delete_root:
        os.chmod(dir_path, 0o666)
        os.rmdir(dir_path)


def parse_arguments():
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(description="Package the hallr Blender add-on.")
    parser.add_argument("--dev_mode", action="store_true", help="Enable development mode.")
    parser.add_argument("--release", action="store_true", help="Build in release mode.")
    parser.add_argument("--debug", action="store_true", help="Build in debug mode.")
    parser.add_argument("--generate_tests", action="store_true", help="enable the ’generate_test_case_from_input’ feature. This will always be disabled in release mode")
    parser.add_argument("--display_sdf_chunks", action="store_true", help="enable the ’display_sdf_chunks’ feature. This will always be disabled in release mode")

    args = parser.parse_args()
    if args.debug and args.release:
        parser.error("--debug and --release cannot be used together.")
    if args.release:
        print("Warning: --release is now the default behavior. Use --debug for debug mode. "
              "The --release flag will be deprecated in a future version.", file=sys.stderr)

    # Set defaults if neither is specified
    if not args.debug and not args.release:
        args.release = True
        args.debug = False
    else:
        args.release = not args.debug

    return args


def validate_rust_project():
    """Check for Cargo.toml and verify project name."""
    if not os.path.isfile("Cargo.toml"):
        print("Error: Cargo.toml not found.")
        exit(1)
    with open("Cargo.toml", "r") as f:
        content = f.read()
        if "name = \"hallr\"" not in content:
            print("Error: The Cargo.toml file does not specify the project name as 'hallr'. Are you in the correct cwd?")
            exit(1)


def run_cargo_build(dev_mode, debug, generate_tests, display_sdf_chunks):
    """Execute the cargo build command with appropriate flags.

    Args:
        dev_mode: Whether to build in development mode
        debug: Whether to build with debug symbols (implies dev_mode)
        generate_tests: Whether to generate test cases (only works in dev_mode)
        display_sdf_chunks: Whether to display the sdf chunk boundaries in the generated mesh (only works in dev_mode)
    """
    feature_args = []
    if generate_tests or display_sdf_chunks:
        if not dev_mode:
            print("Warning: Test features is only available in dev_mode. Ignoring --generate_tests and --display_sdf_chunks flags.")
        else:
            if generate_tests:
                feature_args.append("generate_test_case_from_input")
            if display_sdf_chunks:
                feature_args.append("display_sdf_chunks")
            if feature_args:
                feature_args = ["--features=" + ",".join(feature_args)]

    # Build base command
    if not dev_mode:
        # Production build with maximum optimizations
        command = ["cargo", "rustc", "--release", "--crate-type=cdylib"]
        command.extend(feature_args)  # Will be empty in production
        command.extend(["--", "-C", "opt-level=3", "-C", "lto=fat"])
    elif debug:
        # Debug build (implies dev_mode)
        command = ["cargo", "build"]
        command.extend(feature_args)
    else:
        # Dev release build
        command = ["cargo", "build", "--release"]
        command.extend(feature_args)

    print(f"running : {command}")
    result = subprocess.run(command)
    if result.returncode != 0:
        print("Cargo build failed.")
        exit(1)


def copy_python_files(source_dir, dest_dir, max_depth=1, max_files=30):
    """Copy blender addon files files from the source to the destination directory."""
    clear_directory(os.path.join(dest_dir, "__pycache__"), delete_root=True)
    py_files = glob.glob(f"{source_dir}/*.py")
    os.makedirs(dest_dir, exist_ok=True)
    file_count = 0
    for root, dirs, files in os.walk(source_dir):
        # Calculate current depth
        current_depth = root[len(source_dir):].count(os.sep)

        # Skip if beyond max depth
        if current_depth > max_depth:
            # Remove subdirectories from dirs to prevent os.walk from going deeper
            dirs[:] = []
            continue

        for file in files:
            if file.endswith('.py'):
                file_count += 1
                if file_count > max_files:
                    print(f"Safety limit reached: Stopped at {max_files} files")
                    return
                source_file = os.path.join(root, file)
                rel_path = os.path.relpath(source_file, source_dir)
                dest_file = os.path.join(dest_dir, rel_path)

                dest_dir_path = os.path.dirname(dest_file)
                os.makedirs(dest_dir_path, exist_ok=True)

                if os.path.isfile(dest_file):
                    os.chmod(dest_file, 0o666)
                shutil.copy(source_file, dest_file)
                print(f"Copied Python file: {source_file} -> {dest_file}")

    # Copy blender_manifest.toml specifically
    manifest_source = os.path.join(source_dir, "blender_manifest.toml")
    manifest_dest = os.path.join(dest_dir, "blender_manifest.toml")
    if os.path.exists(manifest_source):
        if os.path.isfile(manifest_dest):
            os.chmod(manifest_dest, 0o666)
        shutil.copy(manifest_source, manifest_dest)
        print(f"Copied manifest: {manifest_source} -> {manifest_dest}")


def process_python_files(addon_exported_path, dev_mode, max_depth=1, max_files=30):
    """Perform replacements in exported Python files."""
    addon_exported_path = os.path.join(os.getcwd(), addon_exported_path)
    target_release_path = os.path.join(addon_exported_path, "lib")

    file_count = 0
    for root, dirs, files in os.walk(addon_exported_path):
            # Calculate current depth
            current_depth = root[len(addon_exported_path):].count(os.sep)

            # Skip if beyond max depth
            if current_depth > max_depth:
                # Remove subdirectories from dirs to prevent os.walk from going deeper
                dirs[:] = []
                continue

            for file in files:
                if file.endswith('.py'):
                    file_count += 1
                    if file_count > max_files:
                        print(f"Safety limit reached: Stopped at {max_files} files")
                        return
                    file = os.path.join(root, file)

                    if args.dev_mode:
                        os.chmod(file, 0o666)  # Make writable before overwrite
                        with open(file, 'r') as f:
                            content = f.read()

                        content = content.replace('HALLR__BLENDER_ADDON_PATH', addon_exported_path)
                        content = content.replace('HALLR__TARGET_RELEASE', target_release_path)
                        content = content.replace('DEV_MODE = False', 'DEV_MODE = True')

                        with open(file, 'w') as f:
                            f.write(content)
                    os.chmod(file, 0o444)  # Read-only for everyone
                    print(f"Processed Python file: {file}")


def copy_library_files(dev_mode, debug, dest_lib_directory):
    """Copy built library files to the destination directory."""
    timestamp = str(int(time.time()))
    build_type = "debug" if debug else "release"
    target_dir = os.path.join("target", build_type)

    is_windows = platform.system() == "Windows"
    library_extension = ".dll" if is_windows else ".so" if platform.system() == "Linux" else ".dylib"

    # Use different prefix patterns based on platform
    lib_prefix = "" if is_windows else "lib"

    # Find library files with the correct pattern for the platform
    lib_files = [f for f in os.listdir(target_dir)
                if f.startswith(f"{lib_prefix}hallr") and f.endswith(library_extension)]

    if not lib_files:
        print(f"No library files found in {target_dir}.")
        exit(1)

    clear_directory(dest_lib_directory)
    # Check if the destination directory exists, and create it if not
    os.makedirs(dest_lib_directory, exist_ok=True)

    for lib_file in lib_files:
        new_name = os.path.join(dest_lib_directory, lib_file)
        if dev_mode:
            # For consistent naming in dev mode, potentially add lib prefix on Windows
            new_name = os.path.join(dest_lib_directory,
                                   f"{lib_prefix}hallr_{timestamp}{library_extension}")
        lib_file = os.path.join(target_dir, lib_file)
        shutil.copy(lib_file, new_name)
        os.chmod(new_name, 0o444)  # Read-only for everyone
        print(f"Copied {lib_file} to {new_name}")


def package_addon(dev_mode):
    """Package the add-on into a zip file."""
    base_dir = os.getcwd()
    export_dir = "blender_addon_exported"
    if dev_mode:
        print(f"Updated the files under blender_addon_exported.")
        print(f"Use blender to open and run the script: {export_dir}/__init__.py")
    else:
        shutil.move(export_dir, "hallr")
        shutil.make_archive("hallr", "zip", root_dir=base_dir, base_dir="hallr")
        shutil.move("hallr", export_dir)
        print("Created a new hallr.zip file.")
        print(f"Use blender to open the add-on: {os.path.join(base_dir, 'hallr.zip')}")


if __name__ == "__main__":
    args = parse_arguments()
    validate_rust_project()
    run_cargo_build(args.dev_mode, args.debug, args.generate_tests, args.display_sdf_chunks)
    staging_area = "blender_addon_exported"
    dest_lib_directory = os.path.join(staging_area, "lib")
    copy_library_files(args.dev_mode,args.debug, dest_lib_directory)
    copy_python_files("blender_addon", staging_area)
    process_python_files(staging_area, args.dev_mode)
    package_addon(args.dev_mode)