From 7edc090ac0b26ca97601d74e8a2eee233a8fc4f4 Mon Sep 17 00:00:00 2001 From: Dorian Ros Date: Fri, 26 Aug 2022 11:23:38 +0200 Subject: [PATCH 1/5] Nodes: Introduce custom shader nodes for Mitsuba BSDFs --- .github/workflows/test.yml | 11 +- mitsuba-blender/__init__.py | 275 ++--- mitsuba-blender/engine/final.py | 8 +- mitsuba-blender/engine/properties.py | 42 +- mitsuba-blender/{io => }/exporter/__init__.py | 15 +- mitsuba-blender/{io => }/exporter/camera.py | 0 .../{io => }/exporter/export_context.py | 0 mitsuba-blender/{io => }/exporter/geometry.py | 3 +- mitsuba-blender/{io => }/exporter/lights.py | 0 .../{io => }/exporter/materials.py | 23 +- mitsuba-blender/{io => }/importer/__init__.py | 56 +- .../{io => }/importer/bl_image_utils.py | 0 .../{io => }/importer/bl_import_obj.py | 0 .../{io => }/importer/bl_import_ply.py | 0 .../{io => }/importer/bl_transform_utils.py | 0 mitsuba-blender/{io => }/importer/common.py | 3 +- mitsuba-blender/{io => }/importer/emitters.py | 0 mitsuba-blender/importer/films.py | 79 ++ mitsuba-blender/importer/integrators.py | 64 ++ mitsuba-blender/importer/materials.py | 1004 +++++++++++++++++ .../{io => }/importer/mi_props_utils.py | 0 .../{io => }/importer/mi_spectra_utils.py | 0 mitsuba-blender/importer/rfilters.py | 57 + mitsuba-blender/importer/samplers.py | 70 ++ mitsuba-blender/{io => }/importer/sensors.py | 0 mitsuba-blender/{io => }/importer/shapes.py | 8 +- mitsuba-blender/{io => }/importer/textures.py | 12 +- mitsuba-blender/{io => }/importer/world.py | 92 +- mitsuba-blender/io/bl_utils.py | 100 -- .../io/importer/bl_shader_utils.py | 243 ---- mitsuba-blender/io/importer/materials.py | 628 ----------- mitsuba-blender/io/importer/renderer.py | 299 ----- mitsuba-blender/nodes/__init__.py | 13 + mitsuba-blender/nodes/base.py | 158 +++ mitsuba-blender/nodes/materials/__init__.py | 40 + mitsuba-blender/nodes/materials/blend.py | 30 + mitsuba-blender/nodes/materials/bumpmap.py | 40 + mitsuba-blender/nodes/materials/conductor.py | 58 + mitsuba-blender/nodes/materials/dielectric.py | 90 ++ mitsuba-blender/nodes/materials/diffuse.py | 20 + mitsuba-blender/nodes/materials/mask.py | 27 + mitsuba-blender/nodes/materials/nodetree.py | 64 ++ mitsuba-blender/nodes/materials/normalmap.py | 27 + mitsuba-blender/nodes/materials/null.py | 17 + mitsuba-blender/nodes/materials/output.py | 22 + mitsuba-blender/nodes/materials/plastic.py | 71 ++ mitsuba-blender/nodes/materials/principled.py | 47 + mitsuba-blender/nodes/materials/twosided.py | 29 + mitsuba-blender/nodes/materials/utils.py | 252 +++++ mitsuba-blender/nodes/sockets.py | 90 ++ mitsuba-blender/nodes/textures/__init__.py | 18 + mitsuba-blender/nodes/textures/bitmap.py | 70 ++ .../nodes/textures/checkerboard.py | 26 + mitsuba-blender/nodes/transforms/__init__.py | 17 + .../nodes/transforms/transform2d.py | 47 + mitsuba-blender/operators/__init__.py | 26 + mitsuba-blender/operators/material.py | 131 +++ mitsuba-blender/operators/nodetree.py | 88 ++ .../{io/__init__.py => operators/scene.py} | 139 ++- mitsuba-blender/operators/utils.py | 23 + mitsuba-blender/properties/__init__.py | 17 + mitsuba-blender/properties/material.py | 21 + mitsuba-blender/ui/__init__.py | 23 + mitsuba-blender/ui/exporter.py | 10 + mitsuba-blender/ui/importer.py | 10 + mitsuba-blender/ui/material.py | 85 ++ mitsuba-blender/ui/node_editor.py | 244 ++++ mitsuba-blender/utils/__init__.py | 40 + mitsuba-blender/utils/material.py | 7 + mitsuba-blender/utils/math.py | 18 + mitsuba-blender/utils/nodetree.py | 236 ++++ scripts/install_addon.py | 33 + scripts/run_tests.py | 78 +- tests/{res => engine}/scenes/film_hdrfilm.xml | 0 .../scenes/film_hdrfilm_crop.xml | 0 .../scenes/integrator_moment.xml | 2 +- .../scenes/integrator_path.xml | 0 tests/{res => engine}/scenes/rfilter_box.xml | 0 .../scenes/rfilter_gaussian.xml | 0 tests/{res => engine}/scenes/rfilter_tent.xml | 0 .../scenes/sampler_independent.xml | 0 .../scenes/sampler_multijitter.xml | 0 .../scenes/sampler_stratified.xml | 0 tests/engine/test_films.py | 13 + tests/engine/test_integrators.py | 12 + tests/engine/test_rfilters.py | 13 + tests/engine/test_samplers.py | 13 + tests/fixtures/__init__.py | 97 +- tests/{res => importer}/scenes/empty.xml | 0 tests/importer/test_importer.py | 70 ++ tests/nodes/scenes/blendbsdf.xml | 13 + tests/nodes/scenes/bumpmap.xml | 13 + tests/nodes/scenes/conductor.xml | 12 + tests/nodes/scenes/dielectric.xml | 14 + tests/nodes/scenes/diffuse.xml | 11 + tests/nodes/scenes/mask.xml | 12 + tests/nodes/scenes/normalmap.xml | 15 + tests/nodes/scenes/null.xml | 10 + tests/nodes/scenes/plastic.xml | 15 + tests/nodes/scenes/principled.xml | 22 + .../scenes/roughconductor_anisotropic.xml | 16 + .../nodes/scenes/roughconductor_isotropic.xml | 15 + .../scenes/roughdielectric_anisotropic.xml | 18 + .../scenes/roughdielectric_isotropic.xml | 17 + tests/nodes/scenes/roughplastic.xml | 18 + tests/nodes/scenes/textures/blank.png | Bin 0 -> 128 bytes tests/nodes/scenes/thindielectric.xml | 14 + tests/nodes/scenes/twosided_1.xml | 11 + tests/nodes/scenes/twosided_2.xml | 12 + tests/nodes/test_materials.py | 28 + tests/res/scenes/meshes/Cube.ply | Bin 641 -> 0 bytes tests/res/scenes/test1.xml | 70 -- tests/test_addon.py | 17 - tests/test_compare.py | 34 +- tests/test_importer.py | 229 ---- tests/test_mitsuba.py | 4 - tests/utils/__init__.py | 1 - tests/utils/mi_scene_utils.py | 38 - 118 files changed, 4491 insertions(+), 2002 deletions(-) rename mitsuba-blender/{io => }/exporter/__init__.py (91%) rename mitsuba-blender/{io => }/exporter/camera.py (100%) rename mitsuba-blender/{io => }/exporter/export_context.py (100%) rename mitsuba-blender/{io => }/exporter/geometry.py (98%) rename mitsuba-blender/{io => }/exporter/lights.py (100%) rename mitsuba-blender/{io => }/exporter/materials.py (95%) rename mitsuba-blender/{io => }/importer/__init__.py (92%) rename mitsuba-blender/{io => }/importer/bl_image_utils.py (100%) rename mitsuba-blender/{io => }/importer/bl_import_obj.py (100%) rename mitsuba-blender/{io => }/importer/bl_import_ply.py (100%) rename mitsuba-blender/{io => }/importer/bl_transform_utils.py (100%) rename mitsuba-blender/{io => }/importer/common.py (98%) rename mitsuba-blender/{io => }/importer/emitters.py (100%) create mode 100644 mitsuba-blender/importer/films.py create mode 100644 mitsuba-blender/importer/integrators.py create mode 100644 mitsuba-blender/importer/materials.py rename mitsuba-blender/{io => }/importer/mi_props_utils.py (100%) rename mitsuba-blender/{io => }/importer/mi_spectra_utils.py (100%) create mode 100644 mitsuba-blender/importer/rfilters.py create mode 100644 mitsuba-blender/importer/samplers.py rename mitsuba-blender/{io => }/importer/sensors.py (100%) rename mitsuba-blender/{io => }/importer/shapes.py (94%) rename mitsuba-blender/{io => }/importer/textures.py (64%) rename mitsuba-blender/{io => }/importer/world.py (54%) delete mode 100644 mitsuba-blender/io/bl_utils.py delete mode 100644 mitsuba-blender/io/importer/bl_shader_utils.py delete mode 100644 mitsuba-blender/io/importer/materials.py delete mode 100644 mitsuba-blender/io/importer/renderer.py create mode 100644 mitsuba-blender/nodes/__init__.py create mode 100644 mitsuba-blender/nodes/base.py create mode 100644 mitsuba-blender/nodes/materials/__init__.py create mode 100644 mitsuba-blender/nodes/materials/blend.py create mode 100644 mitsuba-blender/nodes/materials/bumpmap.py create mode 100644 mitsuba-blender/nodes/materials/conductor.py create mode 100644 mitsuba-blender/nodes/materials/dielectric.py create mode 100644 mitsuba-blender/nodes/materials/diffuse.py create mode 100644 mitsuba-blender/nodes/materials/mask.py create mode 100644 mitsuba-blender/nodes/materials/nodetree.py create mode 100644 mitsuba-blender/nodes/materials/normalmap.py create mode 100644 mitsuba-blender/nodes/materials/null.py create mode 100644 mitsuba-blender/nodes/materials/output.py create mode 100644 mitsuba-blender/nodes/materials/plastic.py create mode 100644 mitsuba-blender/nodes/materials/principled.py create mode 100644 mitsuba-blender/nodes/materials/twosided.py create mode 100644 mitsuba-blender/nodes/materials/utils.py create mode 100644 mitsuba-blender/nodes/sockets.py create mode 100644 mitsuba-blender/nodes/textures/__init__.py create mode 100644 mitsuba-blender/nodes/textures/bitmap.py create mode 100644 mitsuba-blender/nodes/textures/checkerboard.py create mode 100644 mitsuba-blender/nodes/transforms/__init__.py create mode 100644 mitsuba-blender/nodes/transforms/transform2d.py create mode 100644 mitsuba-blender/operators/__init__.py create mode 100644 mitsuba-blender/operators/material.py create mode 100644 mitsuba-blender/operators/nodetree.py rename mitsuba-blender/{io/__init__.py => operators/scene.py} (53%) create mode 100644 mitsuba-blender/operators/utils.py create mode 100644 mitsuba-blender/properties/__init__.py create mode 100644 mitsuba-blender/properties/material.py create mode 100644 mitsuba-blender/ui/__init__.py create mode 100644 mitsuba-blender/ui/exporter.py create mode 100644 mitsuba-blender/ui/importer.py create mode 100644 mitsuba-blender/ui/material.py create mode 100644 mitsuba-blender/ui/node_editor.py create mode 100644 mitsuba-blender/utils/__init__.py create mode 100644 mitsuba-blender/utils/material.py create mode 100644 mitsuba-blender/utils/math.py create mode 100644 mitsuba-blender/utils/nodetree.py create mode 100644 scripts/install_addon.py rename tests/{res => engine}/scenes/film_hdrfilm.xml (100%) rename tests/{res => engine}/scenes/film_hdrfilm_crop.xml (100%) rename tests/{res => engine}/scenes/integrator_moment.xml (85%) rename tests/{res => engine}/scenes/integrator_path.xml (100%) rename tests/{res => engine}/scenes/rfilter_box.xml (100%) rename tests/{res => engine}/scenes/rfilter_gaussian.xml (100%) rename tests/{res => engine}/scenes/rfilter_tent.xml (100%) rename tests/{res => engine}/scenes/sampler_independent.xml (100%) rename tests/{res => engine}/scenes/sampler_multijitter.xml (100%) rename tests/{res => engine}/scenes/sampler_stratified.xml (100%) create mode 100644 tests/engine/test_films.py create mode 100644 tests/engine/test_integrators.py create mode 100644 tests/engine/test_rfilters.py create mode 100644 tests/engine/test_samplers.py rename tests/{res => importer}/scenes/empty.xml (100%) create mode 100644 tests/importer/test_importer.py create mode 100644 tests/nodes/scenes/blendbsdf.xml create mode 100644 tests/nodes/scenes/bumpmap.xml create mode 100644 tests/nodes/scenes/conductor.xml create mode 100644 tests/nodes/scenes/dielectric.xml create mode 100644 tests/nodes/scenes/diffuse.xml create mode 100644 tests/nodes/scenes/mask.xml create mode 100644 tests/nodes/scenes/normalmap.xml create mode 100644 tests/nodes/scenes/null.xml create mode 100644 tests/nodes/scenes/plastic.xml create mode 100644 tests/nodes/scenes/principled.xml create mode 100644 tests/nodes/scenes/roughconductor_anisotropic.xml create mode 100644 tests/nodes/scenes/roughconductor_isotropic.xml create mode 100644 tests/nodes/scenes/roughdielectric_anisotropic.xml create mode 100644 tests/nodes/scenes/roughdielectric_isotropic.xml create mode 100644 tests/nodes/scenes/roughplastic.xml create mode 100644 tests/nodes/scenes/textures/blank.png create mode 100644 tests/nodes/scenes/thindielectric.xml create mode 100644 tests/nodes/scenes/twosided_1.xml create mode 100644 tests/nodes/scenes/twosided_2.xml create mode 100644 tests/nodes/test_materials.py delete mode 100644 tests/res/scenes/meshes/Cube.ply delete mode 100644 tests/res/scenes/test1.xml delete mode 100644 tests/test_addon.py delete mode 100644 tests/test_importer.py delete mode 100644 tests/test_mitsuba.py delete mode 100644 tests/utils/__init__.py delete mode 100644 tests/utils/mi_scene_utils.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 745c9e2..05415fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ on: # Workflow steps jobs: - build: + test: name: "${{ matrix.environment.os }} | Blender ${{ matrix.blender.version }}" runs-on: ${{ matrix.environment.os }} strategy: @@ -81,4 +81,11 @@ jobs: run: | BLENDER_EXECUTABLE=$(find blender/ -maxdepth 1 -regextype posix-extended -regex '.*blender(.exe)?' -print -quit) echo "Blender Executable is $BLENDER_EXECUTABLE" - ./$BLENDER_EXECUTABLE -b -noaudio --factory-startup --python scripts/run_tests.py -- -v --cov=mitsuba-blender + ./$BLENDER_EXECUTABLE -b -noaudio --factory-startup --python scripts/run_tests.py -- -v --cov=mitsuba-blender --local-tmp + + - name: Recover crash logs + if: ${{ failure() }} + uses: actions/upload-artifact@v3 + with: + name: "crash-logs-${{ matrix.environment.os }}-blender-${{ matrix.blender.version }}" + path: tmp/*crash*.txt diff --git a/mitsuba-blender/__init__.py b/mitsuba-blender/__init__.py index e8db00e..baf78f3 100644 --- a/mitsuba-blender/__init__.py +++ b/mitsuba-blender/__init__.py @@ -18,22 +18,35 @@ import os import sys -import subprocess -from . import io, engine +from . import ( + engine, nodes, properties, operators, ui +) + +from .utils import pip_ensure, pip_install_package, pip_package_version DEPS_MITSUBA_VERSION = '3.0.1' def get_addon_preferences(context): return context.preferences.addons[__name__].preferences -def init_mitsuba(context): - # Make sure we can load mitsuba from blender +def get_addon_version_string(): + return f'{".".join(str(e) for e in bl_info["version"])}{bl_info["warning"] if "warning" in bl_info else ""}' + +def get_mitsuba_version_string(): + import mitsuba + return mitsuba.__version__ + +def get_addon_info_string(): + return f'mitsuba-blender v{get_addon_version_string()} registered (with mitsuba v{get_mitsuba_version_string()})' + +def init_mitsuba(): + # Make sure we can load Mitsuba from Blender try: os.environ['DRJIT_NO_RTLD_DEEPBIND'] = 'True' should_reload_mitsuba = 'mitsuba' in sys.modules import mitsuba - # If mitsuba was already loaded and we change the path, we need to reload it, since the import above will be ignored + # If Mitsuba was already loaded and we change the path, we need to reload it, since the import above will be ignored if should_reload_mitsuba: import importlib importlib.reload(mitsuba) @@ -45,151 +58,88 @@ def init_mitsuba(context): except ModuleNotFoundError: return False -def try_register_mitsuba(context): +def register_addon(context): prefs = get_addon_preferences(context) - prefs.mitsuba_dependencies_status_message = '' + prefs.status_message = '' - could_init_mitsuba = False if prefs.using_mitsuba_custom_path: - update_additional_custom_paths(prefs, context) - could_init_mitsuba = init_mitsuba(context) + prefs.update_additional_custom_paths(context) + could_init_mitsuba = init_mitsuba() if could_init_mitsuba: - import mitsuba - prefs.mitsuba_custom_version = mitsuba.__version__ + prefs.mitsuba_custom_version = get_mitsuba_version_string() if prefs.has_valid_mitsuba_custom_version: - prefs.mitsuba_dependencies_status_message = f'Found custom Mitsuba v{prefs.mitsuba_custom_version}.' + prefs.status_message = f'Found custom Mitsuba v{prefs.mitsuba_custom_version}.' else: - prefs.mitsuba_dependencies_status_message = f'Found custom Mitsuba v{prefs.mitsuba_custom_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' + prefs.status_message = f'Found custom Mitsuba v{prefs.mitsuba_custom_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' else: - prefs.mitsuba_dependencies_status_message = 'Failed to load custom Mitsuba. Please verify the path to the build directory.' - elif prefs.has_pip_dependencies: + prefs.status_message = 'Failed to load custom Mitsuba. Please verify the path to the build directory.' + elif prefs.is_mitsuba_installed: if prefs.has_valid_dependencies_version: - could_init_mitsuba = init_mitsuba(context) + could_init_mitsuba = init_mitsuba() if could_init_mitsuba: - import mitsuba - prefs.mitsuba_dependencies_status_message = f'Found pip Mitsuba v{mitsuba.__version__}.' + prefs.status_message = f'Found pip Mitsuba v{get_mitsuba_version_string()}.' else: - prefs.mitsuba_dependencies_status_message = 'Failed to load Mitsuba package.' + prefs.status_message = 'Failed to load Mitsuba package.' else: - prefs.mitsuba_dependencies_status_message = f'Found pip Mitsuba v{prefs.installed_dependencies_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' + prefs.status_message = f'Found pip Mitsuba v{prefs.installed_dependencies_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' else: - prefs.mitsuba_dependencies_status_message = 'Mitsuba dependencies not installed.' + prefs.status_message = 'Mitsuba dependencies not installed.' prefs.is_mitsuba_initialized = could_init_mitsuba if could_init_mitsuba: - io.register() + properties.register() + operators.register() + ui.register() engine.register() + nodes.register() return could_init_mitsuba -def try_unregister_mitsuba(): +def unregister_addon(): ''' Try unregistering Addon classes. This may fail if Mitsuba wasn't found, hence the try catch guard ''' try: - io.unregister() + nodes.unregister() engine.unregister() + ui.unregister() + operators.unregister() + properties.unregister() return True except RuntimeError: return False -def try_reload_mitsuba(context): - try_unregister_mitsuba() - if try_register_mitsuba(context): +def reload_addon(context): + unregister_addon() + if register_addon(context): # Save user preferences bpy.ops.wm.save_userpref() -def ensure_pip(): - result = subprocess.run([sys.executable, '-m', 'ensurepip'], capture_output=True) - return result.returncode == 0 - -def check_pip_dependencies(context): - prefs = get_addon_preferences(context) - result = subprocess.run([sys.executable, '-m', 'pip', 'list'], capture_output=True) - - prefs.has_pip_dependencies = False - prefs.has_valid_dependencies_version = False - - if result.returncode == 0: - output_str = result.stdout.decode('utf-8') - lines = output_str.splitlines(keepends=False) - for line in lines: - parts = line.split() - if len(parts) >= 2 and parts[0] == 'mitsuba': - prefs.has_pip_dependencies = True - prefs.installed_dependencies_version = parts[1] - -def clean_additional_custom_paths(self, context): - # Remove old values from system PATH and sys.path - if self.additional_python_path in sys.path: - sys.path.remove(self.additional_python_path) - if self.additional_path and self.additional_path in os.environ['PATH']: - items = os.environ['PATH'].split(os.pathsep) - items.remove(self.additional_path) - os.environ['PATH'] = os.pathsep.join(items) - -def update_additional_custom_paths(self, context): - build_path = bpy.path.abspath(self.mitsuba_custom_path) - if len(build_path) > 0: - clean_additional_custom_paths(self, context) - - # Add path to the binaries to the system PATH - self.additional_path = build_path - if self.additional_path not in os.environ['PATH']: - os.environ['PATH'] += os.pathsep + self.additional_path - - # Add path to python libs to sys.path - self.additional_python_path = os.path.join(build_path, 'python') - if self.additional_python_path not in sys.path: - # NOTE: We insert in the first position here, so that the custom path - # supersede the pip version - sys.path.insert(0, self.additional_python_path) - -class MITSUBA_OT_install_pip_dependencies(Operator): - bl_idname = 'mitsuba.install_pip_dependencies' - bl_label = 'Install Mitsuba pip dependencies' - bl_description = 'Use pip to install the add-on\'s required dependencies' +class MITSUBA_OT_download_package_dependencies(Operator): + bl_idname = 'mitsuba.download_package_dependencies' + bl_label = 'Download the recommended Mitsuba dependencies' + bl_description = 'Use pip to download the add-on\'s required dependencies' @classmethod def poll(cls, context): prefs = get_addon_preferences(context) - return not prefs.has_pip_dependencies or not prefs.has_valid_dependencies_version + return not prefs.is_mitsuba_installed or not prefs.has_valid_dependencies_version def execute(self, context): - result = subprocess.run([sys.executable, '-m', 'pip', 'install', f'mitsuba=={DEPS_MITSUBA_VERSION}', '--force-reinstall'], capture_output=False) - if result.returncode != 0: - self.report({'ERROR'}, f'Failed to install Mitsuba with return code {result.returncode}.') - return {'CANCELLED'} + if not pip_install_package('mitsuba', version=DEPS_MITSUBA_VERSION): + self.report({'ERROR'}, 'Failed to download Mitsuba package with pip.') + return {'CANCELLED'} - check_pip_dependencies(context) + prefs = get_addon_preferences(context) + prefs.is_mitsuba_installed = True + prefs.installed_dependencies_version = DEPS_MITSUBA_VERSION - try_reload_mitsuba(context) + reload_addon(context) return {'FINISHED'} -def update_using_mitsuba_custom_path(self, context): - self.require_restart = True - if self.using_mitsuba_custom_path: - update_mitsuba_custom_path(self, context) - else: - clean_additional_custom_paths(self, context) - -def update_mitsuba_custom_path(self, context): - if self.is_mitsuba_initialized: - self.require_restart = True - if self.using_mitsuba_custom_path and len(self.mitsuba_custom_path) > 0: - update_additional_custom_paths(self, context) - if not self.is_mitsuba_initialized: - try_reload_mitsuba(context) - -def update_installed_dependencies_version(self, context): - self.has_valid_dependencies_version = self.installed_dependencies_version == DEPS_MITSUBA_VERSION - -def update_mitsuba_custom_version(self, context): - self.has_valid_mitsuba_custom_version = self.mitsuba_custom_version == DEPS_MITSUBA_VERSION - class MitsubaPreferences(AddonPreferences): bl_idname = __name__ @@ -197,10 +147,17 @@ class MitsubaPreferences(AddonPreferences): name = 'Is Mitsuba initialized', ) - has_pip_dependencies : BoolProperty( - name = 'Has pip dependencies installed', + is_mitsuba_installed : BoolProperty( + name = 'Is the Mitsuba package installed', + ) + + is_restart_required : BoolProperty( + name = 'Is a Blender restart required', ) + def update_installed_dependencies_version(self, context): + self.has_valid_dependencies_version = self.installed_dependencies_version == DEPS_MITSUBA_VERSION + installed_dependencies_version : StringProperty( name = 'Installed Mitsuba dependencies version string', default = '', @@ -211,22 +168,72 @@ class MitsubaPreferences(AddonPreferences): name = 'Has the correct version of dependencies' ) - mitsuba_dependencies_status_message : StringProperty( - name = 'Mitsuba dependencies status message', + status_message : StringProperty( + name = 'Add-on status message', default = '', ) - require_restart : BoolProperty( - name = 'Require a Blender restart', - ) - # Advanced settings + def clean_additional_custom_paths(self, context): + # Remove old values from system PATH and sys.path + if self.additional_python_path in sys.path: + sys.path.remove(self.additional_python_path) + if self.additional_path and self.additional_path in os.environ['PATH']: + items = os.environ['PATH'].split(os.pathsep) + items.remove(self.additional_path) + os.environ['PATH'] = os.pathsep.join(items) + + def update_additional_custom_paths(self, context): + build_path = bpy.path.abspath(self.mitsuba_custom_path) + if len(build_path) > 0: + self.clean_additional_custom_paths(context) + + # Add path to the binaries to the system PATH + self.additional_path = build_path + if self.additional_path not in os.environ['PATH']: + os.environ['PATH'] += os.pathsep + self.additional_path + + # Add path to python libs to sys.path + self.additional_python_path = os.path.join(build_path, 'python') + if self.additional_python_path not in sys.path: + # NOTE: We insert in the first position here, so that the custom path + # supersede the pip version + sys.path.insert(0, self.additional_python_path) + + def update_mitsuba_custom_path(self, context): + if self.is_mitsuba_initialized: + self.is_restart_required = True + if self.using_mitsuba_custom_path and len(self.mitsuba_custom_path) > 0: + self.update_additional_custom_paths(context) + if not self.is_mitsuba_initialized: + reload_addon(context) + + def update_using_mitsuba_custom_path(self, context): + self.require_restart = True + if self.using_mitsuba_custom_path: + self.update_mitsuba_custom_path(context) + else: + self.clean_additional_custom_paths(context) + + def update_mitsuba_custom_version(self, context): + self.has_valid_mitsuba_custom_version = self.mitsuba_custom_version == DEPS_MITSUBA_VERSION + using_mitsuba_custom_path : BoolProperty( name = 'Using custom Mitsuba path', update = update_using_mitsuba_custom_path, ) + mitsuba_custom_version : StringProperty( + name = 'Custom Mitsuba build version', + default = '', + update = update_mitsuba_custom_version, + ) + + has_valid_mitsuba_custom_version : BoolProperty( + name = 'Has the correct version of custom Mitsuba build' + ) + mitsuba_custom_path : StringProperty( name = 'Custom Mitsuba path', description = 'Path to the custom Mitsuba build directory', @@ -263,17 +270,17 @@ def draw(self, context): row = layout.row() icon = 'ERROR' row.alert = True - if self.require_restart: - self.mitsuba_dependencies_status_message = 'A restart is required to apply the changes.' + if self.is_restart_required: + self.status_message = 'A restart is required to apply the changes.' elif self.is_mitsuba_initialized and (not self.using_mitsuba_custom_path or (self.using_mitsuba_custom_path and self.has_valid_mitsuba_custom_version)): icon = 'CHECKMARK' row.alert = False - row.label(text=self.mitsuba_dependencies_status_message, icon=icon) + row.label(text=self.status_message, icon=icon) - operator_text = 'Install dependencies' - if self.has_pip_dependencies and not self.has_valid_dependencies_version: - operator_text = 'Update dependencies' - layout.operator(MITSUBA_OT_install_pip_dependencies.bl_idname, text=operator_text) + download_operator_text = 'Install Mitsuba' + if self.is_mitsuba_installed and not self.has_valid_dependencies_version: + download_operator_text = 'Update Mitsuba' + layout.operator(MITSUBA_OT_download_package_dependencies.bl_idname, text=download_operator_text) box = layout.box() box.label(text='Advanced Settings') @@ -282,7 +289,7 @@ def draw(self, context): box.prop(self, 'mitsuba_custom_path') classes = ( - MITSUBA_OT_install_pip_dependencies, + MITSUBA_OT_download_package_dependencies, MitsubaPreferences, ) @@ -290,19 +297,25 @@ def register(): for cls in classes: register_class(cls) + if not pip_ensure(): + raise RuntimeError('Cannot activate mitsuba-blender add-on. Python pip module cannot be initialized.') + context = bpy.context prefs = get_addon_preferences(context) - prefs.require_restart = False + prefs.is_mitsuba_initialized = False + mitsuba_installed_version = pip_package_version('mitsuba') + prefs.is_mitsuba_installed = mitsuba_installed_version != None + prefs.installed_dependencies_version = mitsuba_installed_version if mitsuba_installed_version is not None else '' + prefs.is_restart_required = False - if not ensure_pip(): - raise RuntimeError('Cannot activate mitsuba-blender add-on. Python pip module cannot be initialized.') - - check_pip_dependencies(context) - if try_register_mitsuba(context): - import mitsuba - print(f'mitsuba-blender v{".".join(str(e) for e in bl_info["version"])}{bl_info["warning"] if "warning" in bl_info else ""} registered (with mitsuba v{mitsuba.__version__})') + if register_addon(context): + print(get_addon_info_string()) def unregister(): for cls in classes: unregister_class(cls) - try_unregister_mitsuba() + if not unregister_addon(): + print('FAILED TO UNREGISTER ADDON') + +if __name__ == '__main__': + register() diff --git a/mitsuba-blender/engine/final.py b/mitsuba-blender/engine/final.py index cecd745..409d621 100644 --- a/mitsuba-blender/engine/final.py +++ b/mitsuba-blender/engine/final.py @@ -2,13 +2,19 @@ import tempfile import os import numpy as np -from ..io.exporter import SceneConverter +from ..exporter import SceneConverter class MitsubaRenderEngine(bpy.types.RenderEngine): bl_idname = "MITSUBA" bl_label = "Mitsuba" bl_use_preview = False + bl_use_texture_preview = False + # Hide Cycles shader nodes in the shading menu + bl_use_shading_nodes_custom = False + # FIXME: This is used to get a visual feedback of the shapes, + # it does not produce a correct result. + bl_use_eevee_viewport = True # Init is called whenever a new render engine instance is created. Multiple # instances may exist at the same time, for example for a viewport and final diff --git a/mitsuba-blender/engine/properties.py b/mitsuba-blender/engine/properties.py index 4eb6194..fbd00d1 100644 --- a/mitsuba-blender/engine/properties.py +++ b/mitsuba-blender/engine/properties.py @@ -397,6 +397,12 @@ class MITSUBA_CAMERA_PT_sampler(bpy.types.Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = 'render' + COMPAT_ENGINES = {'MITSUBA'} + + @classmethod + def poll(cls, context): + return context.engine in cls.COMPAT_ENGINES + def draw(self, context): layout = self.layout if hasattr(context.scene.camera, 'data'): @@ -410,6 +416,12 @@ class MITSUBA_CAMERA_PT_rfilter(bpy.types.Panel): bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = 'render' + COMPAT_ENGINES = {'MITSUBA'} + + @classmethod + def poll(cls, context): + return context.engine in cls.COMPAT_ENGINES + def draw(self, context): layout = self.layout if hasattr(context.scene.camera, 'data'): @@ -417,7 +429,7 @@ def draw(self, context): layout.prop(cam_settings, "active_rfilter", text="Filter") getattr(cam_settings.rfilters, cam_settings.active_rfilter).draw(layout) -def draw_device(self, context): +def mitsuba_render_draw(self, context): scene = context.scene layout = self.layout layout.use_property_split = True @@ -429,18 +441,20 @@ def draw_device(self, context): col = layout.column() col.prop(mts_settings, "variant") -def register(): - bpy.types.RENDER_PT_context.append(draw_device) - bpy.utils.register_class(MitsubaRenderSettings) - bpy.utils.register_class(MitsubaCameraSettings) - bpy.utils.register_class(MITSUBA_RENDER_PT_integrator) - bpy.utils.register_class(MITSUBA_CAMERA_PT_sampler) - bpy.utils.register_class(MITSUBA_CAMERA_PT_rfilter) +classes = [ + MitsubaRenderSettings, + MitsubaCameraSettings, + MITSUBA_RENDER_PT_integrator, + MITSUBA_CAMERA_PT_sampler, + MITSUBA_CAMERA_PT_rfilter, +] +def register(): + bpy.types.RENDER_PT_context.append(mitsuba_render_draw) + for cls in classes: + bpy.utils.register_class(cls) + def unregister(): - bpy.types.RENDER_PT_context.remove(draw_device) - bpy.utils.unregister_class(MitsubaRenderSettings) - bpy.utils.unregister_class(MitsubaCameraSettings) - bpy.utils.unregister_class(MITSUBA_RENDER_PT_integrator) - bpy.utils.unregister_class(MITSUBA_CAMERA_PT_sampler) - bpy.utils.unregister_class(MITSUBA_CAMERA_PT_rfilter) \ No newline at end of file + for cls in classes: + bpy.utils.unregister_class(cls) + bpy.types.RENDER_PT_context.remove(mitsuba_render_draw) diff --git a/mitsuba-blender/io/exporter/__init__.py b/mitsuba-blender/exporter/__init__.py similarity index 91% rename from mitsuba-blender/io/exporter/__init__.py rename to mitsuba-blender/exporter/__init__.py index 52ef7dd..da54c26 100644 --- a/mitsuba-blender/io/exporter/__init__.py +++ b/mitsuba-blender/exporter/__init__.py @@ -1,18 +1,5 @@ import os -if "bpy" in locals(): - import importlib - if "export_context" in locals(): - importlib.reload(export_context) - if "materials" in locals(): - importlib.reload(materials) - if "geometry" in locals(): - importlib.reload(geometry) - if "lights" in locals(): - importlib.reload(lights) - if "camera" in locals(): - importlib.reload(camera) - import bpy from . import export_context @@ -53,7 +40,7 @@ def scene_to_dict(self, depsgraph, window_manager): b_scene = depsgraph.scene #TODO: what if there are multiple scenes? if b_scene.render.engine == 'MITSUBA': - integrator = getattr(b_scene.mitsuba.available_integrators,b_scene.mitsuba.active_integrator).to_dict() + integrator = getattr(b_scene.mitsuba.available_integrators, b_scene.mitsuba.active_integrator).to_dict() else: integrator = { 'type':'path', diff --git a/mitsuba-blender/io/exporter/camera.py b/mitsuba-blender/exporter/camera.py similarity index 100% rename from mitsuba-blender/io/exporter/camera.py rename to mitsuba-blender/exporter/camera.py diff --git a/mitsuba-blender/io/exporter/export_context.py b/mitsuba-blender/exporter/export_context.py similarity index 100% rename from mitsuba-blender/io/exporter/export_context.py rename to mitsuba-blender/exporter/export_context.py diff --git a/mitsuba-blender/io/exporter/geometry.py b/mitsuba-blender/exporter/geometry.py similarity index 98% rename from mitsuba-blender/io/exporter/geometry.py rename to mitsuba-blender/exporter/geometry.py index 0f3b2e1..8b99367 100644 --- a/mitsuba-blender/io/exporter/geometry.py +++ b/mitsuba-blender/exporter/geometry.py @@ -101,7 +101,8 @@ def export_object(deg_instance, export_ctx, is_particle): mat_nr) if mts_mesh is not None and mts_mesh.face_count() > 0: converted_parts.append((mat_nr, mts_mesh)) - export_material(export_ctx, b_mesh.materials[mat_nr]) + b_mat = b_mesh.materials[mat_nr] + export_material(export_ctx, b_mat) if b_object.type != 'MESH': b_object.to_mesh_clear() diff --git a/mitsuba-blender/io/exporter/lights.py b/mitsuba-blender/exporter/lights.py similarity index 100% rename from mitsuba-blender/io/exporter/lights.py rename to mitsuba-blender/exporter/lights.py diff --git a/mitsuba-blender/io/exporter/materials.py b/mitsuba-blender/exporter/materials.py similarity index 95% rename from mitsuba-blender/io/exporter/materials.py rename to mitsuba-blender/exporter/materials.py index b3e5a15..ae49a36 100644 --- a/mitsuba-blender/io/exporter/materials.py +++ b/mitsuba-blender/exporter/materials.py @@ -1,5 +1,6 @@ import numpy as np from mathutils import Matrix +from ..utils.nodetree import get_active_output from .export_context import Files RoughnessMode = {'GGX': 'ggx', 'BECKMANN': 'beckmann', 'ASHIKHMIN_SHIRLEY':'beckmann', 'MULTI_GGX':'ggx'} @@ -340,22 +341,37 @@ def get_dummy_material(export_ctx): def b_material_to_dict(export_ctx, b_mat): ''' Converting one material from Blender / Cycles to Mitsuba''' + # NOTE: The evaluated material does not keep references to Mitsuba node trees. + # We need to use the original material instead. + original_mat = b_mat.original mat_params = {} - if b_mat.use_nodes: + if original_mat.mitsuba.node_tree is not None: + output_node = get_active_output(original_mat.mitsuba.node_tree) + if output_node is not None: + mat_params = output_node.to_dict(export_ctx) + else: + export_ctx.log(f'Material {b_mat.name} does not have an output node.', 'ERROR') + + elif b_mat.use_nodes: try: output_node_id = 'Material Output' if output_node_id in b_mat.node_tree.nodes: output_node = b_mat.node_tree.nodes[output_node_id] - surface_node = output_node.inputs["Surface"].links[0].from_node - mat_params = cycles_material_to_dict(export_ctx, surface_node) + if len(output_node.inputs['Surface'].links) > 0: + surface_node = output_node.inputs["Surface"].links[0].from_node + mat_params = cycles_material_to_dict(export_ctx, surface_node) + else: + export_ctx.log(f'Export of material {b_mat.name} failed: Output node is not connected. Exporting a dummy material instead.', 'WARN') + mat_params = get_dummy_material(export_ctx) else: export_ctx.log(f'Export of material {b_mat.name} failed: Cannot find material output node. Exporting a dummy material instead.', 'WARN') mat_params = get_dummy_material(export_ctx) except NotImplementedError as e: export_ctx.log(f'Export of material \'{b_mat.name}\' failed: {e.args[0]}. Exporting a dummy material instead.', 'WARN') mat_params = get_dummy_material(export_ctx) + else: mat_params = {'type':'diffuse'} mat_params['reflectance'] = export_ctx.spectrum(b_mat.diffuse_color) @@ -500,6 +516,7 @@ def convert_world(export_ctx, world, ignore_background): 'type': 'constant', 'radiance': export_ctx.spectrum(radiance) }) + else: raise NotImplementedError("Only Background and Emission nodes are supported as final nodes for World export, got '%s'" % surface_node.name) else: diff --git a/mitsuba-blender/io/importer/__init__.py b/mitsuba-blender/importer/__init__.py similarity index 92% rename from mitsuba-blender/io/importer/__init__.py rename to mitsuba-blender/importer/__init__.py index db55a16..11386ec 100644 --- a/mitsuba-blender/io/importer/__init__.py +++ b/mitsuba-blender/importer/__init__.py @@ -1,26 +1,5 @@ import time -if "bpy" in locals(): - import importlib - if "common" in locals(): - importlib.reload(common) - if "materials" in locals(): - importlib.reload(materials) - if "shapes" in locals(): - importlib.reload(shapes) - if "cameras" in locals(): - importlib.reload(sensors) - if "emitters" in locals(): - importlib.reload(emitters) - if "world" in locals(): - importlib.reload(world) - if "textures" in locals(): - importlib.reload(textures) - if "renderer" in locals(): - importlib.reload(renderer) - if "mi_props_utils" in locals(): - importlib.reload(mi_props_utils) - import bpy from . import common @@ -30,7 +9,10 @@ from . import sensors from . import world from . import textures -from . import renderer +from . import integrators +from . import rfilters +from . import samplers +from . import films from . import mi_props_utils ######################## @@ -50,6 +32,15 @@ def _convert_named_references(mi_context, mi_props, parent_node, type_filter=[]) if child_node is not None: parent_node.add_child(child_node) +def _init_mitsuba_renderer(mi_context): + mi_context.bl_scene.render.engine = 'MITSUBA' + mi_renderer = mi_context.bl_scene.mitsuba + if 'scalar_rgb' not in mi_renderer.variants(): + mi_context.log('Mitsuba variant "scalar_rgb" not available.', 'ERROR') + return False + mi_renderer.variant = 'scalar_rgb' + return True + ######################## ## Scene convertion ## ######################## @@ -174,10 +165,6 @@ def mi_shape_to_bl_node(mi_context, mi_props): return node def mi_texture_to_bl_node(mi_context, mi_props): - # We only parse bitmap textures - if mi_props.plugin_name() != 'bitmap': - return None - node = common.create_blender_node(common.BlenderNodeType.IMAGE, id=mi_props.id()) # Convert dependencies if any _convert_named_references(mi_context, mi_props, node) @@ -288,7 +275,7 @@ def instantiate_bl_object_node(mi_context, bl_node): return True def instantiate_film_properties_node(mi_context, bl_node): - if not renderer.apply_mi_film_properties(mi_context, bl_node.mi_props): + if not films.apply_mi_film_properties(mi_context, bl_node.mi_props): return False # Instantiate child rfilter if present. @@ -299,13 +286,13 @@ def instantiate_film_properties_node(mi_context, bl_node): return True def instantiate_integrator_properties_node(mi_context, bl_node): - return renderer.apply_mi_integrator_properties(mi_context, bl_node.mi_props) + return integrators.apply_mi_integrator_properties(mi_context, bl_node.mi_props) def instantiate_rfilter_properties_node(mi_context, bl_node): - return renderer.apply_mi_rfilter_properties(mi_context, bl_node.mi_props) + return rfilters.apply_mi_rfilter_properties(mi_context, bl_node.mi_props) def instantiate_sampler_properties_node(mi_context, bl_node): - return renderer.apply_mi_sampler_properties(mi_context, bl_node.mi_props) + return samplers.apply_mi_sampler_properties(mi_context, bl_node.mi_props) _bl_properties_node_instantiators = { common.BlenderPropertiesNodeType.FILM: instantiate_film_properties_node, @@ -363,7 +350,7 @@ def instantiate_bl_data_node(mi_context, bl_node): ## Main loading ## ######################### -def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat): +def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat, with_cycles_nodes): ''' Load a Mitsuba scene from an XML file into a Blender scene. Params @@ -373,13 +360,14 @@ def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat bl_collection: Blender collection filepath: Path to the Mitsuba XML scene file global_mat: Axis conversion matrix + with_cycles_nodes: Should create Cycles node tree ''' start_time = time.time() # Load the Mitsuba XML and extract the objects' properties from mitsuba import xml_to_props raw_props = xml_to_props(filepath) mi_scene_props = common.MitsubaSceneProperties(raw_props) - mi_context = common.MitsubaSceneImportContext(bl_context, bl_scene, bl_collection, filepath, mi_scene_props, global_mat) + mi_context = common.MitsubaSceneImportContext(bl_context, bl_scene, bl_collection, filepath, mi_scene_props, global_mat, with_cycles_nodes) _, mi_props = mi_scene_props.get_first_of_class('Scene') bl_scene_data_node = mi_props_to_bl_data_node(mi_context, 'Scene', mi_props) @@ -388,7 +376,7 @@ def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat return # Initialize the Mitsuba renderer inside of Blender - renderer.init_mitsuba_renderer(mi_context) + _init_mitsuba_renderer(mi_context) if not instantiate_bl_data_node(mi_context, bl_scene_data_node): mi_context.log('Failed to instantiate Blender scene', 'ERROR') @@ -404,5 +392,3 @@ def load_mitsuba_scene(bl_context, bl_scene, bl_collection, filepath, global_mat end_time = time.time() mi_context.log(f'Finished loading Mitsuba scene. Took {end_time-start_time:.2f}s.', 'INFO') - - return \ No newline at end of file diff --git a/mitsuba-blender/io/importer/bl_image_utils.py b/mitsuba-blender/importer/bl_image_utils.py similarity index 100% rename from mitsuba-blender/io/importer/bl_image_utils.py rename to mitsuba-blender/importer/bl_image_utils.py diff --git a/mitsuba-blender/io/importer/bl_import_obj.py b/mitsuba-blender/importer/bl_import_obj.py similarity index 100% rename from mitsuba-blender/io/importer/bl_import_obj.py rename to mitsuba-blender/importer/bl_import_obj.py diff --git a/mitsuba-blender/io/importer/bl_import_ply.py b/mitsuba-blender/importer/bl_import_ply.py similarity index 100% rename from mitsuba-blender/io/importer/bl_import_ply.py rename to mitsuba-blender/importer/bl_import_ply.py diff --git a/mitsuba-blender/io/importer/bl_transform_utils.py b/mitsuba-blender/importer/bl_transform_utils.py similarity index 100% rename from mitsuba-blender/io/importer/bl_transform_utils.py rename to mitsuba-blender/importer/bl_transform_utils.py diff --git a/mitsuba-blender/io/importer/common.py b/mitsuba-blender/importer/common.py similarity index 98% rename from mitsuba-blender/io/importer/common.py rename to mitsuba-blender/importer/common.py index aea715d..19cb5da 100644 --- a/mitsuba-blender/io/importer/common.py +++ b/mitsuba-blender/importer/common.py @@ -201,7 +201,7 @@ def get_first_of_class(self, cls): class MitsubaSceneImportContext: ''' Define a context for the Mitsuba scene importer ''' - def __init__(self, bl_context, bl_scene, bl_collection, filepath, mi_scene_props, axis_matrix): + def __init__(self, bl_context, bl_scene, bl_collection, filepath, mi_scene_props, axis_matrix, with_cycles_nodes): self.bl_context = bl_context self.bl_scene = bl_scene self.bl_collection = bl_collection @@ -210,6 +210,7 @@ def __init__(self, bl_context, bl_scene, bl_collection, filepath, mi_scene_props self.mi_scene_props = mi_scene_props self.axis_matrix = axis_matrix self.axis_matrix_inv = axis_matrix.inverted() + self.with_cycles_nodes = with_cycles_nodes self.bl_material_cache = {} self.bl_image_cache = {} diff --git a/mitsuba-blender/io/importer/emitters.py b/mitsuba-blender/importer/emitters.py similarity index 100% rename from mitsuba-blender/io/importer/emitters.py rename to mitsuba-blender/importer/emitters.py diff --git a/mitsuba-blender/importer/films.py b/mitsuba-blender/importer/films.py new file mode 100644 index 0000000..eca3bf4 --- /dev/null +++ b/mitsuba-blender/importer/films.py @@ -0,0 +1,79 @@ +import bpy + +################# +## Utilities ## +################# + +_fileformat_values = { + 'openexr': 'OPEN_EXR', + 'exr': 'OPEN_EXR', + # FIXME: Support other file formats +} + +def mi_fileformat_to_bl_fileformat(mi_context, mi_file_format): + if mi_file_format not in _fileformat_values: + mi_context.log(f'Mitsuba Film image file format "{mi_file_format}" is not supported.', 'ERROR') + return None + return _fileformat_values[mi_file_format] + +_pixelformat_values = { + 'rgb': 'RGB', + 'rgba': 'RGBA', + # FIXME: Support other pixel formats +} + +def mi_pixelformat_to_bl_pixelformat(mi_context, mi_pixel_format): + if mi_pixel_format not in _pixelformat_values: + mi_context.log(f'Mitsuba Film image pixel format "{mi_pixel_format}" is not supported.', 'ERROR') + return None + return _pixelformat_values[mi_pixel_format] + +_componentformat_values = { + 'float16': '16', + 'float32': '32', + # FIXME: Support other component formats +} + +def mi_componentformat_to_bl_componentformat(mi_context, mi_component_format): + if mi_component_format not in _componentformat_values: + mi_context.log(f'Mitsuba Film image component format "{mi_component_format}" is not supported.', 'ERROR') + return None + return _componentformat_values[mi_component_format] + +####################### +## Film properties ## +####################### + +def apply_mi_hdrfilm_properties(mi_context, mi_props): + mi_context.bl_scene.render.resolution_percentage = 100 + render_dims = (mi_props.get('width', 768), mi_props.get('height', 576)) + mi_context.bl_scene.render.resolution_x = render_dims[0] + mi_context.bl_scene.render.resolution_y = render_dims[1] + mi_context.bl_scene.render.image_settings.file_format = mi_fileformat_to_bl_fileformat(mi_context, mi_props.get('file_format', 'openexr')) + mi_context.bl_scene.render.image_settings.color_mode = mi_pixelformat_to_bl_pixelformat(mi_context, mi_props.get('pixel_format', 'rgba')) + mi_context.bl_scene.render.image_settings.color_depth = mi_componentformat_to_bl_componentformat(mi_context, mi_props.get('component_format', 'float16')) + if mi_props.has_property('crop_offset_x') or mi_props.has_property('crop_offset_y') or mi_props.has_property('crop_width') or mi_props.has_property('crop_height'): + mi_context.bl_scene.render.use_border = True + # FIXME: Do we want to crop the resulting image ? + mi_context.bl_scene.render.use_crop_to_border = True + offset_x = mi_props.get('crop_offset_x', 0) + offset_y = mi_props.get('crop_offset_y', 0) + width = mi_props.get('crop_width', render_dims[0]) + height = mi_props.get('crop_height', render_dims[1]) + mi_context.bl_scene.render.border_min_x = offset_x / render_dims[0] + mi_context.bl_scene.render.border_max_x = (offset_x + width) / render_dims[0] + mi_context.bl_scene.render.border_min_y = offset_y / render_dims[1] + mi_context.bl_scene.render.border_max_y = (offset_y + height) / render_dims[1] + return True + +_mi_film_properties_converters = { + 'hdrfilm': apply_mi_hdrfilm_properties +} + +def apply_mi_film_properties(mi_context, mi_props): + mi_film_type = mi_props.plugin_name() + if mi_film_type not in _mi_film_properties_converters: + mi_context.log(f'Mitsuba Film "{mi_film_type}" is not supported.', 'ERROR') + return False + + return _mi_film_properties_converters[mi_film_type](mi_context, mi_props) diff --git a/mitsuba-blender/importer/integrators.py b/mitsuba-blender/importer/integrators.py new file mode 100644 index 0000000..4dc81df --- /dev/null +++ b/mitsuba-blender/importer/integrators.py @@ -0,0 +1,64 @@ +from . import mi_props_utils + +def apply_mi_path_properties(mi_context, mi_props, bl_props=None): + bl_integrator = mi_context.bl_scene.mitsuba if bl_props is None else bl_props + bl_path_props = getattr(bl_integrator.available_integrators, 'path', None) + if bl_path_props is None: + mi_context.log(f'Mitsuba Integrator "path" is not supported.', 'ERROR') + return False + bl_integrator.active_integrator = 'path' + bl_path_props.max_depth = mi_props.get('max_depth', -1) + bl_path_props.rr_depth = mi_props.get('rr_depth', 5) + bl_path_props.hide_emitters = mi_props.get('hide_emitters', False) + + # Cycles properties + if bl_props is None: + bl_renderer = mi_context.bl_scene.cycles + bl_renderer.progressive = 'PATH' + bl_max_bounces = mi_props.get('max_depth', 1024) + bl_renderer.max_bounces = bl_max_bounces + bl_renderer.diffuse_bounces = bl_max_bounces + bl_renderer.glossy_bounces = bl_max_bounces + bl_renderer.transparent_max_bounces = bl_max_bounces + bl_renderer.transmission_bounces = bl_max_bounces + bl_renderer.volume_bounces = bl_max_bounces + bl_renderer.min_light_bounces = mi_props.get('rr_depth', 5) + + return True + +def apply_mi_moment_properties(mi_context, mi_props, bl_props=None): + if bl_props is not None: + # FIXME: support moment integrator nesting + mi_context.log('Mitsuba Integrator "moment" does not support being nested yet.', 'ERROR') + return False + + mi_renderer = mi_context.bl_scene.mitsuba + bl_moment_props = getattr(mi_renderer.available_integrators, 'moment', None) + if bl_moment_props is None: + mi_context.log(f'Mitsuba Integrator "moment" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_renderer.active_integrator = 'moment' + bl_child_integrator_list = bl_moment_props.integrators + for mi_integrator_props in mi_props_utils.named_references_with_class(mi_context, mi_props, 'Integrator'): + bl_child_integrator_list.new(name=mi_integrator_props.id()) + bl_child_integrator = bl_child_integrator_list.collection[bl_child_integrator_list.count-1] + if not apply_mi_integrator_properties(mi_context, mi_integrator_props, bl_child_integrator): + return False + # Cycles properties + mi_context.log('Mitsuba Integrator "moment" is not supported in Blender Cycles', 'WARN') + + return True + +_mi_integrator_properties_converters = { + 'path': apply_mi_path_properties, + 'moment': apply_mi_moment_properties, +} + +def apply_mi_integrator_properties(mi_context, mi_props, bl_integrator_props=None): + mi_integrator_type = mi_props.plugin_name() + if mi_integrator_type not in _mi_integrator_properties_converters: + mi_context.log(f'Mitsuba Integrator "{mi_integrator_type}" is not supported.', 'ERROR') + return False + + return _mi_integrator_properties_converters[mi_integrator_type](mi_context, mi_props, bl_integrator_props) \ No newline at end of file diff --git a/mitsuba-blender/importer/materials.py b/mitsuba-blender/importer/materials.py new file mode 100644 index 0000000..3bdcc0f --- /dev/null +++ b/mitsuba-blender/importer/materials.py @@ -0,0 +1,1004 @@ +import math + +import bpy + +from . import mi_spectra_utils +from . import mi_props_utils +from . import textures +from ..utils import nodetree +from ..utils import material +from ..utils import math as math_utils + +class MaterialConverter: + ''' + Base class for material converters + ''' + def __init__(self, mi_context): + self.mi_context = mi_context + + def write_error_material(self, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_material(self, mi_mat, parent_node, in_socket_id, is_within_twosided=False): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_null_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_principled_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_diffuse_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_twosided_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_dielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_roughdielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_thindielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_blendbsdf_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_conductor_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_roughconductor_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_mask_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_plastic_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_roughplastic_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_bumpmap_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_normalmap_bsdf(self, mi_mat, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_generic_bsdf(self, mi_mat, parent_node, in_socket_id): + mat_type = mi_mat.plugin_name() + function_name = f'write_mi_{mat_type}_bsdf' + converter = getattr(self, function_name) + if converter is None: + self.mi_context.log(f'Mitsuba BSDF type "{mat_type}" not supported. Skipping.', 'WARN') + self.write_error_material(parent_node, in_socket_id) + return False + return converter(mi_mat, parent_node, in_socket_id) + + def write_mi_float_texture(self, mi_texture, parent_node, in_socket_id, default=None): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_float_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_float_value(self, value, parent_node, in_socket_id, transformation=None): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_float_property(self, mi_props, mi_prop_name, parent_node, in_socket_id, default=None, transformation=None): + from mitsuba import Properties + if mi_props.has_property(mi_prop_name): + mi_prop_type = mi_props.type(mi_prop_name) + if mi_prop_type == Properties.Type.Float: + mi_prop_value = mi_props.get(mi_prop_name, default) + self.write_mi_float_value(mi_prop_value, parent_node, in_socket_id, transformation) + elif mi_prop_type == Properties.Type.NamedReference: + mi_texture_ref_id = mi_props.get(mi_prop_name) + mi_texture = self.mi_context.mi_scene_props.get_with_id_and_class(mi_texture_ref_id, 'Texture') + assert mi_texture is not None + self.write_mi_float_texture(mi_texture, parent_node, in_socket_id, default) + elif mi_prop_type == Properties.Type.Object: + mi_props = mi_props.get(mi_prop_name) + self.write_mi_float_spectrum(mi_props, parent_node, in_socket_id, default) + else: + self.mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') + elif default is not None: + self.write_mi_float_value(default, parent_node, in_socket_id, transformation) + else: + self.mi_context.log(f'Material "{mi_props.id()}" does not have property "{mi_prop_name}".', 'ERROR') + + def write_mi_rgb_texture(self, mi_texture, parent_node, in_socket_id, default=None): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_rgb_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_rgb_value(self, value, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + + def write_mi_rgb_property(self, mi_props, mi_prop_name, parent_node, in_socket_id, default=None): + from mitsuba import Properties + if mi_props.has_property(mi_prop_name): + mi_prop_type = mi_props.type(mi_prop_name) + if mi_prop_type == Properties.Type.Color: + self.write_mi_rgb_value(list(mi_props.get(mi_prop_name, default)), parent_node, in_socket_id) + elif mi_prop_type == Properties.Type.NamedReference: + mi_texture_ref_id = mi_props.get(mi_prop_name) + mi_texture = self.mi_context.mi_scene_props.get_with_id_and_class(mi_texture_ref_id, 'Texture') + assert mi_texture is not None + self.write_mi_rgb_texture(mi_texture, parent_node, in_socket_id, default) + elif mi_prop_type == Properties.Type.Object: + mi_obj = mi_props.get(mi_prop_name) + self.write_mi_rgb_spectrum(mi_obj, parent_node, in_socket_id, default) + else: + self.mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to rgb.', 'ERROR') + elif default is not None: + self.write_mi_rgb_value(default, parent_node, in_socket_id) + else: + self.mi_context.log(f'Material "{mi_props.id()}" does not have property "{mi_prop_name}".', 'ERROR') + + def write_mi_transform2d_property(self, mi_props, mi_prop_name, parent_node, in_socket_id): + raise NotImplementedError('Implemented by subclasses') + +######################### +## Blender Converter ## +######################### + +class BlenderMaterialConverter(MaterialConverter): + ''' + Material converter for Cycles shader node tree + ''' + def __init__(self, mi_context): + super().__init__(mi_context) + self.mi_bump = None + self.mi_normalmap = None + + def _eval_mi_bsdf_retro_reflection(self, mi_mat, default=None): + ''' Evaluate the reflectance color of a BSDF for a perfect perpendicular reflection ''' + from mitsuba import load_dict, BSDFContext, SurfaceInteraction3f, Vector3f + # Generate the BSDF properties dictionary + bsdf_dict = { + 'type': mi_mat.plugin_name(), + } + for name in mi_mat.property_names(): + bsdf_dict[name] = mi_mat.get(name) + + bsdf = load_dict(bsdf_dict) + si = SurfaceInteraction3f() + si.wi = Vector3f(0.0, 0.0, 1.0) + wo = Vector3f(0.0, 0.0, 1.0) + color, pdf = bsdf.eval_pdf(BSDFContext(), si, wo) + if pdf == 0.0: + if default is None: + self.mi_context.log(f'Failed to evaluate "{mi_mat.id()}" conductor BSDF.', 'ERROR') + return None + return default + return list(color / pdf) + + def _mi_wrap_mode_to_bl_extension(self, mi_wrap_mode): + if mi_wrap_mode == 'repeat': + return 'REPEAT' + elif mi_wrap_mode == 'mirror': + # NOTE: Blender does not support mirror wrap mode + return 'REPEAT' + elif mi_wrap_mode == 'clamp': + return 'CLIP' + else: + self.mi_context.log(f'Mitsuba wrap mode "{mi_wrap_mode}" is not supported.', 'ERROR') + return None + + def _mi_filter_type_to_bl_interpolation(self, mi_filter_type): + if mi_filter_type == 'bilinear': + return 'Cubic' + elif mi_filter_type == 'nearest': + return 'Closest' + else: + self.mi_context.log(f'Mitsuba filter type "{mi_filter_type}" is not supported.', 'ERROR') + return None + + def _mi_microfacet_to_bl_microfacet(self, mi_microfacet_distribution): + if mi_microfacet_distribution == 'beckmann': + return 'BECKMANN' + elif mi_microfacet_distribution == 'ggx': + return 'GGX' + else: + self.mi_context.log(f'Mitsuba microfacet distribution "{mi_microfacet_distribution}" not supported.', 'ERROR') + return 'BECKMANN' + + _ior_string_values = { + 'acetone': 1.36, + 'acrylic glass': 1.49, + 'air': 1.00028, + 'amber': 1.55, + 'benzene': 1.501, + 'bk7': 1.5046, + 'bromine': 1.661, + 'carbon dioxide': 1.00045, + 'carbon tetrachloride': 1.461, + 'diamond': 2.419, + 'ethanol': 1.361, + 'fused quartz': 1.458, + 'glycerol': 1.4729, + 'helium': 1.00004, + 'hydrogen': 1.00013, + 'pet': 1.575, + 'polypropylene': 1.49, + 'pyrex': 1.470, + 'silicone oil': 1.52045, + 'sodium chloride': 1.544, + 'vacuum': 1.0, + 'water': 1.3330, + 'water ice': 1.31, + } + + def _mi_ior_string_to_float(self, mi_ior): + if mi_ior not in self._ior_string_values: + self.mi_context.log(f'Mitsuba IOR name "{mi_ior}" is not supported.', 'ERROR') + return 1.0 + return self._ior_string_values[mi_ior] + + def _write_mi_ior_property(self, mi_mat, mi_prop_name, parent_node, in_socket_id, default: str=None): + from mitsuba import Properties + if mi_mat.has_property(mi_prop_name): + mi_prop_type = mi_mat.type(mi_prop_name) + if mi_prop_type == Properties.Type.Float: + parent_node.set_property(in_socket_id, mi_mat.get(mi_prop_name, self._mi_ior_string_to_float(default))) + elif mi_prop_type == Properties.Type.String: + parent_node.set_property(in_socket_id, self._mi_ior_string_to_float(mi_mat.get(mi_prop_name, 'bk7'))) + else: + self.mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') + elif default is not None: + parent_node.set_property(in_socket_id, self._mi_ior_string_to_float(default)) + else: + self.mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') + + def _write_mi_roughness_property(self, mi_mat, mi_prop_name, parent_node, in_socket_id, default=None): + self.write_mi_float_property(mi_mat, mi_prop_name, parent_node, in_socket_id, default ** 2, lambda x: math.sqrt(x)) + + def _write_mi_bump_and_normal_maps(self, parent_node, in_socket_id): + normalmap_parent_node = parent_node + if self.mi_bump is not None: + bump_node = parent_node.create_linked('ShaderNodeBump', in_socket_id, out_socket_id='Normal') + mi_bump_textures = mi_props_utils.named_references_with_class(self.mi_context, self.mi_bump, 'Texture') + assert len(mi_bump_textures) == 1 + self.write_mi_float_texture(mi_bump_textures[0], bump_node, 'Height', 0.0) + # FIXME: Can we map directly this value ? + self.write_mi_float_property(self.mi_bump, 'scale', bump_node, 'Distance', 1.0) + normalmap_parent_node = bump_node + + if self.mi_normalmap is not None: + normalmap_node = normalmap_parent_node.create_linked('ShaderNodeNormalMap', in_socket_id, out_socket_id='Normal') + self.write_mi_rgb_property(self.mi_normalmap, 'normalmap', normalmap_node, 'Color', [0.5, 0.5, 1.0]) + + def _write_twosided_material(self, parent_node, in_socket_id, mi_front_mat, mi_back_mat=None): + mix_node = parent_node.create_linked('ShaderNodeMixShader', in_socket_id, out_socket_id='Shader') + # Generate a geometry node that will select the correct BSDF based on face orientation + mix_node.create_linked('ShaderNodeNewGeometry', 'Fac', out_socket_id='Backfacing') + # Write the child materials + self.write_mi_material(mi_front_mat, mix_node, 'Shader', is_within_twosided=True) + if mi_back_mat is not None: + self.write_mi_material(mi_back_mat, mix_node, 'Shader_001', is_within_twosided=True) + else: + diffuse_node = mix_node.create_linked('ShaderNodeBsdfDiffuse', 'Shader_001') + diffuse_node.set_property('Color', material.rgb_to_rgba([0.0, 0.0, 0.0])) + return True + + def write_mi_emitter_bsdf(self, parent_node, in_socket_id, mi_emitter): + add_node = parent_node.create_linked('ShaderNodeAddShader', in_socket_id, out_socket_id='Shader') + emissive_node = add_node.create_linked('ShaderNodeEmission', 'Shader', out_socket_id='Emission') + radiance, strength = mi_spectra_utils.convert_mi_srgb_emitter_spectrum(mi_emitter.get('radiance'), [1.0, 1.0, 1.0]) + emissive_node.set_property('Color', material.rgb_to_rgba(radiance)) + emissive_node.set_property('Strength', strength) + return add_node, 'Shader_001' + + def write_error_material(self, parent_node, in_socket_id): + diffuse_node = parent_node.create_linked('ShaderNodeBsdfDiffuse', in_socket_id) + diffuse_node.set_property('Color', material.rgb_to_rgba([1.0, 0.0, 0.3])) + + def write_mi_null_bsdf(self, mi_mat, parent_node, in_socket_id): + parent_node.create_linked('ShaderNodeBsdfTransparent', in_socket_id) + return True + + def write_mi_principled_bsdf(self, mi_mat, parent_node, in_socket_id): + principled_node = parent_node.create_linked('ShaderNodeBsdfPrincipled', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'base_color', principled_node, 'Base Color', [0.8, 0.8, 0.8]) + self.write_mi_float_property(mi_mat, 'specular', principled_node, 'Specular', 0.5) + self.write_mi_float_property(mi_mat, 'eta', principled_node, 'IOR', 1.450) + self.write_mi_float_property(mi_mat, 'spec_tint', principled_node, 'Specular Tint', 0.0) + self.write_mi_float_property(mi_mat, 'spec_trans', principled_node, 'Transmission', 0.0) + self.write_mi_float_property(mi_mat, 'metallic', principled_node, 'Metallic', 0.0) + self.write_mi_float_property(mi_mat, 'anisotropic', principled_node, 'Anisotropic', 0.0) + self._write_mi_roughness_property(mi_mat, 'roughness', principled_node, 'Roughness', 0.4) + self.write_mi_float_property(mi_mat, 'sheen', principled_node, 'Sheen', 0.0) + self.write_mi_float_property(mi_mat, 'sheen_tint', principled_node, 'Sheen Tint', 0.5) + self.write_mi_float_property(mi_mat, 'flatness', principled_node, 'Subsurface', 0.0) + self.write_mi_rgb_property(mi_mat, 'base_color', principled_node, 'Subsurface Color', [0.8, 0.8, 0.8]) + self.write_mi_float_property(mi_mat, 'clearcoat', principled_node, 'Clearcoat', 0.0) + self._write_mi_roughness_property(mi_mat, 'clearcoat_gloss', principled_node, 'Clearcoat Roughness', 0.03) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(principled_node, 'Normal') + return True + + def write_mi_diffuse_bsdf(self, mi_mat, parent_node, in_socket_id): + diffuse_node = parent_node.create_linked('ShaderNodeBsdfDiffuse', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'reflectance', diffuse_node, 'Color', [0.8, 0.8, 0.8]) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(diffuse_node, 'Normal') + return True + + def write_mi_twosided_bsdf(self, mi_mat, parent_node, in_socket_id): + mi_child_materials = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + mi_child_material_count = len(mi_child_materials) + if mi_child_material_count == 1: + # This case is handled by simply parsing the material. Blender materials are two-sided by default + # NOTE: We always parse the Mitsuba material; we don't use the material cache. + # This is because we have no way of reusing already created materials as a 'sub-material'. + self.write_mi_material(mi_child_materials[0], parent_node, in_socket_id, is_within_twosided=True) + return True + elif mi_child_material_count == 2: + # This case is handled by creating a two-side material where the front face has the first + # material and the back face has the second one. + self._write_twosided_material(parent_node, in_socket_id, mi_child_materials[0], mi_child_materials[1]) + return True + else: + self.mi_context.log(f'Mitsuba twosided material "{mi_mat.id()}" has {mi_child_material_count} child material(s). Expected 1 or 2.', 'ERROR') + return False + + def write_mi_dielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + glass_node = parent_node.create_linked('ShaderNodeBsdfGlass', in_socket_id) + # FIXME: Is this the correct distribution ? + glass_node.set_property('distribution', 'SHARP') + self._write_mi_ior_property(mi_mat, 'int_ior', glass_node, 'IOR', 'bk7') + self.write_mi_rgb_property(mi_mat, 'specular_transmittance', glass_node, 'Color', [1.0, 1.0, 1.0]) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(glass_node, 'Normal') + return True + + def write_mi_roughdielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + glass_node = parent_node.create_linked('ShaderNodeBsdfGlass', in_socket_id) + glass_node.set_property('distribution', self._mi_microfacet_to_bl_microfacet(mi_mat.get('distribution', 'beckmann'))) + self._write_mi_ior_property(mi_mat, 'int_ior', glass_node, 'IOR', 'bk7') + self.write_mi_rgb_property(mi_mat, 'specular_transmittance', glass_node, 'Color', [1.0, 1.0, 1.0]) + self._write_mi_roughness_property(mi_mat, 'alpha', glass_node, 'Roughness', 0.1) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(glass_node, 'Normal') + return True + + def write_mi_thindielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + glass_node = parent_node.create_linked('ShaderNodeBsdfGlass', in_socket_id) + glass_node.set_property('distribution', 'SHARP') + glass_node.set_property('IOR', 1.0) + self.write_mi_rgb_property(mi_mat, 'specular_transmittance', glass_node, 'Color', [1.0, 1.0, 1.0]) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(glass_node, 'Normal') + return True + + def write_mi_blendbsdf_bsdf(self, mi_mat, parent_node, in_socket_id): + mix_node = parent_node.create_linked('ShaderNodeMixShader', in_socket_id, out_socket_id='Shader') + self.write_mi_float_property(mi_mat, 'weight', mix_node, 'Fac', 0.5) + # NOTE: We assume that the two BSDFs are ordered in the list of named references + mi_child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + mi_child_mats_count = len(mi_child_mats) + if mi_child_mats_count != 2: + self.mi_context.log(f'Unexpected number of child BSDFs in blendbsdf. Expected 2 but got {mi_child_mats_count}.', 'ERROR') + return False + self.write_mi_material(mi_child_mats[0], mix_node, 'Shader') + self.write_mi_material(mi_child_mats[1], mix_node, 'Shader_001') + return True + + def write_mi_conductor_bsdf(self, mi_mat, parent_node, in_socket_id): + glossy_node = parent_node.create_linked('ShaderNodeBsdfGlossy', in_socket_id) + glossy_node.set_property('distribution', 'SHARP') + reflectance = self._eval_mi_bsdf_retro_reflection(mi_mat, [1.0, 1.0, 1.0]) + self.write_mi_rgb_value(reflectance, glossy_node, 'Color') + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(glossy_node, 'Normal') + return True + + def write_mi_roughconductor_bsdf(self, mi_mat, parent_node, in_socket_id): + glossy_node = parent_node.create_linked('ShaderNodeBsdfGlossy', in_socket_id) + glossy_node.set_property('distribution', self._mi_microfacet_to_bl_microfacet(mi_mat.get('distribution', 'beckmann'))) + reflectance = self._eval_mi_bsdf_retro_reflection(mi_mat, [1.0, 1.0, 1.0]) + self.write_mi_rgb_value(reflectance, glossy_node, 'Color') + self._write_mi_roughness_property(mi_mat, 'alpha', glossy_node, 'Roughness', 0.1) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(glossy_node, 'Normal') + return True + + def write_mi_mask_bsdf(self, mi_mat, parent_node, in_socket_id): + mix_node = parent_node.create_linked('ShaderNodeMixShader', in_socket_id, out_socket_id='Shader') + # Connect the opacity. A value of 0 is completely transparent and 1 is completely opaque. + self.write_mi_float_property(mi_mat, 'opacity', mix_node, 'Fac', 0.5) + # Add a transparent node to the top socket of the mix shader + mix_node.create_linked('ShaderNodeBsdfTransparent', 'Shader') + # Parse the other BSDF + mi_child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + mi_child_mats_count = len(mi_child_mats) + if mi_child_mats_count != 1: + self.mi_context.log(f'Unexpected number of child BSDFs in mask BSDF. Expected 1 but got {mi_child_mats_count}.', 'ERROR') + return False + self.write_mi_material(mi_child_mats[0], mix_node, 'Shader_001') + return True + + # FIXME: The plastic and roughplastic don't have simple equivalent in Blender. We rely on a + # crude approximation using a Disney principled shader. + def write_mi_plastic_bsdf(self, mi_mat, parent_node, in_socket_id): + principled_node = parent_node.create_linked('ShaderNodeBsdfPrincipled', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'diffuse_reflectance', principled_node, 'Base Color', [0.5, 0.5, 0.5]) + self._write_mi_ior_property(mi_mat, 'int_ior', principled_node, 'IOR', 'polypropylene') + principled_node.set_property('Specular', 0.2) + principled_node.set_property('Specular Tint', 1.0) + principled_node.set_property('Roughness', 0.0) + principled_node.set_property('Clearcoat', 0.8) + principled_node.set_property('Clearcoat Roughness', 0.0) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(principled_node, 'Normal') + return True + + def write_mi_roughplastic_bsdf(self, mi_mat, parent_node, in_socket_id): + principled_node = parent_node.create_linked('ShaderNodeBsdfPrincipled', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'diffuse_reflectance', principled_node, 'Base Color', [0.5, 0.5, 0.5]) + self._write_mi_ior_property(mi_mat, 'int_ior', principled_node, 'IOR', 'polypropylene') + self._write_mi_roughness_property(mi_mat, 'alpha', principled_node, 'Roughness', 0.1) + self._write_mi_roughness_property(mi_mat, 'alpha', principled_node, 'Clearcoat Roughness', 0.1) + principled_node.set_property('distribution', self._mi_microfacet_to_bl_microfacet('ggx')) + principled_node.set_property('Specular', 0.2) + principled_node.set_property('Specular Tint', 1.0) + principled_node.set_property('Clearcoat', 0.8) + # Write normal and bump maps + self._write_mi_bump_and_normal_maps(principled_node, 'Normal') + return True + + def write_mi_bumpmap_bsdf(self, mi_mat, parent_node, in_socket_id): + if self.mi_bump is not None: + self.mi_context.log('Cannot have nested bumpmap BSDFs', 'ERROR') + return False + child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + assert len(child_mats) == 1 + + self.mi_bump = mi_mat + self.write_mi_material(child_mats[0], parent_node, in_socket_id) + self.mi_bump = None + + return True + + def write_mi_normalmap_bsdf(self, mi_mat, parent_node, in_socket_id): + if self.mi_normalmap is not None: + self.mi_context.log('Cannot have nested normalmap BSDFs', 'ERROR') + return False + child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + assert len(child_mats) == 1 + + self.mi_normalmap = mi_mat + self.write_mi_material(child_mats[0], parent_node, in_socket_id) + self.mi_normalmap = None + + return True + + # List of materials that are always two-sided. These are the transmissive materials and + # a few material wrappers. + _always_twosided_bsdfs = [ + 'dielectric', + 'roughdielectric', + 'thindielectric', + 'mask', + 'bumpmap', + 'normalmap', + 'null', + ] + + def write_mi_material(self, mi_mat, parent_node, in_socket_id, is_within_twosided=False): + mat_type = mi_mat.plugin_name() + if is_within_twosided and mat_type == 'twosided': + self.mi_context.log('Cannot have nested twosided materials.', 'ERROR') + return + + if not is_within_twosided and mat_type != 'twosided' and mat_type not in self._always_twosided_bsdfs: + # Write one-sided material + self._write_twosided_material(parent_node, in_socket_id, mi_front_mat=mi_mat, mi_back_mat=None) + elif not self.write_generic_bsdf(mi_mat, parent_node, in_socket_id): + self.mi_context.log(f'Failed to convert Mitsuba material "{mi_mat.id()}". Skipping.', 'WARN') + self.write_error_material(parent_node, in_socket_id) + + def write_mi_float_bitmap(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_id = mi_texture.id() + bl_image = self.mi_context.get_bl_image(mi_texture_id) + if bl_image is None: + # FIXME: We forcibly disable sRGB conversion for float textures. + # This should probably be done elsewhere. + mi_texture['raw'] = True + # If the image is not in the cache, load it from disk. + # This can happen if we have a texture inside of a BSDF that is itself into a + # twosided BSDF. + bl_image = textures.mi_texture_to_bl_image(self.mi_context, mi_texture) + if bl_image is None: + parent_node.set_property(in_socket_id, default) + return + self.mi_context.register_bl_image(mi_texture_id, bl_image) + + # FIXME: Support texture coordinate mapping + # FIXME: For float textures, it is not always clear if we should use the 'Alpha' output instead of the luminance value. + teximage_node = parent_node.create_linked('ShaderNodeTexImage', in_socket_id, out_socket_id='Color') + teximage_node.set_property('image', bl_image) + teximage_node.set_property('extension', self._mi_wrap_mode_to_bl_extension(mi_texture.get('wrap_mode', 'repeat'))) + teximage_node.set_property('interpolation', self._mi_filter_type_to_bl_interpolation(mi_texture.get('filter_type', 'bilinear'))) + self.write_mi_transform2d_property(mi_texture, 'to_uv', teximage_node, 'Vector') + + _float_texture_writers = { + 'bitmap': write_mi_float_bitmap + } + + def write_mi_float_texture(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_type = mi_texture.plugin_name() + if mi_texture_type not in self._float_texture_writers: + self.mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') + return + self._float_texture_writers[mi_texture_type](self, mi_texture, parent_node, in_socket_id, default) + + def write_mi_float_srgb_reflectance_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) + parent_node.set_property(in_socket_id, mi_spectra_utils.linear_rgb_to_luminance(reflectance)) + + _float_spectrum_writers = { + 'SRGBReflectanceSpectrum': write_mi_float_srgb_reflectance_spectrum + } + + def write_mi_float_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + mi_obj_class_name = mi_obj.class_().name() + if mi_obj_class_name not in self._float_spectrum_writers: + self.mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') + return + self._float_spectrum_writers[mi_obj_class_name](self, mi_obj, parent_node, in_socket_id, default) + + def write_mi_float_value(self, value, parent_node, in_socket_id, transformation=None): + if transformation is not None: + value = transformation(value) + parent_node.set_property(in_socket_id, value) + + def write_mi_rgb_bitmap(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_id = mi_texture.id() + bl_image = self.mi_context.get_bl_image(mi_texture_id) + if bl_image is None: + # If the image is not in the cache, load it from disk. + # This can happen if we have a texture inside of a BSDF that is itself into a + # twosided BSDF. + bl_image = textures.mi_texture_to_bl_image(self.mi_context, mi_texture) + if bl_image is None: + parent_node.set_property(in_socket_id, material.rgb_to_rgba(default)) + return + self.mi_context.register_bl_image(mi_texture_id, bl_image) + + # FIXME: Support texture coordinate mapping + teximage_node = parent_node.create_linked('ShaderNodeTexImage', in_socket_id, out_socket_id='Color') + teximage_node.set_property('image', bl_image) + teximage_node.set_property('extension', self._mi_wrap_mode_to_bl_extension(mi_texture.get('wrap_mode', 'repeat'))) + teximage_node.set_property('interpolation', self._mi_filter_type_to_bl_interpolation(mi_texture.get('filter_type', 'bilinear'))) + self.write_mi_transform2d_property(mi_texture, 'to_uv', teximage_node, 'Vector') + + _rgb_texture_writers = { + 'bitmap': write_mi_rgb_bitmap + } + + def write_mi_rgb_texture(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_type = mi_texture.plugin_name() + if mi_texture_type not in self._rgb_texture_writers: + self.mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') + return + self._rgb_texture_writers[mi_texture_type](self, mi_texture, parent_node, in_socket_id, default) + + def write_mi_rgb_srgb_reflectance_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) + parent_node.set_property(in_socket_id, material.rgb_to_rgba(reflectance)) + + _rgb_spectrum_writers = { + 'SRGBReflectanceSpectrum': write_mi_rgb_srgb_reflectance_spectrum + } + + def write_mi_rgb_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + mi_obj_class_name = mi_obj.class_().name() + if mi_obj_class_name not in self._rgb_spectrum_writers: + self.mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') + return + self._rgb_spectrum_writers[mi_obj_class_name](self, mi_obj, parent_node, in_socket_id, default) + + def write_mi_rgb_value(self, value, parent_node, in_socket_id): + parent_node.set_property(in_socket_id, material.rgb_to_rgba(value)) + + def write_mi_transform2d_property(self, mi_props, mi_prop_name, parent_node, in_socket_id): + # TODO: Implement me + pass + +######################### +## Mitsuba Converter ## +######################### + +class MitsubaMaterialConverter(MaterialConverter): + ''' + Material converter for custom Mitsuba node tree + ''' + def __init__(self, mi_context): + super().__init__(mi_context) + + def _write_mi_ior_property(self, mi_mat, mi_prop_name, parent_node, in_socket_id, default: str=None): + ior_enum_attr = f'{in_socket_id}_enum' + ior_value_attr = f'{in_socket_id}_value' + from mitsuba import Properties + if mi_mat.has_property(mi_prop_name): + mi_prop_type = mi_mat.type(mi_prop_name) + if mi_prop_type == Properties.Type.Float: + parent_node.set_property(ior_value_attr, float(mi_mat.get(mi_prop_name))) + elif mi_prop_type == Properties.Type.String: + parent_node.set_property(ior_enum_attr, str(mi_mat.get(mi_prop_name))) + else: + self.mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') + elif default is not None: + parent_node.set_property(ior_enum_attr, default) + else: + self.mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') + + def _write_mi_eta_property(self, mi_mat, mi_prop_name, parent_node, in_socket_id, default: str=None): + eta_enum_attr = f'{in_socket_id}_enum' + eta_value_attr = f'{in_socket_id}_value' + if mi_mat.has_property(mi_prop_name): + self._write_mi_ior_property(mi_mat, mi_prop_name, parent_node, in_socket_id, default) + elif mi_mat.has_property('specular'): + specular = mi_mat.get('specular') + eta = 2 / (1 - math.sqrt(0.08 * specular)) - 1 + parent_node.set_property(eta_value_attr, eta) + elif default is not None: + parent_node.set_property(eta_enum_attr, default) + else: + self.mi_context.log(f'Invalid eta/specular property in material "{mi_mat.id()}".', 'ERROR') + + def _write_mi_roughness_property(self, mi_mat, node): + node.set_property('distribution', mi_mat.get('distribution', 'beckmann')) + node.set_property('sample_visible', mi_mat.get('sample_visible', True)) + if mi_mat.has_property('alpha_u') or mi_mat.has_property('alpha_v'): + node.set_property('anisotropic', True) + self.write_mi_float_property(mi_mat, 'alpha_u', node, 'Alpha U', default=0.1) + self.write_mi_float_property(mi_mat, 'alpha_v', node, 'Alpha V', default=0.1) + else: + if hasattr(node, 'anisotropic'): + node.set_property('anisotropic', False) + self.write_mi_float_property(mi_mat, 'alpha', node, 'Alpha', default=0.1) + + def _write_mi_conductor_property(self, mi_mat, node): + material_prop = mi_mat.get('material', 'none') + node.set_property('conductor_enum', material_prop) + if material_prop == 'none': + self.write_mi_float_property(mi_mat, 'eta', node, 'Eta', default=0.0) + self.write_mi_float_property(mi_mat, 'k', node, 'K', default=1.0) + elif mi_mat.has_property('eta') or mi_mat.has_property('k'): + self.context.log(f'Conductor material "{mi_mat.id()}" specifies (eta, k) and material. Ignoring (eta, k).', 'WARN') + + def write_error_material(self, parent_node, in_socket_id): + diffuse_node = parent_node.create_linked('MitsubaNodeDiffuseBSDF', in_socket_id) + diffuse_node.set_property('Reflectance', [1.0, 0.0, 0.3]) + + def write_mi_material(self, mi_mat, parent_node, in_socket_id, is_within_twosided=False): + if not self.write_generic_bsdf(mi_mat, parent_node, in_socket_id): + self.write_error_material(parent_node, in_socket_id) + return False + + def write_mi_null_bsdf(self, mi_mat, parent_node, in_socket_id): + parent_node.create_linked('MitsubaNodeNullBSDF', in_socket_id) + return True + + def write_mi_principled_bsdf(self, mi_mat, parent_node, in_socket_id): + principled_node = parent_node.create_linked('MitsubaNodePrincipledBSDF', in_socket_id) + self._write_mi_eta_property(mi_mat, 'eta', principled_node, 'eta', 'bk7') + self.write_mi_rgb_property(mi_mat, 'base_color', principled_node, 'Base Color', [0.5, 0.5, 0.5]) + self.write_mi_float_property(mi_mat, 'roughness', principled_node, 'Roughness', 0.5) + self.write_mi_float_property(mi_mat, 'anisotropic', principled_node, 'Anisotropic', 0.0) + self.write_mi_float_property(mi_mat, 'metallic', principled_node, 'Metallic', 0.0) + self.write_mi_float_property(mi_mat, 'spec_trans', principled_node, 'Specular Transmission', 0.0) + self.write_mi_float_property(mi_mat, 'spec_tint', principled_node, 'Specular Tint', 0.0) + self.write_mi_float_property(mi_mat, 'sheen', principled_node, 'Sheen', 0.0) + self.write_mi_float_property(mi_mat, 'sheen_tint', principled_node, 'Sheen Tint', 0.0) + self.write_mi_float_property(mi_mat, 'flatness', principled_node, 'Flatness', 0.0) + self.write_mi_float_property(mi_mat, 'clearcoat', principled_node, 'Clearcoat', 0.0) + self.write_mi_float_property(mi_mat, 'clearcoat_gloss', principled_node, 'Clearcoat Gloss', 0.0) + return True + + def write_mi_diffuse_bsdf(self, mi_mat, parent_node, in_socket_id): + diffuse_node = parent_node.create_linked('MitsubaNodeDiffuseBSDF', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'reflectance', diffuse_node, 'Reflectance', [0.5, 0.5, 0.5]) + return True + + def write_mi_twosided_bsdf(self, mi_mat, parent_node, in_socket_id): + twosided_node = parent_node.create_linked('MitsubaNodeTwosidedBSDF', in_socket_id) + mi_child_materials = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + mi_child_material_count = len(mi_child_materials) + if mi_child_material_count == 1: + self.write_mi_material(mi_child_materials[0], twosided_node, 'BSDF', is_within_twosided=True) + elif mi_child_material_count == 2: + self.write_mi_material(mi_child_materials[0], twosided_node, 'BSDF', is_within_twosided=True) + self.write_mi_material(mi_child_materials[1], twosided_node, 'BSDF_001', is_within_twosided=True) + else: + self.mi_context.log(f'Mitsuba twosided material "{mi_mat.id()}" has {mi_child_material_count} child material(s). Expected 1 or 2.', 'ERROR') + return False + return True + + def write_mi_dielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + dielectric_node = parent_node.create_linked('MitsubaNodeDielectricBSDF', in_socket_id) + self._write_mi_ior_property(mi_mat, 'int_ior', dielectric_node, 'int_ior', 'bk7') + self._write_mi_ior_property(mi_mat, 'ext_ior', dielectric_node, 'ext_ior', 'bk7') + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', dielectric_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + self.write_mi_rgb_property(mi_mat, 'specular_transmittance', dielectric_node, 'Specular Transmittance', [1.0, 1.0, 1.0]) + return True + + def write_mi_roughdielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + rough_dielectric_node = parent_node.create_linked('MitsubaNodeRoughDielectricBSDF', in_socket_id) + self._write_mi_ior_property(mi_mat, 'int_ior', rough_dielectric_node, 'int_ior', 'bk7') + self._write_mi_ior_property(mi_mat, 'ext_ior', rough_dielectric_node, 'ext_ior', 'bk7') + self._write_mi_roughness_property(mi_mat, rough_dielectric_node) + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', rough_dielectric_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + self.write_mi_rgb_property(mi_mat, 'specular_transmittance', rough_dielectric_node, 'Specular Transmittance', [1.0, 1.0, 1.0]) + return True + + def write_mi_thindielectric_bsdf(self, mi_mat, parent_node, in_socket_id): + thin_dielectric_node = parent_node.create_linked('MitsubaNodeThinDielectricBSDF', in_socket_id) + self._write_mi_ior_property(mi_mat, 'int_ior', thin_dielectric_node, 'int_ior', 'bk7') + self._write_mi_ior_property(mi_mat, 'ext_ior', thin_dielectric_node, 'ext_ior', 'bk7') + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', thin_dielectric_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + self.write_mi_rgb_property(mi_mat, 'specular_transmittance', thin_dielectric_node, 'Specular Transmittance', [1.0, 1.0, 1.0]) + return True + + def write_mi_blendbsdf_bsdf(self, mi_mat, parent_node, in_socket_id): + blend_node = parent_node.create_linked('MitsubaNodeBlendBSDF', in_socket_id) + self.write_mi_float_property(mi_mat, 'weight', blend_node, 'Weight', default=0.5) + # NOTE: We assume that the two BSDFs are ordered in the list of named references + mi_child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + mi_child_mats_count = len(mi_child_mats) + if mi_child_mats_count != 2: + self.mi_context.log(f'Unexpected number of child BSDFs in blendbsdf. Expected 2 but got {mi_child_mats_count}.', 'ERROR') + return False + self.write_mi_material(mi_child_mats[0], blend_node, 'BSDF') + self.write_mi_material(mi_child_mats[1], blend_node, 'BSDF_001') + return True + + def write_mi_conductor_bsdf(self, mi_mat, parent_node, in_socket_id): + conductor_node = parent_node.create_linked('MitsubaNodeConductorBSDF', in_socket_id) + self._write_mi_conductor_property(mi_mat, conductor_node) + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', conductor_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + return True + + def write_mi_roughconductor_bsdf(self, mi_mat, parent_node, in_socket_id): + rough_conductor_node = parent_node.create_linked('MitsubaNodeRoughConductorBSDF', in_socket_id) + self._write_mi_conductor_property(mi_mat, rough_conductor_node) + self._write_mi_roughness_property(mi_mat, rough_conductor_node) + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', rough_conductor_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + return True + + def write_mi_mask_bsdf(self, mi_mat, parent_node, in_socket_id): + mask_node = parent_node.create_linked('MitsubaNodeMaskBSDF', in_socket_id) + self.write_mi_float_property(mi_mat, 'opacity', mask_node, 'Opacity', default=0.5) + mi_child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + mi_child_mats_count = len(mi_child_mats) + if mi_child_mats_count != 1: + self.mi_context.log(f'Unexpected number of child BSDFs in mask BSDF. Expected 1 but got {mi_child_mats_count}.', 'ERROR') + return False + self.write_mi_material(mi_child_mats[0], mask_node, 'BSDF') + return True + + def write_mi_plastic_bsdf(self, mi_mat, parent_node, in_socket_id): + plastic_node = parent_node.create_linked('MitsubaNodePlasticBSDF', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'diffuse_reflectance', plastic_node, 'Diffuse Reflectance', [0.5, 0.5, 0.5]) + plastic_node.set_property('nonlinear', mi_mat.get('nonlinear', False)) + self._write_mi_ior_property(mi_mat, 'int_ior', plastic_node, 'int_ior', default='polypropylene') + self._write_mi_ior_property(mi_mat, 'ext_ior', plastic_node, 'ext_ior', default='air') + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', plastic_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + return True + + def write_mi_roughplastic_bsdf(self, mi_mat, parent_node, in_socket_id): + rough_plastic_node = parent_node.create_linked('MitsubaNodeRoughPlasticBSDF', in_socket_id) + self.write_mi_rgb_property(mi_mat, 'diffuse_reflectance', rough_plastic_node, 'Diffuse Reflectance', [0.5, 0.5, 0.5]) + rough_plastic_node.set_property('nonlinear', mi_mat.get('nonlinear', False)) + self._write_mi_ior_property(mi_mat, 'int_ior', rough_plastic_node, 'int_ior', default='polypropylene') + self._write_mi_ior_property(mi_mat, 'ext_ior', rough_plastic_node, 'ext_ior', default='air') + self.write_mi_rgb_property(mi_mat, 'specular_reflectance', rough_plastic_node, 'Specular Reflectance', [1.0, 1.0, 1.0]) + self._write_mi_roughness_property(mi_mat, rough_plastic_node) + return True + + def write_mi_bumpmap_bsdf(self, mi_mat, parent_node, in_socket_id): + bump_node = parent_node.create_linked('MitsubaNodeBumpMapBSDF', in_socket_id) + bump_node.set_property('scale', mi_mat.get('scale', 1.0)) + + mi_bump_textures = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'Texture') + assert len(mi_bump_textures) == 1 + self.write_mi_float_texture(mi_bump_textures[0], bump_node, 'Bump Height', default=0.0) + + mi_child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + assert len(mi_child_mats) == 1 + self.write_mi_material(mi_child_mats[0], bump_node, 'BSDF') + + return True + + def write_mi_normalmap_bsdf(self, mi_mat, parent_node, in_socket_id): + normalmap_node = parent_node.create_linked('MitsubaNodeNormalMapBSDF', in_socket_id) + + mi_normalmap_textures = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'Texture') + assert len(mi_normalmap_textures) == 1 + self.write_mi_rgb_bitmap(mi_normalmap_textures[0], normalmap_node, 'Normal Map') + + mi_child_mats = mi_props_utils.named_references_with_class(self.mi_context, mi_mat, 'BSDF') + assert len(mi_child_mats) == 1 + self.write_mi_material(mi_child_mats[0], normalmap_node, 'BSDF') + + return True + + def write_mi_float_bitmap(self, mi_texture, parent_node, in_socket_id, default=None): + # FIXME: We forcibly disable sRGB conversion for float textures. + # This should probably be done elsewhere. + mi_texture['raw'] = True + self.write_mi_rgb_bitmap(mi_texture, parent_node, in_socket_id, default) + + def write_mi_float_checkerboard(self, mi_texture, parent_node, in_socket_id, default=None): + self.write_mi_rgb_checkerboard(mi_texture, parent_node, in_socket_id, default) + + _float_texture_writers = { + 'bitmap': write_mi_float_bitmap, + 'checkerboard': write_mi_float_checkerboard, + } + + def write_mi_float_texture(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_type = mi_texture.plugin_name() + if mi_texture_type not in self._float_texture_writers: + self.mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') + return + self._float_texture_writers[mi_texture_type](self, mi_texture, parent_node, in_socket_id, default) + + def write_mi_float_srgb_reflectance_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) + parent_node.set_property(in_socket_id, mi_spectra_utils.linear_rgb_to_luminance(reflectance)) + + _float_spectrum_writers = { + 'SRGBReflectanceSpectrum': write_mi_float_srgb_reflectance_spectrum + } + + def write_mi_float_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + mi_obj_class_name = mi_obj.class_().name() + if mi_obj_class_name not in self._float_spectrum_writers: + self.mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') + return + self._float_spectrum_writers[mi_obj_class_name](self, mi_obj, parent_node, in_socket_id, default) + + def write_mi_float_value(self, value, parent_node, in_socket_id, transformation=None): + if transformation is not None: + value = transformation(value) + parent_node.set_property(in_socket_id, value) + + def write_mi_rgb_bitmap(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_id = mi_texture.id() + bl_image = self.mi_context.get_bl_image(mi_texture_id) + if bl_image is None: + # If the image is not in the cache, load it from disk. + # This can happen if we have a texture inside of a BSDF that is itself into a + # twosided BSDF. + bl_image = textures.mi_texture_to_bl_image(self.mi_context, mi_texture) + if bl_image is None: + parent_node.set_property(in_socket_id, default) + return + self.mi_context.register_bl_image(mi_texture_id, bl_image) + + bitmap_node = parent_node.create_linked('MitsubaNodeBitmapTexture', in_socket_id, out_socket_id='Color') + bitmap_node.set_property('image', bl_image) + bitmap_node.set_property('filter_type', mi_texture.get('filter_type', 'bilinear')) + bitmap_node.set_property('wrap_mode', mi_texture.get('wrap_mode', 'repeat')) + bitmap_node.set_property('raw', mi_texture.get('raw', False)) + self.write_mi_transform2d_property(mi_texture, 'to_uv', bitmap_node, 'Transform') + + def write_mi_rgb_checkerboard(self, mi_texture, parent_node, in_socket_id, default=None): + checkerboard_node = parent_node.create_linked('MitsubaNodeCheckerboardTexture', in_socket_id, out_socket_id='Color') + self.write_mi_rgb_property(mi_texture, 'color0', checkerboard_node, 'Color 0', [0.4, 0.4, 0.4]) + self.write_mi_rgb_property(mi_texture, 'color1', checkerboard_node, 'Color 1', [0.2, 0.2, 0.2]) + self.write_mi_transform2d_property(mi_texture, 'to_uv', checkerboard_node, 'Transform') + + _rgb_texture_writers = { + 'bitmap': write_mi_rgb_bitmap, + 'checkerboard': write_mi_rgb_checkerboard, + } + + def write_mi_rgb_texture(self, mi_texture, parent_node, in_socket_id, default=None): + mi_texture_type = mi_texture.plugin_name() + if mi_texture_type not in self._rgb_texture_writers: + self.mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') + return + self._rgb_texture_writers[mi_texture_type](self, mi_texture, parent_node, in_socket_id, default) + + def write_mi_rgb_srgb_reflectance_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) + parent_node.set_property(in_socket_id, reflectance) + + _rgb_spectrum_writers = { + 'SRGBReflectanceSpectrum': write_mi_rgb_srgb_reflectance_spectrum + } + + def write_mi_rgb_spectrum(self, mi_obj, parent_node, in_socket_id, default=None): + mi_obj_class_name = mi_obj.class_().name() + if mi_obj_class_name not in self._rgb_spectrum_writers: + self.mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') + return + self._rgb_spectrum_writers[mi_obj_class_name](self, mi_obj, parent_node, in_socket_id, default) + + def write_mi_rgb_value(self, value, parent_node, in_socket_id): + parent_node.set_property(in_socket_id, value) + + def write_mi_transform2d_property(self, mi_props, mi_prop_name, parent_node, in_socket_id): + if mi_props.has_property(mi_prop_name): + transform = mi_props.get(mi_prop_name) + translation, rotation, scale = math_utils.decompose_transform_2d(transform) + transform_node = parent_node.create_linked('MitsubaNode2DTransform', in_socket_id, out_socket_id='Transform') + transform_node.set_property('translate_x', translation[0]) + transform_node.set_property('translate_y', translation[1]) + transform_node.set_property('rotate', rotation) + transform_node.set_property('scale_x', scale[0]) + transform_node.set_property('scale_y', scale[1]) + +###################### +## Main import ## +###################### + +def mi_material_to_bl_cycles_material(mi_context, bl_mat, mi_mat, mi_emitter=None): + ''' Convert a Mitsuba material to a Cycles shader node tree. + This is experimental and is not guaranteed to produce a perfect result. + ''' + bl_converter = BlenderMaterialConverter(mi_context) + bl_node_tree = nodetree.NodeTreeWrapper.init_cycles_material(bl_mat) + bl_node_tree.clear() + output_node = bl_node_tree.create_node('ShaderNodeOutputMaterial') + in_socket_id = 'Surface' + + # If the material is emissive, write the emission shader + if mi_emitter is not None: + old_output_node = output_node + output_node, in_socket_id = bl_converter.write_mi_emitter_bsdf(output_node, in_socket_id, mi_emitter) + + # Write the Mitsuba material to the surface output + bl_converter.write_mi_material(mi_mat, output_node, in_socket_id) + + # Restore the old material wrapper for formatting + if mi_emitter is not None: + output_node = old_output_node + + # Format the shader node graph + bl_node_tree.prettify() + +def mi_material_to_bl_mitsuba_material(mi_context, bl_mat, mi_mat, mi_emitter=None): + ''' Convert a Mitsuba material to a custom Mitsuba shader node tree ''' + mi_converter = MitsubaMaterialConverter(mi_context) + mi_node_tree = nodetree.NodeTreeWrapper.init_mitsuba_material(bl_mat) + mi_node_tree.clear() + output_node = mi_node_tree.create_node('MitsubaNodeOutputMaterial') + in_socket_id = 'BSDF' + + mi_converter.write_mi_material(mi_mat, output_node, in_socket_id) + + mi_node_tree.prettify() + +def mi_material_to_bl_material(mi_context, mi_mat, mi_emitter=None): + ''' Create a Blender node tree representing a given Mitsuba material + + Params + ------ + mi_context : Mitsuba import context + mi_mat : Mitsuba material properties + mi_emitter : Optional, Mitsuba area emitter properties + + Returns + ------- + The newly created Blender material + ''' + # Check that the emitter is of the correct type + assert mi_emitter is None or mi_emitter.plugin_name() == 'area' + + bl_mat = bpy.data.materials.new(name=mi_mat.id()) + + mi_material_to_bl_mitsuba_material(mi_context, bl_mat, mi_mat, mi_emitter) + + # Convert to Cycles if requested + if mi_context.with_cycles_nodes: + mi_material_to_bl_cycles_material(mi_context, bl_mat, mi_mat, mi_emitter) + + return bl_mat diff --git a/mitsuba-blender/io/importer/mi_props_utils.py b/mitsuba-blender/importer/mi_props_utils.py similarity index 100% rename from mitsuba-blender/io/importer/mi_props_utils.py rename to mitsuba-blender/importer/mi_props_utils.py diff --git a/mitsuba-blender/io/importer/mi_spectra_utils.py b/mitsuba-blender/importer/mi_spectra_utils.py similarity index 100% rename from mitsuba-blender/io/importer/mi_spectra_utils.py rename to mitsuba-blender/importer/mi_spectra_utils.py diff --git a/mitsuba-blender/importer/rfilters.py b/mitsuba-blender/importer/rfilters.py new file mode 100644 index 0000000..866e2c2 --- /dev/null +++ b/mitsuba-blender/importer/rfilters.py @@ -0,0 +1,57 @@ +import bpy + +def apply_mi_tent_properties(mi_context, mi_props): + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_box_props = getattr(mi_camera.rfilters, 'tent', None) + if bl_box_props is None: + mi_context.log(f'Mitsuba Reconstruction Filter "tent" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_camera.active_rfilter = 'tent' + # Cycles properties + # NOTE: Cycles does not have any equivalent to the tent filter + + return True + +def apply_mi_box_properties(mi_context, mi_props): + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_renderer = mi_context.bl_scene.cycles + bl_box_props = getattr(mi_camera.rfilters, 'box', None) + if bl_box_props is None: + mi_context.log(f'Mitsuba Reconstruction Filter "box" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_camera.active_rfilter = 'box' + # Cycles properties + bl_renderer.pixel_filter_type = 'BOX' + + return True + +def apply_mi_gaussian_properties(mi_context, mi_props): + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_renderer = mi_context.bl_scene.cycles + bl_box_props = getattr(mi_camera.rfilters, 'gaussian', None) + if bl_box_props is None: + mi_context.log(f'Mitsuba Reconstruction Filter "gaussian" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_camera.active_rfilter = 'gaussian' + bl_box_props.stddev = mi_props.get('stddev', 0.5) + # Cycles properties + bl_renderer.pixel_filter_type = 'GAUSSIAN' + bl_renderer.filter_width = mi_props.get('stddev', 0.5) + return True + +_mi_rfilter_properties_converters = { + 'box': apply_mi_box_properties, + 'tent': apply_mi_tent_properties, + 'gaussian': apply_mi_gaussian_properties, +} + +def apply_mi_rfilter_properties(mi_context, mi_props): + mi_rfilter_type = mi_props.plugin_name() + if mi_rfilter_type not in _mi_rfilter_properties_converters: + mi_context.log(f'Mitsuba Reconstruction Filter "{mi_rfilter_type}" is not supported.', 'ERROR') + return False + + return _mi_rfilter_properties_converters[mi_rfilter_type](mi_context, mi_props) diff --git a/mitsuba-blender/importer/samplers.py b/mitsuba-blender/importer/samplers.py new file mode 100644 index 0000000..dfeb71c --- /dev/null +++ b/mitsuba-blender/importer/samplers.py @@ -0,0 +1,70 @@ +import bpy + +def apply_mi_independent_properties(mi_context, mi_props): + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_renderer = mi_context.bl_scene.cycles + bl_independent_props = getattr(mi_camera.samplers, 'independent', None) + if bl_independent_props is None: + mi_context.log(f'Mitsuba Sampler "independent" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_camera.active_sampler = 'independent' + bl_independent_props.sample_count = mi_props.get('sample_count', 4) + bl_independent_props.seed = mi_props.get('seed', 0) + # Cycles properties + bl_renderer.sampling_pattern = 'SOBOL' + bl_renderer.samples = mi_props.get('sample_count', 4) + bl_renderer.preview_samples = mi_props.get('sample_count', 4) + bl_renderer.seed = mi_props.get('seed', 0) + return True + +def apply_mi_stratified_properties(mi_context, mi_props): + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_renderer = mi_context.bl_scene.cycles + bl_stratified_props = getattr(mi_camera.samplers, 'stratified', None) + if bl_stratified_props is None: + mi_context.log(f'Mitsuba Sampler "stratified" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_camera.active_sampler = 'stratified' + bl_stratified_props.sample_count = mi_props.get('sample_count', 4) + bl_stratified_props.seed = mi_props.get('seed', 0) + bl_stratified_props.jitter = mi_props.get('jitter', True) + # Cycles properties + # NOTE: There isn't any equivalent sampler in Blender. We use the default Sobol pattern. + bl_renderer.sampling_pattern = 'SOBOL' + bl_renderer.samples = mi_props.get('sample_count', 4) + bl_renderer.seed = mi_props.get('seed', 0) + return True + +def apply_mi_multijitter_properties(mi_context, mi_props): + mi_camera = mi_context.bl_scene.camera.data.mitsuba + bl_renderer = mi_context.bl_scene.cycles + bl_multijitter_props = getattr(mi_camera.samplers, 'multijitter', None) + if bl_multijitter_props is None: + mi_context.log(f'Mitsuba Sampler "multijitter" is not supported.', 'ERROR') + return False + # Mitsuba properties + mi_camera.active_sampler = 'multijitter' + bl_multijitter_props.sample_count = mi_props.get('sample_count', 4) + bl_multijitter_props.seed = mi_props.get('seed', 0) + bl_multijitter_props.jitter = mi_props.get('jitter', True) + # Cycles properties + bl_renderer.sampling_pattern = 'CORRELATED_MUTI_JITTER' if bpy.app.version < (3, 0, 0) else 'PROGRESSIVE_MULTI_JITTER' + bl_renderer.samples = mi_props.get('sample_count', 4) + bl_renderer.seed = mi_props.get('seed', 0) + return True + +_mi_sampler_properties_converters = { + 'independent': apply_mi_independent_properties, + 'stratified': apply_mi_stratified_properties, + 'multijitter': apply_mi_multijitter_properties, +} + +def apply_mi_sampler_properties(mi_context, mi_props): + mi_sampler_type = mi_props.plugin_name() + if mi_sampler_type not in _mi_sampler_properties_converters: + mi_context.log(f'Mitsuba Sampler "{mi_sampler_type}" is not supported.', 'ERROR') + return False + + return _mi_sampler_properties_converters[mi_sampler_type](mi_context, mi_props) diff --git a/mitsuba-blender/io/importer/sensors.py b/mitsuba-blender/importer/sensors.py similarity index 100% rename from mitsuba-blender/io/importer/sensors.py rename to mitsuba-blender/importer/sensors.py diff --git a/mitsuba-blender/io/importer/shapes.py b/mitsuba-blender/importer/shapes.py similarity index 94% rename from mitsuba-blender/io/importer/shapes.py rename to mitsuba-blender/importer/shapes.py index 01d68c9..3dbc31e 100644 --- a/mitsuba-blender/io/importer/shapes.py +++ b/mitsuba-blender/importer/shapes.py @@ -105,8 +105,12 @@ def mi_sphere_to_bl_shape(mi_context, mi_shape): radius = mi_shape.get('radius', 1.0) # Create a UV sphere mesh - # NOTE: The 'diameter' parameter seems to be missnamed as it results in sphere twice as big as expected - bmesh.ops.create_uvsphere(bl_bmesh, u_segments=32, v_segments=16, diameter=radius, calc_uvs=True) + # NOTE: The 'diameter' parameter seems to be missnamed as it results in sphere twice as big as expected. + # This was fixed in Blender 3.0 and later. + if bpy.app.version < (3, 0, 0): + bmesh.ops.create_uvsphere(bl_bmesh, u_segments=32, v_segments=16, diameter=radius, calc_uvs=True) + else: + bmesh.ops.create_uvsphere(bl_bmesh, u_segments=32, v_segments=16, radius=radius, calc_uvs=True) bl_bmesh.to_mesh(bl_mesh) bl_bmesh.free() diff --git a/mitsuba-blender/io/importer/textures.py b/mitsuba-blender/importer/textures.py similarity index 64% rename from mitsuba-blender/io/importer/textures.py rename to mitsuba-blender/importer/textures.py index caa9016..e522f40 100644 --- a/mitsuba-blender/io/importer/textures.py +++ b/mitsuba-blender/importer/textures.py @@ -19,15 +19,25 @@ def mi_bitmap_to_bl_image(mi_context, mi_texture): if bl_image is None: mi_context.log(f'Failed to load image from path "{filepath}".', 'ERROR') return None - bl_image.name = mi_texture.id() + # NOTE: We need to choose whether to keep the texture ID or the filename as Blender image name. + # This name will be used as the filename when exporting. + # bl_image.name = mi_texture.id() return bl_image +def mi_checkerboard_to_bl_image(mi_context, mi_texture): + # FIXME: Checkerboard textures do not need to reference a Blender image object. + # We therefore return a value other than None (which signifies failure) here + # as no one should use the value returned by this function. + # We need to find a better way of handling this. + return False + ###################### ## Main import ## ###################### _texture_converters = { 'bitmap': mi_bitmap_to_bl_image, + 'checkerboard': mi_checkerboard_to_bl_image, } def mi_texture_to_bl_image(mi_context, mi_texture): diff --git a/mitsuba-blender/io/importer/world.py b/mitsuba-blender/importer/world.py similarity index 54% rename from mitsuba-blender/io/importer/world.py rename to mitsuba-blender/importer/world.py index b4efc5d..5d2aec3 100644 --- a/mitsuba-blender/io/importer/world.py +++ b/mitsuba-blender/importer/world.py @@ -1,71 +1,60 @@ -if "bpy" in locals(): - import importlib - if "bl_material_utils" in locals(): - importlib.reload(bl_shader_utils) - if "mi_spectra_utils" in locals(): - importlib.reload(mi_spectra_utils) - if "bl_image_utils" in locals(): - importlib.reload(bl_image_utils) - import bpy -from mathutils import Color - -from . import bl_shader_utils from . import mi_spectra_utils from . import bl_image_utils +from ..utils import nodetree +from ..utils import material ############################## ## World property writers ## ############################## -def write_mi_srgb_emitter_spectrum(mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default=None): +def write_mi_srgb_emitter_spectrum(mi_context, mi_obj, parent_node, radiance_socket_id, strength_socket_id, default=None): color, strength = mi_spectra_utils.convert_mi_srgb_emitter_spectrum(mi_obj, default) - bl_world_wrap.out_node.inputs[radiance_socket_id].default_value = bl_shader_utils.rgb_to_rgba(color) - bl_world_wrap.out_node.inputs[strength_socket_id].default_value = strength - + parent_node.set_property(radiance_socket_id, material.rgb_to_rgba(color)) + parent_node.set_property(strength_socket_id, strength) + _emitter_spectrum_object_writers = { 'SRGBEmitterSpectrum': write_mi_srgb_emitter_spectrum } -def write_mi_emitter_spectrum_object(mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default=None): +def write_mi_emitter_spectrum_object(mi_context, mi_obj, parent_node, radiance_socket_id, strength_socket_id, default=None): mi_obj_class_name = mi_obj.class_().name() if mi_obj_class_name not in _emitter_spectrum_object_writers: mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') return - _emitter_spectrum_object_writers[mi_obj_class_name](mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default) + _emitter_spectrum_object_writers[mi_obj_class_name](mi_context, mi_obj, parent_node, radiance_socket_id, strength_socket_id, default) -def write_mi_world_radiance_property(mi_context, mi_emitter, mi_prop_name, bl_world_wrap, radiance_socket_id, strength_socket_id, default=None): +def write_mi_world_radiance_property(mi_context, mi_emitter, mi_prop_name, parent_node, radiance_socket_id, strength_socket_id, default=None): from mitsuba import Properties if mi_emitter.has_property(mi_prop_name): mi_prop_type = mi_emitter.type(mi_prop_name) if mi_prop_type == Properties.Type.Color: color, strength = mi_spectra_utils.get_color_strength_from_radiance(list(mi_emitter.get(mi_prop_name, default))) - bl_world_wrap.out_node.inputs[radiance_socket_id].default_value = bl_shader_utils.rgb_to_rgba(color) - bl_world_wrap.out_node.inputs[strength_socket_id].default_value = strength + parent_node.set_property(radiance_socket_id, material.rgb_to_rgba(color)) + parent_node.set_property(strength_socket_id, strength) elif mi_prop_type == Properties.Type.Object: mi_obj = mi_emitter.get(mi_prop_name) - write_mi_emitter_spectrum_object(mi_context, mi_obj, bl_world_wrap, radiance_socket_id, strength_socket_id, default) + write_mi_emitter_spectrum_object(mi_context, mi_obj, parent_node, radiance_socket_id, strength_socket_id, default) else: mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to rgb.', 'ERROR') elif default is not None: color, strength = mi_spectra_utils.get_color_strength_from_radiance(default) - bl_world_wrap.out_node.inputs[radiance_socket_id].default_value = bl_shader_utils.rgb_to_rgba(color) - bl_world_wrap.out_node.inputs[strength_socket_id].default_value = strength + parent_node.set_property(radiance_socket_id, material.rgb_to_rgba(color)) + parent_node.set_property(strength_socket_id, strength) else: - mi_context.log(f'Emitter "{bl_world_wrap.id()}" does not have property "{mi_prop_name}".', 'ERROR') + mi_context.log(f'Emitter "{mi_emitter.id()}" does not have property "{mi_prop_name}".', 'ERROR') ###################### ## Writers ## ###################### -def write_mi_constant_emitter(mi_context, mi_emitter, bl_world_wrap, out_socket_id): - bl_background = bl_world_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBackground', 'Background') - bl_background_wrap = bl_shader_utils.NodeWorldWrapper(bl_world_wrap.bl_world, out_node=bl_background) - write_mi_world_radiance_property(mi_context, mi_emitter, 'radiance', bl_background_wrap, 'Color', 'Strength', [0.8, 0.8, 0.8]) +def write_mi_constant_emitter(mi_context, mi_emitter, parent_node, in_socket_id): + background_node = parent_node.create_linked('ShaderNodeBackground', in_socket_id, out_socket_id='Background') + write_mi_world_radiance_property(mi_context, mi_emitter, 'radiance', background_node, 'Color', 'Strength', [0.8, 0.8, 0.8]) return True -def write_mi_envmap_emitter(mi_context, mi_emitter, bl_world_wrap, out_socket_id): +def write_mi_envmap_emitter(mi_context, mi_emitter, parent_node, in_socket_id): # Load the environment texture filepath = mi_context.resolve_scene_relative_path(mi_emitter.get('filename')) bl_image = bl_image_utils.load_bl_image_from_filepath(mi_context, filepath, is_data=False) @@ -74,12 +63,12 @@ def write_mi_envmap_emitter(mi_context, mi_emitter, bl_world_wrap, out_socket_id return False bl_image.name = mi_emitter.id() # Create the background shader node - bl_background = bl_world_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBackground', 'Background') - bl_background.inputs['Strength'].default_value = mi_emitter.get('scale', 1.0) + background_node = parent_node.create_linked('ShaderNodeBackground', in_socket_id, out_socket_id='Background') + background_node.set_property('Strength', mi_emitter.get('scale', 1.0)) # Create the environment texture node - bl_environment = bl_world_wrap.ensure_node_type([out_socket_id, 'Color'], 'ShaderNodeTexEnvironment', 'Color') - bl_environment.projection = 'EQUIRECTANGULAR' - bl_environment.image = bl_image + environment_node = background_node.create_linked('ShaderNodeTexEnvironment', 'Color', out_socket_id='Color') + environment_node.set_property('projection', 'EQUIRECTANGULAR') + environment_node.set_property('image', bl_image) # FIXME: Handle texture coordinate transforms return True @@ -87,19 +76,19 @@ def write_mi_envmap_emitter(mi_context, mi_emitter, bl_world_wrap, out_socket_id ## Main import ## ###################### -def write_bl_error_world(bl_world_wrap, out_socket_id): +def write_error_world(parent_node, in_socket_id): ''' Write a Blender error world that can be applied whenever a Mitsuba emitter cannot be loaded. ''' - bl_background = bl_world_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBackground', 'Background') - bl_background.inputs['Color'].default_value = [1.0, 0.0, 0.3, 1.0] + background_node = parent_node.create_linked('ShaderNodeBackground', in_socket_id, out_socket_id='Background') + background_node.set_property('Color', material.rgb_to_rgba(1.0, 0.0, 0.3)) _world_writers = { 'constant': write_mi_constant_emitter, 'envmap': write_mi_envmap_emitter, } -def write_mi_emitter_to_node_graph(mi_context, mi_emitter, bl_world_wrap, out_socket_id): +def write_mi_emitter(mi_context, mi_emitter, parent_node, in_socket_id): ''' Write a Mitsuba emitter in a node graph starting at a specific node in the shader graph. ''' @@ -108,8 +97,8 @@ def write_mi_emitter_to_node_graph(mi_context, mi_emitter, bl_world_wrap, out_so mi_context.log(f'Mitsuba Emitter type "{emitter_type}" not supported.', 'ERROR') return - if not _world_writers[emitter_type](mi_context, mi_emitter, bl_world_wrap, out_socket_id): - write_bl_error_world(bl_world_wrap, out_socket_id) + if not _world_writers[emitter_type](mi_context, mi_emitter, parent_node, in_socket_id): + write_error_world(parent_node, in_socket_id) def mi_emitter_to_bl_world(mi_context, mi_emitter): ''' Create a Blender node tree representing a given Mitsuba emitter @@ -124,13 +113,16 @@ def mi_emitter_to_bl_world(mi_context, mi_emitter): The newly created Blender world ''' bl_world = bpy.data.worlds.new(name=mi_emitter.id()) - bl_world_wrap = bl_shader_utils.NodeWorldWrapper(bl_world, init_empty=True) + node_tree = nodetree.NodeTreeWrapper.init_cycles_world(bl_world) + node_tree.clear() + output_node = node_tree.create_node('ShaderNodeOutputWorld') + in_socket_id = 'Surface' # Write the Mitsuba emitter to the world output - write_mi_emitter_to_node_graph(mi_context, mi_emitter, bl_world_wrap, 'Surface') + write_mi_emitter(mi_context, mi_emitter, output_node, in_socket_id) # Format the shader node graph - bl_world_wrap.format_node_tree() + node_tree.prettify() return bl_world @@ -143,10 +135,14 @@ def should_convert_mi_emitter_to_bl_world(mi_emitter): def create_default_bl_world(): ''' Create the default Blender world ''' bl_world = bpy.data.worlds.new(name='World') - bl_world_wrap = bl_shader_utils.NodeWorldWrapper(bl_world, init_empty=True) - bl_background = bl_world_wrap.ensure_node_type(['Surface'], 'ShaderNodeBackground', 'Background') + node_tree = nodetree.NodeTreeWrapper.init_cycles_world(bl_world) + node_tree.clear() + output_node = node_tree.create_node('ShaderNodeOutputWorld') + background_node = output_node.create_linked('ShaderNodeBackground', 'Surface', out_socket_id='Background') # NOTE: This is the default Blender background color for worlds. This is required in order to be # compatible with the exporter and the 'ignore_background' property - bl_background.inputs['Color'].default_value = [0.05087608844041824]*3 + [1.0] - bl_world_wrap.format_node_tree() + background_node.set_property('Color', material.rgb_to_rgba([0.05087608844041824]*3)) + + node_tree.prettify() + return bl_world diff --git a/mitsuba-blender/io/bl_utils.py b/mitsuba-blender/io/bl_utils.py deleted file mode 100644 index bb068e8..0000000 --- a/mitsuba-blender/io/bl_utils.py +++ /dev/null @@ -1,100 +0,0 @@ - -import bpy - -def init_empty_scene(bl_context, name='Scene', clear_all_scenes=False): - ''' Create an empty Blender scene with a specific name. - - If a scene already exists with the same name, it will be - cleared. - - Params - ------ - bl_context : Blender context - name : str, optional - Name of the newly created scene - clear_all_scenes : bool, optional - Delete all other scenes from the context - - Returns - ------- - The newly created Blender scene - ''' - # Create a temporary scene to be able to delete others. - # This is required as Blender needs at least one scene - tmp_scene = bpy.data.scenes.new('mi-tmp') - - if clear_all_scenes: - # Delete all scenes that are not the temporary one - for scene in bpy.data.scenes: - if scene.name != 'mi-tmp': - bpy.data.scenes.remove(scene) - # Clear all orphaned data - if bpy.app.version < (2, 93, 0): - # NOTE: Calling `orphans_purge` on Blender 2.83 - # results in a segfault. - for data_type in ( - bpy.data.objects, - bpy.data.meshes, - bpy.data.cameras, - bpy.data.images, - bpy.data.lights, - bpy.data.materials, - bpy.data.meshes, - bpy.data.textures, - bpy.data.worlds - ): - for data in data_type: - if data and data.users == 0: - data_type.remove(data) - else: - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - - # Check if the scene already exists - bl_scene = bpy.data.scenes.get(name) - if bl_scene is not None: - # Delete the scene if it exists - bpy.data.scenes.remove(bl_scene) - - bl_scene = bpy.data.scenes.new(name) - - # Delete the temporary scene - bpy.data.scenes.remove(tmp_scene) - - # Clear all orphaned data - if bpy.app.version < (2, 93, 0): - bpy.ops.outliner.orphans_purge() - else: - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - - return bl_scene - -def init_empty_collection(bl_scene, name='Collection'): - ''' Create an empty Blender collection with a specific name - in the provided scene. - - If a collection already exists with the same name, it will be - cleared. - - Params - ------ - bl_scene : Blender scene to instantiate the collection into - name : str, optional - The name of the new collection - - Returns - ------- - The newly created Blender collection - ''' - # Check if the collection already exists - bl_collection = bpy.data.collections.get(name) - if bl_collection is not None: - # Delete the collection if it exists - for obj in bl_collection.objects: - bpy.data.objects.remove(obj) - bpy.data.collections.remove(bl_collection) - else: - # Create the new collection - bl_collection = bpy.data.collections.new(name) - # Link the collection to the scene - bl_scene.collection.children.link(bl_collection) - return bl_collection \ No newline at end of file diff --git a/mitsuba-blender/io/importer/bl_shader_utils.py b/mitsuba-blender/io/importer/bl_shader_utils.py deleted file mode 100644 index c7bf3d5..0000000 --- a/mitsuba-blender/io/importer/bl_shader_utils.py +++ /dev/null @@ -1,243 +0,0 @@ -from collections import OrderedDict - -from mathutils import Color - -def rgb_to_rgba(color): - return color + [1.0] - -def rgba_to_rgb(color): - return Color(color[0], color[1], color[2]) - -class NodeShaderWrapper: - ''' Utility wrapper around a node-based Blender shader ''' - def __init__(self, bl_node_tree, init_empty=False, out_node=None): - ''' Construct a new NodeShaderWrapper - - Params - ------ - bl_node_tree : The wrapped Blender shader node tree - init_empty : bool, optional - If set, the material's node tree will be cleared - out_node : optional - Reference to the output (root) node of the material. - If not set, the default output material node is used. - If init_empty is set, this argument is ignored. - ''' - self.tree = bl_node_tree - # Clear the node tree if requested - if init_empty: - for node in self.tree.nodes: - self.tree.nodes.remove(node) - # Get the output node - self.out_node = self._ensure_out_node() - elif out_node is not None: - # Try to find the provided output node in the node tree - for node in self.tree.nodes: - if node == out_node: - self.out_node = node - assert self.out_node is not None - else: - # Ensure that an output node exists - self.out_node = self._ensure_out_node() - - def _delete_node_recursive(self, node): - for input in node.inputs: - if input.is_linked: - for link in input.links: - self._delete_node_recursive(link.from_node) - self.tree.nodes.remove(node) - - def _ensure_out_node(self): - raise NotImplementedError('To implement in subclasses') - - def _get_socket_with_id(self, socket_list, identifier): - for socket in socket_list: - if socket.identifier == identifier: - return socket - return None - - def ensure_node_type(self, path, bl_idname, output_socket_id): - ''' Ensures that a node of a certain type exists at the correct location - in the graph. If another node already exists at that location, then it is - removed. - - Params - ------ - path: list[str] - Path to the requested node. Each element of this list represent the identifier - of the input socket to follow starting from the output node. - bl_idname: str - Type of the node that should be connected to the last input in the path. - output_socket_id: str - Socket identifier of the newly created node that should be connected to the rest - of the path. - - Returns - ------- - The reference to the existing or newly created node. - ''' - current_node = self.out_node - next_socket = None - final_node = None - for i, next_id in enumerate(path): - # Ensure that a starting point for the path exists from the current node - next_socket = self._get_socket_with_id(current_node.inputs, next_id) - assert next_socket is not None - if i < len(path)-1: - # If this is not the last element of the path, follow the path if it exists - assert next_socket.is_linked - current_node = next_socket.links[0].from_node - elif next_socket.is_linked: - # If this is the last element of the path, check that the last node is of the - # correct type. If not, delete it recursively. - final_node = next_socket.links[0].from_node - if final_node.bl_idname != bl_idname: - self._delete_node_recursive(final_node) - final_node = None - # Create the new node only if it was not already present - if final_node is None: - final_node = self.tree.nodes.new(type=bl_idname) - output_socket = self._get_socket_with_id(final_node.outputs, output_socket_id) - assert output_socket is not None - self.tree.links.new(output_socket, next_socket) - return final_node - - def _get_node_depths(self): - def _traverse(node, graph=OrderedDict(), depth=0): - node_depth = depth - if node in graph: - current_node_depth = graph[node] - node_depth = depth if current_node_depth < depth else current_node_depth - graph[node] = node_depth - for input in node.inputs: - for link in input.links: - _traverse(link.from_node, graph, depth=depth+1) - return graph - - graph = _traverse(self.out_node) - depths = [] - for node, depth in graph.items(): - while len(depths) <= depth: - depths.append([]) - depths[depth].append(node) - return depths - - def _get_approximate_node_dimension(self, node): - ''' Get an approximation of a node's dimensions. - Nodes have dimensions attributes, however they are not updated until they are - displayed in the editor. Therefore, we cannot use them in this case as we create - and format the entire node tree in a script. - We use the number of inputs and outputs plus the header times a standard height - of 24 units as an approximation. POUET - ''' - # Hardcoded constant width - width = 240 - height = 24 * (len(node.inputs) + len(node.outputs) + 1) - return (width, height) - - def format_node_tree(self): - ''' Formats the placement of material nodes in the shader editor. ''' - margin_x = 100 - margin_y = 50 - - node_depths = self._get_node_depths() - tree_depth = len(node_depths) - - # 2D bbox, [min_x, min_y, max_x, max_y] - tree_bbox = [0.0, 0.0, 0.0, 0.0] - def expand_bbox(tree_bbox, other_bbox): - if other_bbox[0] < tree_bbox[0]: - tree_bbox[0] = other_bbox[0] - if other_bbox[1] < tree_bbox[1]: - tree_bbox[1] = other_bbox[1] - if other_bbox[2] > tree_bbox[2]: - tree_bbox[2] = other_bbox[2] - if other_bbox[3] > tree_bbox[3]: - tree_bbox[3] = other_bbox[3] - return tree_bbox - - current_x = 0.0 - for depth in range(tree_depth): - depth_width = 0.0 - depth_height = 0.0 - node_dims = [] - for node in node_depths[depth]: - node_width, node_height = self._get_approximate_node_dimension(node) - node_dims.append((node_width, node_height)) - if node_width > depth_width: - depth_width = node_width - depth_height += node_height + margin_y - - current_y = depth_height / 2.0 - for i, node in enumerate(node_depths[depth]): - node.location = (current_x, current_y) - node_width, node_height = node_dims[i] - tree_bbox = expand_bbox(tree_bbox, [current_x, current_y-node_height, current_x+node_width, current_y]) - current_y -= node_height + margin_y - - current_x -= depth_width + margin_x - - center = [(tree_bbox[0]+tree_bbox[2])/2.0, (tree_bbox[1]+tree_bbox[3])/2.0] - for node in self.tree.nodes: - current_location = node.location - node.location = (current_location[0]-center[0], current_location[1]-center[1]) - -class NodeMaterialWrapper(NodeShaderWrapper): - ''' Utility wrapper around a node-based Blender material ''' - def __init__(self, bl_mat, init_empty=False, out_node=None): - ''' Construct a new NodeMaterialWrapper - - Params - ------ - bl_mat : The wrapped Blender material - init_empty : bool, optional - If set, the material's node tree will be cleared - out_node : optional - Reference to the output (root) node of the material. - If not set, the default output material node is used. - If init_empty is set, this argument is ignored. - ''' - self.bl_mat = bl_mat - if not bl_mat.use_nodes: - bl_mat.use_nodes = True - super(NodeMaterialWrapper, self).__init__(bl_mat.node_tree, init_empty, out_node) - - def _ensure_out_node(self): - out_node = None - for node in self.tree.nodes: - if node.bl_idname == 'ShaderNodeOutputMaterial': - out_node = node - break - if out_node is None: - out_node = self.tree.nodes.new(type='ShaderNodeOutputMaterial') - return out_node - -class NodeWorldWrapper(NodeShaderWrapper): - ''' Utility wrapper around a node-based Blender world ''' - def __init__(self, bl_world, init_empty=False, out_node=None): - ''' Construct a new NodeWorldWrapper - - Params - ------ - bl_world : The wrapped Blender world - init_empty : bool, optional - If set, the material's node tree will be cleared - out_node : optional - Reference to the output (root) node of the material. - If not set, the default output material node is used. - If init_empty is set, this argument is ignored. - ''' - self.bl_world = bl_world - if not bl_world.use_nodes: - bl_world.use_nodes = True - super(NodeWorldWrapper, self).__init__(bl_world.node_tree, init_empty, out_node) - - def _ensure_out_node(self): - out_node = None - for node in self.tree.nodes: - if node.bl_idname == 'ShaderNodeOutputWorld': - out_node = node - break - if out_node is None: - out_node = self.tree.nodes.new(type='ShaderNodeOutputWorld') - return out_node diff --git a/mitsuba-blender/io/importer/materials.py b/mitsuba-blender/io/importer/materials.py deleted file mode 100644 index 2c6f78d..0000000 --- a/mitsuba-blender/io/importer/materials.py +++ /dev/null @@ -1,628 +0,0 @@ -import math - -if "bpy" in locals(): - import importlib - if "bl_material_utils" in locals(): - importlib.reload(bl_shader_utils) - if "mi_spectra_utils" in locals(): - importlib.reload(mi_spectra_utils) - if "mi_props_utils" in locals(): - importlib.reload(mi_props_utils) - if "textures" in locals(): - importlib.reload(textures) - -import bpy - -from . import bl_shader_utils -from . import mi_spectra_utils -from . import mi_props_utils -from . import textures - -################# -## Utilities ## -################# - -def _eval_mi_bsdf_retro_reflection(mi_context, mi_mat, default): - ''' Evaluate the reflectance color of a BSDF for a perfect perpendicular reflection ''' - from mitsuba import load_dict, BSDFContext, SurfaceInteraction3f, Vector3f - # Generate the BSDF properties dictionary - bsdf_dict = { - 'type': mi_mat.plugin_name(), - } - for name in mi_mat.property_names(): - bsdf_dict[name] = mi_mat.get(name) - - bsdf = load_dict(bsdf_dict) - si = SurfaceInteraction3f() - si.wi = Vector3f(0.0, 0.0, 1.0) - wo = Vector3f(0.0, 0.0, 1.0) - color, pdf = bsdf.eval_pdf(BSDFContext(), si, wo) - if pdf == 0.0: - return default - return list(color / pdf) - -################################ -## Misc property converters ## -################################ - -def mi_wrap_mode_to_bl_extension(mi_context, mi_wrap_mode): - if mi_wrap_mode == 'repeat': - return 'REPEAT' - elif mi_wrap_mode == 'mirror': - # NOTE: Blender does not support mirror wrap mode - return 'REPEAT' - elif mi_wrap_mode == 'clamp': - return 'CLIP' - else: - mi_context.log(f'Mitsuba wrap mode "{mi_wrap_mode}" is not supported.', 'ERROR') - return None - -def mi_filter_type_to_bl_interpolation(mi_context, mi_filter_type): - if mi_filter_type == 'bilinear': - return 'Cubic' - elif mi_filter_type == 'nearest': - return 'Closest' - else: - mi_context.log(f'Mitsuba filter type "{mi_filter_type}" is not supported.', 'ERROR') - return None - -_ior_string_values = { - 'acetone': 1.36, - 'acrylic glass': 1.49, - 'air': 1.00028, - 'amber': 1.55, - 'benzene': 1.501, - 'bk7': 1.5046, - 'bromine': 1.661, - 'carbon dioxide': 1.00045, - 'carbon tetrachloride': 1.461, - 'diamond': 2.419, - 'ethanol': 1.361, - 'fused quartz': 1.458, - 'glycerol': 1.4729, - 'helium': 1.00004, - 'hydrogen': 1.00013, - 'pet': 1.575, - 'polypropylene': 1.49, - 'pyrex': 1.470, - 'silicone oil': 1.52045, - 'sodium chloride': 1.544, - 'vacuum': 1.0, - 'water': 1.3330, - 'water ice': 1.31, -} - -def mi_ior_string_to_float(mi_context, mi_ior): - if mi_ior not in _ior_string_values: - mi_context.log(f'Mitsuba IOR name "{mi_ior}" is not supported.', 'ERROR') - return 1.0 - return _ior_string_values[mi_ior] - -_microfacet_distribution_values = { - 'beckmann': 'BECKMANN', - 'ggx': 'GGX' -} - -def mi_microfacet_to_bl_microfacet(mi_context, mi_microfacet_distribution): - if mi_microfacet_distribution not in _microfacet_distribution_values: - mi_context.log(f'Mitsuba microfacet distribution "{mi_microfacet_distribution}" not supported.', 'ERROR') - return 'BECKMANN' - return _microfacet_distribution_values[mi_microfacet_distribution] - -############################## -## Float property writers ## -############################## - -def write_mi_float_bitmap(mi_context, mi_texture, bl_mat_wrap, out_socket_id, default=None): - mi_texture_id = mi_texture.id() - bl_image = mi_context.get_bl_image(mi_texture_id) - if bl_image is None: - # FIXME: We forcibly disable sRGB conversion for float textures. - # This should probably be done elsewhere. - mi_texture['raw'] = True - # If the image is not in the cache, load it from disk. - # This can happen if we have a texture inside of a BSDF that is itself into a - # twosided BSDF. - bl_image = textures.mi_texture_to_bl_image(mi_context, mi_texture) - if bl_image is None: - bl_mat_wrap.out_node[out_socket_id].default_value = default - return - mi_context.register_bl_image(mi_texture_id, bl_image) - - # FIXME: Support texture coordinate mapping - # FIXME: For float textures, it is not always clear if we should use the 'Alpha' output instead of the luminance value. - bl_teximage = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeTexImage', 'Color') - bl_teximage.image = bl_image - bl_teximage.extension = mi_wrap_mode_to_bl_extension(mi_context, mi_texture.get('wrap_mode', 'repeat')) - bl_teximage.interpolation = mi_filter_type_to_bl_interpolation(mi_context, mi_texture.get('filter_type', 'bilinear')) - -_float_texture_writers = { - 'bitmap': write_mi_float_bitmap -} - -def write_mi_float_texture(mi_context, mi_texture, bl_mat_wrap, out_socket_id, default=None): - mi_texture_type = mi_texture.plugin_name() - if mi_texture_type not in _float_texture_writers: - mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') - return - _float_texture_writers[mi_texture_type](mi_context, mi_texture, bl_mat_wrap, out_socket_id, default) - -def write_mi_float_srgb_reflectance_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = mi_spectra_utils.linear_rgb_to_luminance(reflectance) - -_float_spectrum_writers = { - 'SRGBReflectanceSpectrum': write_mi_float_srgb_reflectance_spectrum -} - -def write_mi_float_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - mi_obj_class_name = mi_obj.class_().name() - if mi_obj_class_name not in _float_spectrum_writers: - mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') - return - _float_spectrum_writers[mi_obj_class_name](mi_context, mi_obj, bl_mat_wrap, out_socket_id, default) - -def write_mi_float_value(mi_context, float_value, bl_mat_wrap, out_socket_id, transformation=None): - if transformation is not None: - float_value = transformation(float_value) - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = float_value - -def write_mi_float_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None, transformation=None): - from mitsuba import Properties - if mi_mat.has_property(mi_prop_name): - mi_prop_type = mi_mat.type(mi_prop_name) - if mi_prop_type == Properties.Type.Float: - mi_prop_value = mi_mat.get(mi_prop_name, default) - write_mi_float_value(mi_context, mi_prop_value, bl_mat_wrap, out_socket_id, transformation) - elif mi_prop_type == Properties.Type.NamedReference: - mi_texture_ref_id = mi_mat.get(mi_prop_name) - mi_texture = mi_context.mi_scene_props.get_with_id_and_class(mi_texture_ref_id, 'Texture') - assert mi_texture is not None - write_mi_float_texture(mi_context, mi_texture, bl_mat_wrap, out_socket_id, default) - elif mi_prop_type == Properties.Type.Object: - mi_obj = mi_mat.get(mi_prop_name) - write_mi_float_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') - elif default is not None: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = default - else: - mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -############################ -## RGB property writers ## -############################ - -def write_mi_rgb_bitmap(mi_context, mi_texture, bl_mat_wrap, out_socket_id, default=None): - mi_texture_id = mi_texture.id() - bl_image = mi_context.get_bl_image(mi_texture_id) - if bl_image is None: - # If the image is not in the cache, load it from disk. - # This can happen if we have a texture inside of a BSDF that is itself into a - # twosided BSDF. - bl_image = textures.mi_texture_to_bl_image(mi_context, mi_texture) - if bl_image is None: - bl_mat_wrap.out_node[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(default) - return - mi_context.register_bl_image(mi_texture_id, bl_image) - - # FIXME: Support texture coordinate mapping - bl_teximage = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeTexImage', 'Color') - bl_teximage.image = bl_image - bl_teximage.extension = mi_wrap_mode_to_bl_extension(mi_context, mi_texture.get('wrap_mode', 'repeat')) - bl_teximage.interpolation = mi_filter_type_to_bl_interpolation(mi_context, mi_texture.get('filter_type', 'bilinear')) - -_rgb_texture_writers = { - 'bitmap': write_mi_rgb_bitmap -} - -def write_mi_rgb_texture(mi_context, mi_texture, bl_mat_wrap, out_socket_id, default=None): - mi_texture_type = mi_texture.plugin_name() - if mi_texture_type not in _rgb_texture_writers: - mi_context.log(f'Mitsuba Texture type "{mi_texture_type}" is not supported.', 'ERROR') - return - _rgb_texture_writers[mi_texture_type](mi_context, mi_texture, bl_mat_wrap, out_socket_id, default) - -def write_mi_rgb_srgb_reflectance_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - reflectance = mi_spectra_utils.convert_mi_srgb_reflectance_spectrum(mi_obj, default) - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(reflectance) - -_rgb_spectrum_writers = { - 'SRGBReflectanceSpectrum': write_mi_rgb_srgb_reflectance_spectrum -} - -def write_mi_rgb_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default=None): - mi_obj_class_name = mi_obj.class_().name() - if mi_obj_class_name not in _rgb_spectrum_writers: - mi_context.log(f'Mitsuba object type "{mi_obj_class_name}" is not supported.', 'ERROR') - return - _rgb_spectrum_writers[mi_obj_class_name](mi_context, mi_obj, bl_mat_wrap, out_socket_id, default) - -def write_mi_rgb_value(mi_context, rgb_value, bl_mat_wrap, out_socket_id): - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(rgb_value) - -def write_mi_rgb_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None): - from mitsuba import Properties - if mi_mat.has_property(mi_prop_name): - mi_prop_type = mi_mat.type(mi_prop_name) - if mi_prop_type == Properties.Type.Color: - write_mi_rgb_value(mi_context, list(mi_mat.get(mi_prop_name, default)), bl_mat_wrap, out_socket_id) - elif mi_prop_type == Properties.Type.NamedReference: - mi_texture_ref_id = mi_mat.get(mi_prop_name) - mi_texture = mi_context.mi_scene_props.get_with_id_and_class(mi_texture_ref_id, 'Texture') - assert mi_texture is not None - write_mi_rgb_texture(mi_context, mi_texture, bl_mat_wrap, out_socket_id, default) - elif mi_prop_type == Properties.Type.Object: - mi_obj = mi_mat.get(mi_prop_name) - write_mi_rgb_spectrum(mi_context, mi_obj, bl_mat_wrap, out_socket_id, default) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to rgb.', 'ERROR') - elif default is not None: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = bl_shader_utils.rgb_to_rgba(default) - else: - mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -############################ -## IOR property writers ## -############################ - -def write_mi_ior_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None): - from mitsuba import Properties - if mi_mat.has_property(mi_prop_name): - mi_prop_type = mi_mat.type(mi_prop_name) - if mi_prop_type == Properties.Type.Float: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = mi_mat.get(mi_prop_name, default) - elif mi_prop_type == Properties.Type.String: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = mi_ior_string_to_float(mi_context, mi_mat.get(mi_prop_name, 'bk7')) - else: - mi_context.log(f'Material property "{mi_prop_name}" of type "{mi_prop_type}" cannot be converted to float.', 'ERROR') - elif default is not None: - bl_mat_wrap.out_node.inputs[out_socket_id].default_value = default - else: - mi_context.log(f'Material "{mi_mat.id()}" does not have property "{mi_prop_name}".', 'ERROR') - -################################## -## Roughness property writers ## -################################## - -def write_mi_roughness_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default=None): - # FIXME: Check that the roughness value transformation is actually correct. - # FIXME: Verify that roughness textures don't also need to take the transformation into account. - write_mi_float_property(mi_context, mi_mat, mi_prop_name, bl_mat_wrap, out_socket_id, default ** 2, lambda x: math.sqrt(x)) - -############################# -## Normal & Bump writers ## -############################# - -def write_mi_bump_and_normal_maps(mi_context, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - normal_mat_wrap = bl_mat_wrap - if mi_bump is not None: - bl_bump = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBump', 'Normal') - bl_bump_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_bump) - mi_bump_textures = mi_props_utils.named_references_with_class(mi_context, mi_bump, 'Texture') - assert len(mi_bump_textures) == 1 - write_mi_float_bitmap(mi_context, mi_bump_textures[0], bl_bump_wrap, 'Height', 0.0) - # FIXME: Can we map directly this value ? - write_mi_float_property(mi_context, mi_bump, 'scale', bl_bump_wrap, 'Distance', 1.0) - normal_mat_wrap = bl_bump_wrap - - if mi_normal is not None: - bl_normal = normal_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeNormalMap', 'Normal') - bl_normal_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_normal) - write_mi_rgb_property(mi_context, mi_normal, 'normalmap', bl_normal_wrap, 'Color', [0.5, 0.5, 1.0]) - -########################### -## Area emitter writer ## -########################### - -def write_mi_emitter_bsdf(mi_context, bl_mat_wrap, out_socket_id, mi_emitter): - bl_add = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeAddShader', 'Shader') - bl_add_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_add) - - bl_emissive = bl_add_wrap.ensure_node_type(['Shader'], 'ShaderNodeEmission', 'Emission') - radiance, strength = mi_spectra_utils.convert_mi_srgb_emitter_spectrum(mi_emitter.get('radiance'), [1.0, 1.0, 1.0]) - bl_emissive.inputs['Color'].default_value = bl_shader_utils.rgb_to_rgba(radiance) - bl_emissive.inputs['Strength'].default_value = strength - - return bl_add_wrap, 'Shader_001' - -###################### -## BSDF writers ## -###################### - -def write_mi_principled_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_principled = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfPrincipled', 'BSDF') - bl_principled_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_principled) - write_mi_rgb_property(mi_context, mi_mat, 'base_color', bl_principled_wrap, 'Base Color', [0.8, 0.8, 0.8]) - write_mi_float_property(mi_context, mi_mat, 'specular', bl_principled_wrap, 'Specular', 0.5) - write_mi_float_property(mi_context, mi_mat, 'spec_tint', bl_principled_wrap, 'Specular Tint', 0.0) - write_mi_float_property(mi_context, mi_mat, 'spec_trans', bl_principled_wrap, 'Transmission', 0.0) - write_mi_float_property(mi_context, mi_mat, 'metallic', bl_principled_wrap, 'Metallic', 0.0) - write_mi_float_property(mi_context, mi_mat, 'anisotropic', bl_principled_wrap, 'Anisotropic', 0.0) - write_mi_roughness_property(mi_context, mi_mat, 'roughness', bl_principled_wrap, 'Roughness', 0.4) - write_mi_float_property(mi_context, mi_mat, 'sheen', bl_principled_wrap, 'Sheen', 0.0) - write_mi_float_property(mi_context, mi_mat, 'sheen_tint', bl_principled_wrap, 'Sheen Tint', 0.5) - write_mi_float_property(mi_context, mi_mat, 'clearcoat', bl_principled_wrap, 'Clearcoat', 0.0) - write_mi_roughness_property(mi_context, mi_mat, 'clearcoat_gloss', bl_principled_wrap, 'Clearcoat Roughness', 0.03) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_principled_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_diffuse_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_diffuse = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfDiffuse', 'BSDF') - bl_diffuse_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_diffuse) - write_mi_rgb_property(mi_context, mi_mat, 'reflectance', bl_diffuse_wrap, 'Color', [0.8, 0.8, 0.8]) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_diffuse_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_twosided_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - mi_child_materials = mi_props_utils.named_references_with_class(mi_context, mi_mat, 'BSDF') - mi_child_material_count = len(mi_child_materials) - if mi_child_material_count == 1: - # This case is handled by simply parsing the material. Blender materials are two-sided by default - # NOTE: We always parse the Mitsuba material; we don't use the material cache. - # This is because we have no way of reusing already created materials as a 'sub-material'. - write_mi_material_to_node_graph(mi_context, mi_child_materials[0], bl_mat_wrap, out_socket_id, is_within_twosided=True, mi_bump=mi_bump, mi_normal=mi_normal) - return True - elif mi_child_material_count == 2: - # This case is handled by creating a two-side material where the front face has the first - # material and the back face has the second one. - write_twosided_material(mi_context, bl_mat_wrap, out_socket_id, mi_child_materials[0], mi_child_materials[1], mi_bump=mi_bump, mi_normal=mi_normal) - return True - else: - mi_context.log(f'Mitsuba twosided material "{mi_mat.id()}" has {mi_child_material_count} child material(s). Expected 1 or 2.', 'ERROR') - return False - -def write_mi_dielectric_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glass = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlass', 'BSDF') - bl_glass_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glass) - # FIXME: Is this the correct distribution ? - bl_glass.distribution = 'SHARP' - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_glass_wrap, 'IOR', 1.5046) - write_mi_rgb_property(mi_context, mi_mat, 'specular_transmittance', bl_glass_wrap, 'Color', [1.0, 1.0, 1.0]) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glass_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_roughdielectric_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glass = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlass', 'BSDF') - bl_glass_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glass) - bl_glass.distribution = mi_microfacet_to_bl_microfacet(mi_context, mi_mat.get('distribution', 'beckmann')) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_glass_wrap, 'IOR', 1.5046) - write_mi_rgb_property(mi_context, mi_mat, 'specular_transmittance', bl_glass_wrap, 'Color', [1.0, 1.0, 1.0]) - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glass_wrap, 'Roughness', 0.1) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glass_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_thindielectric_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glass = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlass', 'BSDF') - bl_glass_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glass) - bl_glass.distribution = 'SHARP' - bl_glass.inputs['IOR'].default_value = 1.0 - write_mi_rgb_property(mi_context, mi_mat, 'specular_transmittance', bl_glass_wrap, 'Color', [1.0, 1.0, 1.0]) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glass_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_blend_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_mix = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeMixShader', 'Shader') - bl_mix_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_mix) - write_mi_float_property(mi_context, mi_mat, 'weight', bl_mix_wrap, 'Fac', 0.5) - # NOTE: We assume that the two BSDFs are ordered in the list of named references - mi_child_mats = mi_props_utils.named_references_with_class(mi_context, mi_mat, 'BSDF') - mi_child_mats_count = len(mi_child_mats) - if mi_child_mats_count != 2: - mi_context.log(f'Unexpected number of child BSDFs in blendbsdf. Expected 2 but got {mi_child_mats_count}.', 'ERROR') - return False - write_mi_material_to_node_graph(mi_context, mi_child_mats[0], bl_mix_wrap, 'Shader', mi_bump=mi_bump, mi_normal=mi_normal) - write_mi_material_to_node_graph(mi_context, mi_child_mats[1], bl_mix_wrap, 'Shader_001', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_conductor_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glossy = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlossy', 'BSDF') - bl_glossy_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glossy) - bl_glossy.distribution = 'SHARP' - reflectance = _eval_mi_bsdf_retro_reflection(mi_context, mi_mat, [1.0, 1.0, 1.0]) - write_mi_rgb_value(mi_context, reflectance, bl_glossy_wrap, 'Color') - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glossy_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_roughconductor_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_glossy = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfGlossy', 'BSDF') - bl_glossy_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_glossy) - bl_glossy.distribution = mi_microfacet_to_bl_microfacet(mi_context, mi_mat.get('distribution', 'beckmann')) - reflectance = _eval_mi_bsdf_retro_reflection(mi_context, mi_mat, [1.0, 1.0, 1.0]) - write_mi_rgb_value(mi_context, reflectance, bl_glossy_wrap, 'Color') - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_glossy_wrap, 'Roughness', 0.1) - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_glossy_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_mask_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_mix = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeMixShader', 'Shader') - bl_mix_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_mix) - # Connect the opacity. A value of 0 is completely transparent and 1 is completely opaque. - write_mi_float_property(mi_context, mi_mat, 'opacity', bl_mix_wrap, 'Fac', 0.5) - # Add a transparent node to the top socket of the mix shader - bl_mix_wrap.ensure_node_type(['Shader'], 'ShaderNodeBsdfTransparent', 'BSDF') - # Parse the other BSDF - mi_child_mats = mi_props_utils.named_references_with_class(mi_context, mi_mat, 'BSDF') - mi_child_mats_count = len(mi_child_mats) - if mi_child_mats_count != 1: - mi_context.log(f'Unexpected number of child BSDFs in mask BSDF. Expected 1 but got {mi_child_mats_count}.', 'ERROR') - return False - write_mi_material_to_node_graph(mi_context, mi_child_mats[0], bl_mix_wrap, 'Shader_001', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -# FIXME: The plastic and roughplastic don't have simple equivalent in Blender. We rely on a -# crude approximation using a Disney principled shader. -def write_mi_plastic_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_principled = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfPrincipled', 'BSDF') - bl_principled_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_principled) - write_mi_rgb_property(mi_context, mi_mat, 'diffuse_reflectance', bl_principled_wrap, 'Base Color', [0.5, 0.5, 0.5]) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_principled_wrap, 'IOR', 1.49) - bl_principled.inputs['Specular'].default_value = 0.2 - bl_principled.inputs['Specular Tint'].default_value = 1.0 - bl_principled.inputs['Roughness'].default_value = 0.0 - bl_principled.inputs['Clearcoat'].default_value = 0.8 - bl_principled.inputs['Clearcoat Roughness'].default_value = 0.0 - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_principled_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_roughplastic_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_principled = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfPrincipled', 'BSDF') - bl_principled_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_principled) - write_mi_rgb_property(mi_context, mi_mat, 'diffuse_reflectance', bl_principled_wrap, 'Base Color', [0.5, 0.5, 0.5]) - write_mi_ior_property(mi_context, mi_mat, 'int_ior', bl_principled_wrap, 'IOR', 1.49) - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_principled_wrap, 'Roughness', 0.1) - write_mi_roughness_property(mi_context, mi_mat, 'alpha', bl_principled_wrap, 'Clearcoat Roughness', 0.1) - bl_principled.distribution = mi_microfacet_to_bl_microfacet(mi_context, 'ggx') - bl_principled.inputs['Specular'].default_value = 0.2 - bl_principled.inputs['Specular Tint'].default_value = 1.0 - bl_principled.inputs['Clearcoat'].default_value = 0.8 - # Write normal and bump maps - write_mi_bump_and_normal_maps(mi_context, bl_principled_wrap, 'Normal', mi_bump=mi_bump, mi_normal=mi_normal) - return True - -def write_mi_bumpmap_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - if mi_bump is not None: - mi_context.log('Cannot have nested bumpmap BSDFs', 'ERROR') - return False - child_mats = mi_props_utils.named_references_with_class(mi_context, mi_mat, 'BSDF') - assert len(child_mats) == 1 - - write_mi_material_to_node_graph(mi_context, child_mats[0], bl_mat_wrap, out_socket_id, mi_bump=mi_mat, mi_normal=mi_normal) - return True - -def write_mi_normalmap_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - if mi_normal is not None: - mi_context.log('Cannot have nested normalmap BSDFs', 'ERROR') - return False - child_mats = mi_props_utils.named_references_with_class(mi_context, mi_mat, 'BSDF') - assert len(child_mats) == 1 - - write_mi_material_to_node_graph(mi_context, child_mats[0], bl_mat_wrap, out_socket_id, mi_bump=mi_bump, mi_normal=mi_mat) - return True - -def write_mi_null_bsdf(mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=None, mi_normal=None): - bl_transparent = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeTransparentBSDF', 'BSDF') - return True - -###################### -## Main import ## -###################### - -def write_twosided_material(mi_context, bl_mat_wrap, out_socket_id, mi_front_mat, mi_back_mat=None, mi_bump=None, mi_normal=None): - bl_mix = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeMixShader', 'Shader') - # Generate a geometry node that will select the correct BSDF based on face orientation - bl_mat_wrap.ensure_node_type([out_socket_id, 'Fac'], 'ShaderNodeNewGeometry', 'Backfacing') - # Create a new material wrapper with the mix shader as output node - bl_child_mat_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat_wrap.bl_mat, out_node=bl_mix) - # Write the child materials - write_mi_material_to_node_graph(mi_context, mi_front_mat, bl_child_mat_wrap, 'Shader', is_within_twosided=True, mi_bump=mi_bump, mi_normal=mi_normal) - if mi_back_mat is not None: - write_mi_material_to_node_graph(mi_context, mi_back_mat, bl_child_mat_wrap, 'Shader_001', is_within_twosided=True, mi_bump=mi_bump, mi_normal=mi_normal) - else: - bl_diffuse = bl_child_mat_wrap.ensure_node_type(['Shader_001'], 'ShaderNodeBsdfDiffuse', 'BSDF') - bl_diffuse.inputs['Color'].default_value = [0.0, 0.0, 0.0, 1.0] - return True - -def write_bl_error_material(bl_mat_wrap, out_socket_id): - ''' Write a Blender error material that can be applied whenever - a Mitsuba material cannot be loaded. - ''' - bl_diffuse = bl_mat_wrap.ensure_node_type([out_socket_id], 'ShaderNodeBsdfDiffuse', 'BSDF') - bl_diffuse.inputs['Color'].default_value = [1.0, 0.0, 0.3, 1.0] - -_material_writers = { - 'principled': write_mi_principled_bsdf, - 'diffuse': write_mi_diffuse_bsdf, - 'twosided': write_mi_twosided_bsdf, - 'dielectric': write_mi_dielectric_bsdf, - 'roughdielectric': write_mi_roughdielectric_bsdf, - 'thindielectric': write_mi_thindielectric_bsdf, - 'blendbsdf': write_mi_blend_bsdf, - 'conductor': write_mi_conductor_bsdf, - 'roughconductor': write_mi_roughconductor_bsdf, - 'mask': write_mi_mask_bsdf, - 'plastic': write_mi_plastic_bsdf, - 'roughplastic': write_mi_roughplastic_bsdf, - 'bumpmap': write_mi_bumpmap_bsdf, - 'normalmap': write_mi_normalmap_bsdf, - 'null': write_mi_null_bsdf, -} - -# List of materials that are always two-sided. These are the transmissive materials and -# a few material wrappers. -_always_twosided_bsdfs = [ - 'dielectric', - 'roughdielectric', - 'thindielectric', - 'mask', - 'bumpmap', - 'normalmap', - 'null', -] - -def write_mi_material_to_node_graph(mi_context, mi_mat, bl_mat_wrap, out_socket_id, is_within_twosided=False, mi_bump=None, mi_normal=None): - ''' Write a Mitsuba material in a node graph starting at a specific - node in the shader graph. This function is always guaranteed to succeed. - If a material cannot be converted, it will result in a distinctive error material. - ''' - mat_type = mi_mat.plugin_name() - if mat_type not in _material_writers: - mi_context.log(f'Mitsuba BSDF type "{mat_type}" not supported. Skipping.', 'WARN') - write_bl_error_material(bl_mat_wrap, out_socket_id) - return - - if is_within_twosided and mat_type == 'twosided': - mi_context.log('Cannot have nested twosided materials.', 'ERROR') - return - - if not is_within_twosided and mat_type != 'twosided' and mat_type not in _always_twosided_bsdfs: - # Write one-sided material - write_twosided_material(mi_context, bl_mat_wrap, out_socket_id, mi_front_mat=mi_mat, mi_back_mat=None, mi_bump=mi_bump, mi_normal=mi_normal) - elif not _material_writers[mat_type](mi_context, mi_mat, bl_mat_wrap, out_socket_id, mi_bump=mi_bump, mi_normal=mi_normal): - mi_context.log(f'Failed to convert Mitsuba material "{mi_mat.id()}". Skipping.', 'WARN') - write_bl_error_material(bl_mat_wrap, out_socket_id) - -def mi_material_to_bl_material(mi_context, mi_mat, mi_emitter=None): - ''' Create a Blender node tree representing a given Mitsuba material - - Params - ------ - mi_context : Mitsuba import context - mi_mat : Mitsuba material properties - mi_emitter : optional, Mitsuba area emitter properties - - Returns - ------- - The newly created Blender material - ''' - # Check that the emitter is of the correct type - assert mi_emitter is None or mi_emitter.plugin_name() == 'area' - - bl_mat = bpy.data.materials.new(name=mi_mat.id()) - bl_mat_wrap = bl_shader_utils.NodeMaterialWrapper(bl_mat, init_empty=True) - out_socket_id = 'Surface' - - # If the material is emissive, write the emission shader - if mi_emitter is not None: - old_bl_mat_wrap = bl_mat_wrap - bl_mat_wrap, out_socket_id = write_mi_emitter_bsdf(mi_context, bl_mat_wrap, out_socket_id, mi_emitter) - - # Write the Mitsuba material to the surface output - write_mi_material_to_node_graph(mi_context, mi_mat, bl_mat_wrap, out_socket_id) - - # Restore the old material wrapper for formatting - if mi_emitter is not None: - bl_mat_wrap = old_bl_mat_wrap - - # Format the shader node graph - bl_mat_wrap.format_node_tree() - - return bl_mat diff --git a/mitsuba-blender/io/importer/renderer.py b/mitsuba-blender/io/importer/renderer.py deleted file mode 100644 index faa1909..0000000 --- a/mitsuba-blender/io/importer/renderer.py +++ /dev/null @@ -1,299 +0,0 @@ -if "bpy" in locals(): - import importlib - if "mi_props_utils" in locals(): - importlib.reload(mi_props_utils) - -import bpy - -from . import mi_props_utils - -################# -## Utilities ## -################# - -_fileformat_values = { - 'openexr': 'OPEN_EXR', - 'exr': 'OPEN_EXR', - # FIXME: Support other file formats -} - -def mi_fileformat_to_bl_fileformat(mi_context, mi_file_format): - if mi_file_format not in _fileformat_values: - mi_context.log(f'Mitsuba Film image file format "{mi_file_format}" is not supported.', 'ERROR') - return None - return _fileformat_values[mi_file_format] - -_pixelformat_values = { - 'rgb': 'RGB', - 'rgba': 'RGBA', - # FIXME: Support other pixel formats -} - -def mi_pixelformat_to_bl_pixelformat(mi_context, mi_pixel_format): - if mi_pixel_format not in _pixelformat_values: - mi_context.log(f'Mitsuba Film image pixel format "{mi_pixel_format}" is not supported.', 'ERROR') - return None - return _pixelformat_values[mi_pixel_format] - -_componentformat_values = { - 'float16': '16', - 'float32': '32', - # FIXME: Support other component formats -} - -def mi_componentformat_to_bl_componentformat(mi_context, mi_component_format): - if mi_component_format not in _componentformat_values: - mi_context.log(f'Mitsuba Film image component format "{mi_component_format}" is not supported.', 'ERROR') - return None - return _componentformat_values[mi_component_format] - -############################# -## Integrator properties ## -############################# - -def apply_mi_path_properties(mi_context, mi_props, bl_props=None): - bl_integrator = mi_context.bl_scene.mitsuba if bl_props is None else bl_props - bl_path_props = getattr(bl_integrator.available_integrators, 'path', None) - if bl_path_props is None: - mi_context.log(f'Mitsuba Integrator "path" is not supported.', 'ERROR') - return False - bl_integrator.active_integrator = 'path' - bl_path_props.max_depth = mi_props.get('max_depth', -1) - bl_path_props.rr_depth = mi_props.get('rr_depth', 5) - bl_path_props.hide_emitters = mi_props.get('hide_emitters', False) - - # Cycles properties - if bl_props is None: - bl_renderer = mi_context.bl_scene.cycles - bl_renderer.progressive = 'PATH' - bl_max_bounces = mi_props.get('max_depth', 1024) - bl_renderer.max_bounces = bl_max_bounces - bl_renderer.diffuse_bounces = bl_max_bounces - bl_renderer.glossy_bounces = bl_max_bounces - bl_renderer.transparent_max_bounces = bl_max_bounces - bl_renderer.transmission_bounces = bl_max_bounces - bl_renderer.volume_bounces = bl_max_bounces - bl_renderer.min_light_bounces = mi_props.get('rr_depth', 5) - - return True - -def apply_mi_moment_properties(mi_context, mi_props, bl_props=None): - if bl_props is not None: - # FIXME: support moment integrator nesting - mi_context.log('Mitsuba Integrator "moment" does not support being nested yet.', 'ERROR') - return False - - mi_renderer = mi_context.bl_scene.mitsuba - bl_moment_props = getattr(mi_renderer.available_integrators, 'moment', None) - if bl_moment_props is None: - mi_context.log(f'Mitsuba Integrator "moment" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_renderer.active_integrator = 'moment' - bl_child_integrator_list = bl_moment_props.integrators - for mi_integrator_props in mi_props_utils.named_references_with_class(mi_context, mi_props, 'Integrator'): - bl_child_integrator_list.new(name=mi_integrator_props.id()) - bl_child_integrator = bl_child_integrator_list.collection[bl_child_integrator_list.count-1] - if not apply_mi_integrator_properties(mi_context, mi_integrator_props, bl_child_integrator): - return False - # Cycles properties - mi_context.log('Mitsuba Integrator "moment" is not supported in Blender Cycles', 'WARN') - - return True - -_mi_integrator_properties_converters = { - 'path': apply_mi_path_properties, - 'moment': apply_mi_moment_properties, -} - -def apply_mi_integrator_properties(mi_context, mi_props, bl_integrator_props=None): - mi_integrator_type = mi_props.plugin_name() - if mi_integrator_type not in _mi_integrator_properties_converters: - mi_context.log(f'Mitsuba Integrator "{mi_integrator_type}" is not supported.', 'ERROR') - return False - - return _mi_integrator_properties_converters[mi_integrator_type](mi_context, mi_props, bl_integrator_props) - -########################## -## RFilter properties ## -########################## - -def apply_mi_tent_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_box_props = getattr(mi_camera.rfilters, 'tent', None) - if bl_box_props is None: - mi_context.log(f'Mitsuba Reconstruction Filter "tent" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_rfilter = 'tent' - # Cycles properties - # NOTE: Cycles does not have any equivalent to the tent filter - - return True - -def apply_mi_box_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_box_props = getattr(mi_camera.rfilters, 'box', None) - if bl_box_props is None: - mi_context.log(f'Mitsuba Reconstruction Filter "box" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_rfilter = 'box' - # Cycles properties - bl_renderer.pixel_filter_type = 'BOX' - - return True - -def apply_mi_gaussian_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_box_props = getattr(mi_camera.rfilters, 'gaussian', None) - if bl_box_props is None: - mi_context.log(f'Mitsuba Reconstruction Filter "gaussian" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_rfilter = 'gaussian' - bl_box_props.stddev = mi_props.get('stddev', 0.5) - # Cycles properties - bl_renderer.pixel_filter_type = 'GAUSSIAN' - bl_renderer.filter_width = mi_props.get('stddev', 0.5) - return True - -_mi_rfilter_properties_converters = { - 'box': apply_mi_box_properties, - 'tent': apply_mi_tent_properties, - 'gaussian': apply_mi_gaussian_properties, -} - -def apply_mi_rfilter_properties(mi_context, mi_props): - mi_rfilter_type = mi_props.plugin_name() - if mi_rfilter_type not in _mi_rfilter_properties_converters: - mi_context.log(f'Mitsuba Reconstruction Filter "{mi_rfilter_type}" is not supported.', 'ERROR') - return False - - return _mi_rfilter_properties_converters[mi_rfilter_type](mi_context, mi_props) - -########################## -## Sampler properties ## -########################## - -def apply_mi_independent_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_independent_props = getattr(mi_camera.samplers, 'independent', None) - if bl_independent_props is None: - mi_context.log(f'Mitsuba Sampler "independent" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_sampler = 'independent' - bl_independent_props.sample_count = mi_props.get('sample_count', 4) - bl_independent_props.seed = mi_props.get('seed', 0) - # Cycles properties - bl_renderer.sampling_pattern = 'SOBOL' - bl_renderer.samples = mi_props.get('sample_count', 4) - bl_renderer.preview_samples = mi_props.get('sample_count', 4) - bl_renderer.seed = mi_props.get('seed', 0) - return True - -def apply_mi_stratified_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_stratified_props = getattr(mi_camera.samplers, 'stratified', None) - if bl_stratified_props is None: - mi_context.log(f'Mitsuba Sampler "stratified" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_sampler = 'stratified' - bl_stratified_props.sample_count = mi_props.get('sample_count', 4) - bl_stratified_props.seed = mi_props.get('seed', 0) - bl_stratified_props.jitter = mi_props.get('jitter', True) - # Cycles properties - # NOTE: There isn't any equivalent sampler in Blender. We use the default Sobol pattern. - bl_renderer.sampling_pattern = 'SOBOL' - bl_renderer.samples = mi_props.get('sample_count', 4) - bl_renderer.seed = mi_props.get('seed', 0) - return True - -def apply_mi_multijitter_properties(mi_context, mi_props): - mi_camera = mi_context.bl_scene.camera.data.mitsuba - bl_renderer = mi_context.bl_scene.cycles - bl_multijitter_props = getattr(mi_camera.samplers, 'multijitter', None) - if bl_multijitter_props is None: - mi_context.log(f'Mitsuba Sampler "multijitter" is not supported.', 'ERROR') - return False - # Mitsuba properties - mi_camera.active_sampler = 'multijitter' - bl_multijitter_props.sample_count = mi_props.get('sample_count', 4) - bl_multijitter_props.seed = mi_props.get('seed', 0) - bl_multijitter_props.jitter = mi_props.get('jitter', True) - # Cycles properties - bl_renderer.sampling_pattern = 'CORRELATED_MUTI_JITTER' if bpy.app.version < (3, 0, 0) else 'PROGRESSIVE_MULTI_JITTER' - bl_renderer.samples = mi_props.get('sample_count', 4) - bl_renderer.seed = mi_props.get('seed', 0) - return True - -_mi_sampler_properties_converters = { - 'independent': apply_mi_independent_properties, - 'stratified': apply_mi_stratified_properties, - 'multijitter': apply_mi_multijitter_properties, -} - -def apply_mi_sampler_properties(mi_context, mi_props): - mi_sampler_type = mi_props.plugin_name() - if mi_sampler_type not in _mi_sampler_properties_converters: - mi_context.log(f'Mitsuba Sampler "{mi_sampler_type}" is not supported.', 'ERROR') - return False - - return _mi_sampler_properties_converters[mi_sampler_type](mi_context, mi_props) - -####################### -## Film properties ## -####################### - -def apply_mi_hdrfilm_properties(mi_context, mi_props): - mi_context.bl_scene.render.resolution_percentage = 100 - render_dims = (mi_props.get('width', 768), mi_props.get('height', 576)) - mi_context.bl_scene.render.resolution_x = render_dims[0] - mi_context.bl_scene.render.resolution_y = render_dims[1] - mi_context.bl_scene.render.image_settings.file_format = mi_fileformat_to_bl_fileformat(mi_context, mi_props.get('file_format', 'openexr')) - mi_context.bl_scene.render.image_settings.color_mode = mi_pixelformat_to_bl_pixelformat(mi_context, mi_props.get('pixel_format', 'rgba')) - mi_context.bl_scene.render.image_settings.color_depth = mi_componentformat_to_bl_componentformat(mi_context, mi_props.get('component_format', 'float16')) - if mi_props.has_property('crop_offset_x') or mi_props.has_property('crop_offset_y') or mi_props.has_property('crop_width') or mi_props.has_property('crop_height'): - mi_context.bl_scene.render.use_border = True - # FIXME: Do we want to crop the resulting image ? - mi_context.bl_scene.render.use_crop_to_border = True - offset_x = mi_props.get('crop_offset_x', 0) - offset_y = mi_props.get('crop_offset_y', 0) - width = mi_props.get('crop_width', render_dims[0]) - height = mi_props.get('crop_height', render_dims[1]) - mi_context.bl_scene.render.border_min_x = offset_x / render_dims[0] - mi_context.bl_scene.render.border_max_x = (offset_x + width) / render_dims[0] - mi_context.bl_scene.render.border_min_y = offset_y / render_dims[1] - mi_context.bl_scene.render.border_max_y = (offset_y + height) / render_dims[1] - return True - -_mi_film_properties_converters = { - 'hdrfilm': apply_mi_hdrfilm_properties -} - -def apply_mi_film_properties(mi_context, mi_props): - mi_film_type = mi_props.plugin_name() - if mi_film_type not in _mi_film_properties_converters: - mi_context.log(f'Mitsuba Film "{mi_film_type}" is not supported.', 'ERROR') - return False - - return _mi_film_properties_converters[mi_film_type](mi_context, mi_props) - -########################### -## Renderer properties ## -########################### - -def init_mitsuba_renderer(mi_context): - mi_context.bl_scene.render.engine = 'MITSUBA' - mi_renderer = mi_context.bl_scene.mitsuba - if 'scalar_rgb' not in mi_renderer.variants(): - mi_context.log('Mitsuba variant "scalar_rgb" not available.', 'ERROR') - return False - mi_renderer.variant = 'scalar_rgb' - return True diff --git a/mitsuba-blender/nodes/__init__.py b/mitsuba-blender/nodes/__init__.py new file mode 100644 index 0000000..4579882 --- /dev/null +++ b/mitsuba-blender/nodes/__init__.py @@ -0,0 +1,13 @@ +def register(): + from . import (materials, textures, transforms, sockets) + sockets.register() + transforms.register() + textures.register() + materials.register() + +def unregister(): + from . import (materials, textures, transforms, sockets) + materials.unregister() + textures.unregister() + transforms.unregister() + sockets.unregister() diff --git a/mitsuba-blender/nodes/base.py b/mitsuba-blender/nodes/base.py new file mode 100644 index 0000000..f4127ab --- /dev/null +++ b/mitsuba-blender/nodes/base.py @@ -0,0 +1,158 @@ +from bpy.props import BoolProperty + +from ..utils.nodetree import get_output_nodes + +class MitsubaSocket: + ''' + Base class for custom Mitsuba node sockets + ''' + bl_label = '' + + color = (1, 1, 1, 1) + slider = False + + @classmethod + def is_input_connection_valid(cls, connected_socket): + for valid_cls in cls.valid_inputs: + if isinstance(connected_socket, valid_cls): + return True + return False + + def get_linked_node(self): + if self.is_linked and len(self.links) > 0: + return self.links[0].from_node + return None + + def has_valid_state(self): + # If this socket is linked, check that the connected + # socket is of a valid type. + if self.is_linked and len(self.links) > 0: + link = self.links[0] + if link and hasattr(self, 'valid_inputs'): + if not self.is_input_connection_valid(link.from_socket): + return False + return True + + def draw_prop(self, context, layout, node, text): + layout.prop(self, 'default_value', text=text, slider=self.slider) + + def draw(self, context, layout, node, text): + if not self.has_valid_state(): + layout.label(text='Wrong Input', icon='CANCEL') + return + + has_default = hasattr(self, "default_value") and self.default_value is not None + + if self.is_output or self.is_linked or not has_default: + layout.label(text=text) + else: + self.draw_prop(context, layout, node, text) + + def draw_color(self, context, node): + return self.color + + def to_default_dict(self, export_context): + ''' + Export the default value of a socket in a form the Mitsuba can understand + (either a value or a dictionary for complex types). + ''' + # Implement in subclasses + raise RuntimeError(f'{self.bl_idname} default conversion not implemented') + + def to_dict(self, export_context): + ''' + Convert this socket to a Mitsuba dictionary. + ''' + if not self.has_valid_state(): + export_context.log('Cannot export material: Invalid socket state', 'ERROR') + return None + linked_node = self.get_linked_node() + if linked_node is not None: + # If a socket is connected, convert the connected node to a Mitsuba dictionary + return linked_node.to_dict(export_context) + if hasattr(self, 'default_value'): + # If the socket is not connected and has a default value, convert it to a Mitsuba dictionary + return self.to_default_dict(export_context) + # Otherwise, this socket cannot be converted + return None + +class MitsubaNode: + ''' + Base class for custom Mitsuba shader nodes + ''' + bl_label = '' + bl_width_default = 160 + + @classmethod + def poll(cls, tree): + return tree.bl_idname == 'mitsuba_material_nodes' + + def init(self, context): + # Error node color + self.color = (1.0, 0.3, 0.3) + + def add_input(self, type, name, identifier='', default=None): + input = self.inputs.new(type, name, identifier=identifier) + if hasattr(input, 'default_value'): + input.default_value = default + return input + + def update(self): + # Activate the error node color if a socket has an invalid state + has_errors = False + for socket in self.inputs: + if hasattr(socket, 'has_valid_state') and not socket.has_valid_state(): + has_errors = True + self.use_custom_color = has_errors + + def to_dict(self, export_context): + # To implement in the subclasses + raise RuntimeError(f'{self.bl_idname} conversion not implemented.') + +class MitsubaNodeOutput(MitsubaNode): + ''' + Shader node representing a Mitsuba material output + ''' + def _update_active(output_node, context): + if not output_node.is_active: + output_node.is_active = True + output_node.disable_other_outputs() + + is_active: BoolProperty(name='Active', default=True, update=_update_active) + + def init(self, context): + super().init(context) + self.disable_other_outputs() + + def draw_buttons(self, context, layout): + layout.prop(self, 'is_active') + + def free(self): + if not self.is_active: + return + + node_tree = self.id_data + if node_tree is None: + return + for node in get_output_nodes(node_tree): + if node != self: + node['is_active'] = True + break + + def disable_other_outputs(self): + node_tree = self.id_data + if node_tree is None: + return + for node in get_output_nodes(node_tree): + if node != self: + node['is_active'] = False + +class MitsubaNodeTree: + ''' + Base class for custom Mitsuba shader node trees + ''' + bl_label = '' + + @classmethod + def poll(cls, context): + return context.scene.render.engine == 'MITSUBA' diff --git a/mitsuba-blender/nodes/materials/__init__.py b/mitsuba-blender/nodes/materials/__init__.py new file mode 100644 index 0000000..f794e7e --- /dev/null +++ b/mitsuba-blender/nodes/materials/__init__.py @@ -0,0 +1,40 @@ +from bpy.utils import register_class, unregister_class +from nodeitems_utils import register_node_categories, unregister_node_categories + +from . import ( + blend, bumpmap, nodetree, output, twosided, diffuse, dielectric, conductor, plastic, normalmap, mask, null, principled +) + +from .nodetree import mitsuba_node_categories_material + +classes = ( + nodetree.MitsubaNodeTreeMaterial, + output.MitsubaNodeOutputMaterial, + twosided.MitsubaNodeTwosidedBSDF, + diffuse.MitsubaNodeDiffuseBSDF, + dielectric.MitsubaNodeDielectricBSDF, + dielectric.MitsubaNodeThinDielectricBSDF, + dielectric.MitsubaNodeRoughDielectricBSDF, + conductor.MitsubaNodeConductorBSDF, + conductor.MitsubaNodeRoughConductorBSDF, + plastic.MitsubaNodePlasticBSDF, + plastic.MitsubaNodeRoughPlasticBSDF, + bumpmap.MitsubaNodeBumpMapBSDF, + normalmap.MitsubaNodeNormalMapBSDF, + blend.MitsubaNodeBlendBSDF, + mask.MitsubaNodeMaskBSDF, + null.MitsubaNodeNullBSDF, + principled.MitsubaNodePrincipledBSDF, +) + +def register(): + register_node_categories('MITSUBA_MATERIAL_TREE', mitsuba_node_categories_material) + + for cls in classes: + register_class(cls) + +def unregister(): + for cls in classes: + unregister_class(cls) + + unregister_node_categories('MITSUBA_MATERIAL_TREE') diff --git a/mitsuba-blender/nodes/materials/blend.py b/mitsuba-blender/nodes/materials/blend.py new file mode 100644 index 0000000..e773f8f --- /dev/null +++ b/mitsuba-blender/nodes/materials/blend.py @@ -0,0 +1,30 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeBlendBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba blend material + ''' + bl_idname = 'MitsubaNodeBlendBSDF' + bl_label = 'Blend BSDF' + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Weight', default=0.5) + self.add_input('MitsubaSocketBSDF', 'BSDF') + self.add_input('MitsubaSocketBSDF', 'BSDF', identifier='BSDF_001') + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def to_dict(self, export_context): + params = { 'type': 'blendbsdf' } + params['weight'] = self.inputs['Weight'].to_dict(export_context) + + bsdf1 = self.inputs[1].to_dict(export_context) + bsdf2 = self.inputs[2].to_dict(export_context) + if bsdf1 is None or bsdf2 is None: + export_context.log('Blend node does not have two input BSDFs', 'ERROR') + return None + params['bsdf1'] = bsdf1 + params['bsdf2'] = bsdf2 + return params diff --git a/mitsuba-blender/nodes/materials/bumpmap.py b/mitsuba-blender/nodes/materials/bumpmap.py new file mode 100644 index 0000000..6d6a0c2 --- /dev/null +++ b/mitsuba-blender/nodes/materials/bumpmap.py @@ -0,0 +1,40 @@ +import bpy +from bpy.props import FloatProperty +from ..base import MitsubaNode + +class MitsubaNodeBumpMapBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba bump material + ''' + bl_idname = 'MitsubaNodeBumpMapBSDF' + bl_label = 'Bump Map BSDF' + + scale: FloatProperty(name='Scale', default=1) + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketBSDF', 'BSDF') + self.add_input('MitsubaSocketFloatTextureNoDefault', 'Bump Height') + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def draw_buttons(self, context, layout): + layout.prop(self, 'scale') + + def to_dict(self, export_context): + params = { 'type': 'bumpmap' } + + bsdf = self.inputs['BSDF'].to_dict(export_context) + if bsdf is None: + export_context.log('Bump map node does not have an input BSDF', 'ERROR') + return None + params['bsdf'] = bsdf + + bump = self.inputs['Bump Height'].to_dict(export_context) + if bump is None: + export_context.log('Bump map node does not have a bump texture', 'ERROR') + return None + params['bump'] = bump + + params['scale'] = self.scale + return params diff --git a/mitsuba-blender/nodes/materials/conductor.py b/mitsuba-blender/nodes/materials/conductor.py new file mode 100644 index 0000000..c953d9d --- /dev/null +++ b/mitsuba-blender/nodes/materials/conductor.py @@ -0,0 +1,58 @@ +import bpy +from ..base import MitsubaNode +from .utils import ConductorPropertyHelper, AnisotropicRoughnessPropertyHelper + +class MitsubaNodeConductorBSDF(bpy.types.Node, MitsubaNode, ConductorPropertyHelper): + ''' + Shader node representing a Mitsuba conductor material + ''' + bl_idname = 'MitsubaNodeConductorBSDF' + bl_label = 'Conductor BSDF' + bl_width_default = 190 + + def init(self, context): + super().init(context) + self.add_conductor_inputs() + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.conductor_enum = 'none' + + def draw_buttons(self, context, layout): + self.draw_conductor_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'conductor' } + self.write_conductor_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + return params + +class MitsubaNodeRoughConductorBSDF(bpy.types.Node, MitsubaNode, ConductorPropertyHelper, AnisotropicRoughnessPropertyHelper): + ''' + Shader node representing a Mitsuba conductor material + ''' + bl_idname = 'MitsubaNodeRoughConductorBSDF' + bl_label = 'Rough Conductor BSDF' + bl_width_default = 190 + + def init(self, context): + super().init(context) + self.add_conductor_inputs() + self.add_roughness_inputs() + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.conductor_enum = 'none' + + def draw_buttons(self, context, layout): + self.draw_roughness_props(context, layout) + self.draw_conductor_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'roughconductor' } + self.write_conductor_props_to_dict(params, export_context) + self.write_roughness_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + return params diff --git a/mitsuba-blender/nodes/materials/dielectric.py b/mitsuba-blender/nodes/materials/dielectric.py new file mode 100644 index 0000000..a14756a --- /dev/null +++ b/mitsuba-blender/nodes/materials/dielectric.py @@ -0,0 +1,90 @@ +import bpy +from ..base import MitsubaNode +from .utils import TwosidedIORPropertyHelper, AnisotropicRoughnessPropertyHelper + +class MitsubaNodeDielectricBSDF(bpy.types.Node, MitsubaNode, TwosidedIORPropertyHelper): + ''' + Shader node representing a Mitsuba dielectric material + ''' + bl_idname = 'MitsubaNodeDielectricBSDF' + bl_label = 'Dielectric BSDF' + bl_width_default = 190 + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + self.add_input('MitsubaSocketColorTexture', 'Specular Transmittance', default=(1.0, 1.0, 1.0)) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.int_ior_enum = 'bk7' + self.ext_ior_enum = 'bk7' + + def draw_buttons(self, context, layout): + self.draw_ior_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'dielectric' } + self.write_ior_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + params['specular_transmittance'] = self.inputs['Specular Transmittance'].to_dict(export_context) + return params + +class MitsubaNodeThinDielectricBSDF(bpy.types.Node, MitsubaNode, TwosidedIORPropertyHelper): + ''' + Shader node representing a Mitsuba thin dielectric material + ''' + bl_idname = 'MitsubaNodeThinDielectricBSDF' + bl_label = 'Thin Dielectric BSDF' + bl_width_default = 190 + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + self.add_input('MitsubaSocketColorTexture', 'Specular Transmittance', default=(1.0, 1.0, 1.0)) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.int_ior_enum = 'bk7' + self.ext_ior_enum = 'bk7' + + def draw_buttons(self, context, layout): + self.draw_ior_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'thindielectric' } + self.write_ior_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + params['specular_transmittance'] = self.inputs['Specular Transmittance'].to_dict(export_context) + return params + +class MitsubaNodeRoughDielectricBSDF(bpy.types.Node, MitsubaNode, TwosidedIORPropertyHelper, AnisotropicRoughnessPropertyHelper): + ''' + Shader node representing a Mitsuba thin dielectric material + ''' + bl_idname = 'MitsubaNodeRoughDielectricBSDF' + bl_label = 'Rough Dielectric BSDF' + bl_width_default = 190 + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + self.add_input('MitsubaSocketColorTexture', 'Specular Transmittance', default=(1.0, 1.0, 1.0)) + self.add_roughness_inputs() + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.int_ior_enum = 'bk7' + self.ext_ior_enum = 'bk7' + + def draw_buttons(self, context, layout): + self.draw_roughness_props(context, layout) + self.draw_ior_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'roughdielectric' } + self.write_ior_props_to_dict(params, export_context) + self.write_roughness_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + params['specular_transmittance'] = self.inputs['Specular Transmittance'].to_dict(export_context) + return params diff --git a/mitsuba-blender/nodes/materials/diffuse.py b/mitsuba-blender/nodes/materials/diffuse.py new file mode 100644 index 0000000..eeb8ab9 --- /dev/null +++ b/mitsuba-blender/nodes/materials/diffuse.py @@ -0,0 +1,20 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeDiffuseBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba diffuse material + ''' + bl_idname = 'MitsubaNodeDiffuseBSDF' + bl_label = 'Diffuse BSDF' + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Reflectance', default=(0.5, 0.5, 0.5)) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def to_dict(self, export_context): + params = { 'type': 'diffuse' } + params['reflectance'] = self.inputs['Reflectance'].to_dict(export_context) + return params diff --git a/mitsuba-blender/nodes/materials/mask.py b/mitsuba-blender/nodes/materials/mask.py new file mode 100644 index 0000000..19c205f --- /dev/null +++ b/mitsuba-blender/nodes/materials/mask.py @@ -0,0 +1,27 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeMaskBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba mask material + ''' + bl_idname = 'MitsubaNodeMaskBSDF' + bl_label = 'Opacity Mask BSDF' + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Opacity', default=0.5) + self.add_input('MitsubaSocketBSDF', 'BSDF') + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def to_dict(self, export_context): + params = { 'type': 'mask' } + + bsdf = self.inputs['BSDF'].to_dict(export_context) + if bsdf is None: + export_context.log('Mask node does not have an input BSDF', 'ERROR') + return None + params['bsdf'] = bsdf + params['opacity'] = self.inputs['Opacity'].to_dict(export_context) + return params diff --git a/mitsuba-blender/nodes/materials/nodetree.py b/mitsuba-blender/nodes/materials/nodetree.py new file mode 100644 index 0000000..64a6fee --- /dev/null +++ b/mitsuba-blender/nodes/materials/nodetree.py @@ -0,0 +1,64 @@ +import bpy +from nodeitems_utils import NodeCategory, NodeItem + +from ..base import MitsubaNodeTree + +class MitsubaNodeTreeMaterial(bpy.types.NodeTree, MitsubaNodeTree): + ''' + Custom Blender Node Tree for Mitsuba BSDFs + ''' + bl_idname = 'mitsuba_material_nodes' + bl_label = 'Mitsuba Material Editor' + bl_icon = 'NODE_MATERIAL' + + @classmethod + def get_from_context(cls, context): + ''' + Updates the node tree with the currently selected material + ''' + obj = context.active_object + if obj and obj.type not in { 'LIGHT', 'CAMERA' }: + mat = obj.active_material + if mat: + node_tree = mat.mitsuba.node_tree + if node_tree: + return node_tree, mat, mat + return None, None, None + +class MitsubaNodeCategoryMaterial(NodeCategory): + @classmethod + def poll(cls, context): + return context.space_data.tree_type == MitsubaNodeTreeMaterial.bl_idname + +mitsuba_node_categories_material = [ + MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_BSDF', 'BSDFs', items=[ + NodeItem('MitsubaNodeTwosidedBSDF', label='Twosided'), + NodeItem('MitsubaNodeDiffuseBSDF', label='Diffuse'), + NodeItem('MitsubaNodeDielectricBSDF', label='Dielectric'), + NodeItem('MitsubaNodeThinDielectricBSDF', label='Thin Dielectric'), + NodeItem('MitsubaNodeRoughDielectricBSDF', label='Rough Dielectric'), + NodeItem('MitsubaNodeConductorBSDF', label='Conductor'), + NodeItem('MitsubaNodeRoughConductorBSDF', label='Rough Conductor'), + NodeItem('MitsubaNodePlasticBSDF', label='Plastic'), + NodeItem('MitsubaNodeRoughPlasticBSDF', label='Rough Plastic'), + NodeItem('MitsubaNodeBumpMapBSDF', label='Bump Map'), + NodeItem('MitsubaNodeNormalMapBSDF', label='Normal Map'), + NodeItem('MitsubaNodeBlendBSDF', label='Blend'), + NodeItem('MitsubaNodeMaskBSDF', label='Opacity Mask'), + NodeItem('MitsubaNodeNullBSDF', label='Null'), + NodeItem('MitsubaNodePrincipledBSDF', label='Principled'), + ]), + + MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_TEXTURE', 'Textures', items=[ + NodeItem('MitsubaNodeBitmapTexture', label='Bitmap'), + NodeItem('MitsubaNodeCheckerboardTexture', label='Checkerboard'), + ]), + + MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_OUTPUT', 'Output', items=[ + NodeItem('MitsubaNodeOutputMaterial', label='Output'), + ]), + + MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_TRANSFORM', 'Transforms', items=[ + NodeItem('MitsubaNode2DTransform', label='Transform 2D'), + ]), +] diff --git a/mitsuba-blender/nodes/materials/normalmap.py b/mitsuba-blender/nodes/materials/normalmap.py new file mode 100644 index 0000000..69c5234 --- /dev/null +++ b/mitsuba-blender/nodes/materials/normalmap.py @@ -0,0 +1,27 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeNormalMapBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba normal map material + ''' + bl_idname = 'MitsubaNodeNormalMapBSDF' + bl_label = 'Normal Map BSDF' + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketBSDF', 'BSDF') + self.add_input('MitsubaSocketNormalMap', 'Normal Map') + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def to_dict(self, export_context): + params = { 'type': 'normalmap' } + + bsdf = self.inputs['BSDF'].to_dict(export_context) + if bsdf is None: + export_context.log('Normal map node does not have an input BSDF', 'ERROR') + return None + params['bsdf'] = bsdf + params['normalmap'] = self.inputs['Normal Map'].to_dict(export_context) + return params diff --git a/mitsuba-blender/nodes/materials/null.py b/mitsuba-blender/nodes/materials/null.py new file mode 100644 index 0000000..092e82a --- /dev/null +++ b/mitsuba-blender/nodes/materials/null.py @@ -0,0 +1,17 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeNullBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba null material + ''' + bl_idname = 'MitsubaNodeNullBSDF' + bl_label = 'Null BSDF' + + def init(self, context): + super().init(context) + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def to_dict(self, export_context): + params = { 'type': 'null' } + return params diff --git a/mitsuba-blender/nodes/materials/output.py b/mitsuba-blender/nodes/materials/output.py new file mode 100644 index 0000000..75352dc --- /dev/null +++ b/mitsuba-blender/nodes/materials/output.py @@ -0,0 +1,22 @@ +import bpy +from ..base import MitsubaNodeOutput + +class MitsubaNodeOutputMaterial(bpy.types.Node, MitsubaNodeOutput): + ''' + Shader node representing a Mitsuba material output + ''' + bl_idname = 'MitsubaNodeOutputMaterial' + bl_label = 'Material Output' + + def init(self, context): + self.add_input('MitsubaSocketBSDF', 'BSDF') + super().init(context) + + def draw_buttons(self, context, layout): + super().draw_buttons(context, layout) + + def to_dict(self, export_context): + if not self.is_active: + export_context.log('Output node is not active. Skipping.', 'WARN') + return None + return self.inputs['BSDF'].to_dict(export_context) diff --git a/mitsuba-blender/nodes/materials/plastic.py b/mitsuba-blender/nodes/materials/plastic.py new file mode 100644 index 0000000..9fb9d03 --- /dev/null +++ b/mitsuba-blender/nodes/materials/plastic.py @@ -0,0 +1,71 @@ +import bpy +from bpy.props import BoolProperty +from ..base import MitsubaNode +from .utils import TwosidedIORPropertyHelper, RoughnessPropertyHelper + +class MitsubaNodePlasticBSDF(bpy.types.Node, MitsubaNode, TwosidedIORPropertyHelper): + ''' + Shader node representing a Mitsuba plastic material + ''' + bl_idname = 'MitsubaNodePlasticBSDF' + bl_label = 'Plastic BSDF' + bl_width_default = 190 + + nonlinear: BoolProperty(name='Nonlinear', default=False) + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Diffuse Reflectance', default=(0.5, 0.5, 0.5)) + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.int_ior_enum = 'polypropylene' + self.ext_ior_enum = 'air' + + def draw_buttons(self, context, layout): + layout.prop(self, 'nonlinear') + self.draw_ior_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'plastic' } + params['diffuse_reflectance'] = self.inputs['Diffuse Reflectance'].to_dict(export_context) + params['nonlinear'] = self.nonlinear + self.write_ior_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + return params + +class MitsubaNodeRoughPlasticBSDF(bpy.types.Node, MitsubaNode, TwosidedIORPropertyHelper, RoughnessPropertyHelper): + ''' + Shader node representing a Mitsuba rough plastic material + ''' + bl_idname = 'MitsubaNodeRoughPlasticBSDF' + bl_label = 'Rough Plastic BSDF' + bl_width_default = 190 + + nonlinear: BoolProperty(name='Nonlinear', default=False) + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Diffuse Reflectance', default=(0.5, 0.5, 0.5)) + self.add_input('MitsubaSocketColorTexture', 'Specular Reflectance', default=(1.0, 1.0, 1.0)) + self.add_roughness_inputs() + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + self.int_ior_enum = 'polypropylene' + self.ext_ior_enum = 'air' + + def draw_buttons(self, context, layout): + self.draw_roughness_props(context, layout) + layout.prop(self, 'nonlinear') + self.draw_ior_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'roughplastic' } + params['diffuse_reflectance'] = self.inputs['Diffuse Reflectance'].to_dict(export_context) + params['nonlinear'] = self.nonlinear + self.write_ior_props_to_dict(params, export_context) + self.write_roughness_props_to_dict(params, export_context) + params['specular_reflectance'] = self.inputs['Specular Reflectance'].to_dict(export_context) + return params diff --git a/mitsuba-blender/nodes/materials/principled.py b/mitsuba-blender/nodes/materials/principled.py new file mode 100644 index 0000000..e85059c --- /dev/null +++ b/mitsuba-blender/nodes/materials/principled.py @@ -0,0 +1,47 @@ +import bpy +from bpy.props import FloatProperty +from .utils import OnesidedIORPropertyHelper +from ..base import MitsubaNode + +class MitsubaNodePrincipledBSDF(bpy.types.Node, MitsubaNode, OnesidedIORPropertyHelper): + ''' + Shader node representing a Mitsuba principled material + ''' + bl_idname = 'MitsubaNodePrincipledBSDF' + bl_label = 'Principled BSDF' + bl_width_default = 190 + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Base Color', default=(0.5, 0.5, 0.5)) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Roughness', default=0.5) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Anisotropic', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Metallic', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Specular Transmission', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Specular Tint', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Sheen', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Sheen Tint', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Flatness', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Clearcoat', default=0.0) + self.add_input('MitsubaSocketFloatTextureBounded0to1', 'Clearcoat Gloss', default=0.0) + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def draw_buttons(self, context, layout): + self.draw_ior_props(context, layout) + + def to_dict(self, export_context): + params = { 'type': 'principled' } + params['base_color'] = self.inputs['Base Color'].to_dict(export_context) + params['roughness'] = self.inputs['Roughness'].to_dict(export_context) + params['anisotropic'] = self.inputs['Anisotropic'].to_dict(export_context) + params['metallic'] = self.inputs['Metallic'].to_dict(export_context) + params['spec_trans'] = self.inputs['Specular Transmission'].to_dict(export_context) + params['spec_tint'] = self.inputs['Specular Tint'].to_dict(export_context) + params['sheen'] = self.inputs['Sheen'].to_dict(export_context) + params['sheen_tint'] = self.inputs['Sheen Tint'].to_dict(export_context) + params['flatness'] = self.inputs['Flatness'].to_dict(export_context) + params['clearcoat'] = self.inputs['Clearcoat'].to_dict(export_context) + params['clearcoat_gloss'] = self.inputs['Clearcoat Gloss'].to_dict(export_context) + self.write_ior_props_to_dict(params, export_context) + return params diff --git a/mitsuba-blender/nodes/materials/twosided.py b/mitsuba-blender/nodes/materials/twosided.py new file mode 100644 index 0000000..a40a5cb --- /dev/null +++ b/mitsuba-blender/nodes/materials/twosided.py @@ -0,0 +1,29 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeTwosidedBSDF(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba twosided material + ''' + bl_idname = 'MitsubaNodeTwosidedBSDF' + bl_label = 'Twosided BSDF' + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketBSDF', 'BSDF') + self.add_input('MitsubaSocketBSDF', 'BSDF', identifier='BSDF_001') + + self.outputs.new('MitsubaSocketBSDF', 'BSDF') + + def to_dict(self, export_context): + params = { 'type': 'twosided' } + bsdf1 = self.inputs[0].to_dict(export_context) + bsdf2 = self.inputs[1].to_dict(export_context) + if bsdf1 is None and bsdf2 is None: + export_context.log('Twosided node must be connected to at least one BSDF', 'ERROR') + return None + if bsdf1 is not None: + params['bsdf1'] = bsdf1 + if bsdf2 is not None: + params['bsdf2'] = bsdf2 + return params diff --git a/mitsuba-blender/nodes/materials/utils.py b/mitsuba-blender/nodes/materials/utils.py new file mode 100644 index 0000000..c2b4350 --- /dev/null +++ b/mitsuba-blender/nodes/materials/utils.py @@ -0,0 +1,252 @@ +from bpy.props import BoolProperty, EnumProperty, FloatProperty + +IOR_FLOAT_PRECISION = 5 +IOR_PRESETS = ( + # (mitsuba_id, value, display_name) + ('acetone', 1.36, 'Acetone'), + ('acrylic glass', 1.49, 'Acrylic glass'), + ('air', 1.00028, 'Air'), + ('amber', 1.55, 'Amber'), + ('benzene', 1.501, 'Benzene'), + ('bk7', 1.5046, 'BK7'), + ('bromine', 1.661, 'Bromine'), + ('carbon dioxide', 1.00045, 'Carbon dioxide'), + ('carbon tetrachloride', 1.461, 'Carbon tetrachloride'), + ('diamond', 2.419, 'Diamond'), + ('ethanol', 1.361, 'Ethanol'), + ('fused quartz', 1.458, 'Fused quartz'), + ('glycerol', 1.4729, 'Glycerol'), + ('helium', 1.00004, 'Helium'), + ('hydrogen', 1.00013, 'Hydrogen'), + ('pet', 1.575, 'PET'), + ('polypropylene', 1.49, 'Polypropylene'), + ('pyrex', 1.470, 'Pyrex'), + ('silicone oil', 1.52045, 'Silicone oil'), + ('sodium chloride', 1.544, 'Sodium chloride'), + ('vacuum', 1.0, 'Vacuum'), + ('water', 1.3330, 'Water'), + ('water ice', 1.31, 'Water ice'), +) + +def _generate_ior_dict(): + dict = {} + enum = [('custom', 'Custom', '', 0)] + for i, (mitsuba_name, value, display_name) in enumerate(IOR_PRESETS): + enum.append((mitsuba_name, display_name, '', i+1)) + dict[mitsuba_name] = value + return enum, dict + +class OnesidedIORPropertyHelper: + ''' + Helper class for transmissive material with IOR properties + ''' + eta_enum_items, eta_value_dict = _generate_ior_dict() + + def _update_eta_enum(self, enum_prop, value_prop, update_prop): + enum_value = getattr(self, enum_prop) + if enum_value != 'custom': + # We don't perform the value update logic in that situation + setattr(self, update_prop, False) + setattr(self, value_prop, OnesidedIORPropertyHelper.eta_value_dict[enum_value]) + + def _update_eta_value(self, enum_prop, value_prop, update_prop): + if getattr(self, update_prop): + setattr(self, enum_prop, 'custom') + else: + setattr(self, update_prop, True) + + eta_need_value_update: BoolProperty(default=True) + eta_enum: EnumProperty(items=eta_enum_items, + name='IOR Preset', + update=lambda self, context: self._update_eta_enum('eta_enum', 'eta_value', 'eta_need_value_update')) + eta_value: FloatProperty(name='IOR Value', + min=1, + precision=IOR_FLOAT_PRECISION, + update=lambda self, context: self._update_eta_value('eta_enum', 'eta_value', 'eta_need_value_update')) + + def draw_ior_props(self, context, layout): + layout.label(text='IOR') + split = layout.split(factor=0.5) + split.prop(self, 'eta_enum', text='') + split.prop(self, 'eta_value', text='') + + def write_ior_props_to_dict(self, dict, export_context): + dict['eta'] = self.eta_value if self.eta_enum == 'custom' else self.eta_enum + +class TwosidedIORPropertyHelper: + ''' + Helper class for transmissive material with interior/exterior IOR properties + ''' + ior_enum_items, ior_value_dict = _generate_ior_dict() + + def _update_ior_enum(self, enum_prop, value_prop, update_prop): + enum_value = getattr(self, enum_prop) + if enum_value != 'custom': + # We don't perform the value update logic in that situation + setattr(self, update_prop, False) + setattr(self, value_prop, TwosidedIORPropertyHelper.ior_value_dict[enum_value]) + + def _update_ior_value(self, enum_prop, value_prop, update_prop): + if getattr(self, update_prop): + setattr(self, enum_prop, 'custom') + else: + setattr(self, update_prop, True) + + int_ior_need_value_update: BoolProperty(default=True) + int_ior_enum: EnumProperty(items=ior_enum_items, + name='IOR Preset', + update=lambda self, context: self._update_ior_enum('int_ior_enum', 'int_ior_value', 'int_ior_need_value_update')) + int_ior_value: FloatProperty(name='IOR Value', + min=1, + precision=IOR_FLOAT_PRECISION, + update=lambda self, context: self._update_ior_value('int_ior_enum', 'int_ior_value', 'int_ior_need_value_update')) + + ext_ior_need_value_update: BoolProperty(default=True) + ext_ior_enum: EnumProperty(items=ior_enum_items, + name='IOR Preset', + update=lambda self, context: self._update_ior_enum('ext_ior_enum', 'ext_ior_value', 'ext_ior_need_value_update')) + ext_ior_value: FloatProperty(name='IOR Value', + min=1, + precision=IOR_FLOAT_PRECISION, + update=lambda self, context: self._update_ior_value('ext_ior_enum', 'ext_ior_value', 'ext_ior_need_value_update')) + + def draw_ior_props(self, context, layout): + layout.label(text='Interior IOR') + split = layout.split(factor=0.5) + split.prop(self, 'int_ior_enum', text='') + split.prop(self, 'int_ior_value', text='') + + layout.label(text='Exterior IOR') + split = layout.split(factor=0.5) + split.prop(self, 'ext_ior_enum', text='') + split.prop(self, 'ext_ior_value', text='') + + def write_ior_props_to_dict(self, dict, export_context): + dict['int_ior'] = self.int_ior_value if self.int_ior_enum == 'custom' else self.int_ior_enum + dict['ext_ior'] = self.ext_ior_value if self.ext_ior_enum == 'custom' else self.ext_ior_enum + +class ConductorPropertyHelper: + ''' + Helper class for conductor specific node properties + ''' + conductor_enum_items = ( + ('none', 'Custom', '', 0), + ('a-C', 'Amorphous carbon', '', 1), + ('Ag', 'Silver', '', 2), + ('Al', 'Aluminium', '', 3), + ('AlAs', 'Cubic aluminium arsenide', '', 4), + ('AlSb', 'Cubic aluminium antimonide', '', 5), + ('Au', 'Gold', '', 6), + ('Be', 'Polycrystalline beryllium', '', 7), + ('Cr', 'Chromium', '', 8), + ('CsI', 'Cubic caesium iodide', '', 9), + ('Cu', 'Copper', '', 10), + ('Cu2O', 'Copper (I) oxide', '', 11), + ('CuO', 'Copper (II) oxide', '', 12), + ('d-C', 'Cubic diamond', '', 13), + ('Hg', 'Mercury', '', 14), + ('HgTe', 'Mercury telluride', '', 15), + ('Ir', 'Iridium', '', 16), + ('K', 'Polycrystalline potassium', '', 17), + ('Li', 'Lithium', '', 18), + ('MgO', 'Magnesium oxide', '', 19), + ('Mo', 'Molybdenum', '', 20), + ('Na_palik', 'Sodium', '', 21), + ('Nb', 'Niobium', '', 22), + ('Ni_palik', 'Nickel', '', 23), + ('Rh', 'Rhodium', '', 24), + ('Se', 'Selenium', '', 25), + ('SiC', 'Hexagonal silicon carbide', '', 26), + ('SnTe', 'Tin telluride', '', 27), + ('Ta', 'Tantalum', '', 28), + ('Te', 'Trigonal tellurium', '', 29), + ('ThF4', 'Polycryst. thorium (IV) fluoride', '', 30), + ('TiC', 'Polycrystalline titanium carbide', '', 31), + ('TiN', 'Titanium nitride', '', 32), + ('TiO2', 'Tetragonal titan. dioxide', '', 33), + ('VC', 'Vanadium carbide', '', 34), + ('V_palik', 'Vanadium', '', 35), + ('VN', 'Vanadium nitride', '', 36), + ('W', 'Tungsten', '', 37), + ) + + def _update_conductor_enum(self, context): + should_enable_mat_params = self.conductor_enum == 'none' + if 'Eta' in self.inputs and 'K' in self.inputs: + self.inputs['Eta'].enabled = should_enable_mat_params + self.inputs['K'].enabled = should_enable_mat_params + + conductor_enum: EnumProperty(items=conductor_enum_items, + name='Conductor Preset', + update=_update_conductor_enum) + + def add_conductor_inputs(self): + self.add_input('MitsubaSocketFloatTextureUnbounded', 'Eta', default=0) + self.add_input('MitsubaSocketFloatTextureUnbounded', 'K', default=1) + + def draw_conductor_props(self, context, layout): + split = layout.split(factor=0.3) + split.label(text='Material') + split.prop(self, 'conductor_enum', text='') + + def write_conductor_props_to_dict(self, dict, export_context): + if self.conductor_enum == 'none': + dict['eta'] = self.inputs['Eta'].to_dict(export_context) + dict['k'] = self.inputs['K'].to_dict(export_context) + else: + dict['material'] = self.conductor_enum + +class RoughnessPropertyHelper: + ''' + Helper class for isotropic rough material nodes + ''' + distribution_enum_items = ( + ('beckmann', 'Beckmann', '', 0), + ('ggx', 'GGX', '', 1), + ) + + distribution: EnumProperty(items=distribution_enum_items, + name='Microfacet Model') + + sample_visible: BoolProperty(default=True, name='Visible Sampling') + + def add_roughness_inputs(self): + self.add_input('MitsubaSocketFloatTextureUnbounded', 'Alpha', default=0.1) + + def draw_roughness_props(self, context, layout): + layout.prop(self, 'distribution', text='') + layout.prop(self, 'sample_visible') + + def write_roughness_props_to_dict(self, dict, export_context): + dict['distribution'] = self.distribution + dict['sample_visible'] = self.sample_visible + if self.inputs['Alpha'].enabled: + dict['alpha'] = self.inputs['Alpha'].to_dict(export_context) + +class AnisotropicRoughnessPropertyHelper(RoughnessPropertyHelper): + ''' + Helper class for anisotropic rough material nodes + ''' + def _update_anisotropic(self, context): + if 'Alpha' in self.inputs and 'Alpha U' in self.inputs and 'Alpha V' in self.inputs: + use_anisotropic = self.anisotropic + self.inputs['Alpha'].enabled = not use_anisotropic + self.inputs['Alpha U'].enabled = use_anisotropic + self.inputs['Alpha V'].enabled = use_anisotropic + + anisotropic: BoolProperty(default=False, name='Anisotropic Roughness', update=_update_anisotropic) + + def add_roughness_inputs(self): + super().add_roughness_inputs() + self.add_input('MitsubaSocketFloatTextureUnbounded', 'Alpha U', default=0.1).enabled = False + self.add_input('MitsubaSocketFloatTextureUnbounded', 'Alpha V', default=0.1).enabled = False + + def draw_roughness_props(self, context, layout): + super().draw_roughness_props(context, layout) + layout.prop(self, 'anisotropic') + + def write_roughness_props_to_dict(self, dict, export_context): + super().write_roughness_props_to_dict(dict, export_context) + if self.anisotropic: + dict['alpha_u'] = self.inputs['Alpha U'].to_dict(export_context) + dict['alpha_v'] = self.inputs['Alpha V'].to_dict(export_context) diff --git a/mitsuba-blender/nodes/sockets.py b/mitsuba-blender/nodes/sockets.py new file mode 100644 index 0000000..847a0e6 --- /dev/null +++ b/mitsuba-blender/nodes/sockets.py @@ -0,0 +1,90 @@ +import bpy +from bpy.utils import register_class, unregister_class +from bpy.props import FloatProperty, FloatVectorProperty + +from .base import MitsubaSocket + +class MitsubaSocketColors: + ''' + Collection of default socket colors for various socket types + ''' + bsdf = (0.39, 0.78, 0.39, 1.0) + color_texture = (0.78, 0.78, 0.16, 1.0) + float_texture = (0.63, 0.63, 0.63, 1.0) + transform_2d = (0.65, 0.55, 0.75, 1.0) + +class MitsubaSocketBSDF(bpy.types.NodeSocket, MitsubaSocket): + color = MitsubaSocketColors.bsdf + +class MitsubaSocketColorTexture(bpy.types.NodeSocket, MitsubaSocket): + color = MitsubaSocketColors.color_texture + default_value: FloatVectorProperty(subtype='COLOR', soft_min=0, soft_max=1, precision=3) + + def draw_prop(self, context, layout, node, text): + split = layout.split(factor=0.7) + split.label(text=text) + split.prop(self, 'default_value', text='') + + def to_default_dict(self, export_context): + return { + 'type': 'rgb', + 'value': list(self.default_value), + } + +class MitsubaSocketNormalMap(bpy.types.NodeSocket, MitsubaSocket): + color = MitsubaSocketColors.color_texture + +class MitsubaSocketFloatTexture(bpy.types.NodeSocket, MitsubaSocket): + color = MitsubaSocketColors.float_texture + + def to_default_dict(self, export_context): + return self.default_value + +class MitsubaSocketFloatTextureNoDefault(MitsubaSocketFloatTexture): + pass + +class MitsubaSocketFloatTextureBounded0to1(MitsubaSocketFloatTexture): + default_value: FloatProperty(min=0, max=1, description='Float value between 0 and 1') + slider = True + +class MitsubaSocketFloatTextureUnbounded(MitsubaSocketFloatTexture): + default_value: FloatProperty(description='Float value') + +class MitsubaSocket2DTransform(bpy.types.NodeSocket, MitsubaSocket): + color = MitsubaSocketColors.transform_2d + +############################### +## Valid input connections ## +############################### + +MitsubaSocketBSDF.valid_inputs = { MitsubaSocketBSDF } + +MitsubaSocketColorTexture.valid_inputs = { MitsubaSocketColorTexture } + +MitsubaSocketNormalMap.valid_inputs = { MitsubaSocketColorTexture } + +MitsubaSocketFloatTexture.valid_inputs = { MitsubaSocketFloatTexture, MitsubaSocketColorTexture } + +MitsubaSocket2DTransform.valid_inputs = { MitsubaSocket2DTransform } + +#################### +## Registration ## +#################### + +classes = ( + MitsubaSocketBSDF, + MitsubaSocketColorTexture, + MitsubaSocketNormalMap, + MitsubaSocketFloatTextureNoDefault, + MitsubaSocketFloatTextureUnbounded, + MitsubaSocketFloatTextureBounded0to1, + MitsubaSocket2DTransform, +) + +def register(): + for cls in classes: + register_class(cls) + +def unregister(): + for cls in classes: + unregister_class(cls) diff --git a/mitsuba-blender/nodes/textures/__init__.py b/mitsuba-blender/nodes/textures/__init__.py new file mode 100644 index 0000000..995842f --- /dev/null +++ b/mitsuba-blender/nodes/textures/__init__.py @@ -0,0 +1,18 @@ +import bpy +from bpy.utils import register_class, unregister_class +from . import ( + bitmap, checkerboard +) + +classes = ( + bitmap.MitsubaNodeBitmapTexture, + checkerboard.MitsubaNodeCheckerboardTexture, +) + +def register(): + for cls in classes: + register_class(cls) + +def unregister(): + for cls in classes: + unregister_class(cls) diff --git a/mitsuba-blender/nodes/textures/bitmap.py b/mitsuba-blender/nodes/textures/bitmap.py new file mode 100644 index 0000000..aa64c19 --- /dev/null +++ b/mitsuba-blender/nodes/textures/bitmap.py @@ -0,0 +1,70 @@ +import bpy +from bpy.props import PointerProperty, EnumProperty, BoolProperty +from ..base import MitsubaNode + +class MitsubaNodeBitmapTexture(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba bitmap texture + ''' + bl_idname = 'MitsubaNodeBitmapTexture' + bl_label = 'Bitmap Texture' + bl_width_default = 200 + + image: PointerProperty(name="Image", type=bpy.types.Image) + + filter_type_enum_items = ( + ('bilinear', 'Bilinear', '', 0), + ('nearest', 'Nearest', '', 1), + ) + filter_type: EnumProperty(items=filter_type_enum_items, name='Filter Type', default='bilinear') + + wrap_mode_enum_items = ( + ('repeat', 'Repeat', '', 0), + ('mirror', 'Mirror', '', 1), + ('clamp', 'Clamp', '', 0), + ) + wrap_mode: EnumProperty(items=wrap_mode_enum_items, name='Wrap Mode', default='repeat') + + raw: BoolProperty(name='Raw', default=False) + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocket2DTransform', 'Transform') + + self.outputs.new('MitsubaSocketColorTexture', 'Color') + + def draw_label(self): + return self.image.name if self.image else self.bl_label + + def draw_buttons(self, context, layout): + layout.template_ID(self, 'image', open='image.open', new='image.new') + + col = layout.column() + col.active = self.image is not None + + row = col.row() + row.prop(self, 'raw') + + col.prop(self, 'filter_type', text='') + col.prop(self, 'wrap_mode', text='') + + if self.image: + col.prop(self.image, 'source', text='') + + if self.image.source in { 'MOVIE', 'TILED' }: + col.label(text="Unsupported Source!", icon='X') + + def to_dict(self, export_context): + params = { 'type': 'bitmap' } + if self.image is not None: + params['filename'] = export_context.export_texture(self.image) + else: + export_context.log('Bitmap node does not have a selected image to export', 'ERROR') + return None + params['filter_type'] = self.filter_type + params['wrap_mode'] = self.wrap_mode + params['raw'] = self.raw + transform = self.inputs['Transform'].to_dict(export_context) + if transform is not None: + params['to_uv'] = transform + return params diff --git a/mitsuba-blender/nodes/textures/checkerboard.py b/mitsuba-blender/nodes/textures/checkerboard.py new file mode 100644 index 0000000..17713d9 --- /dev/null +++ b/mitsuba-blender/nodes/textures/checkerboard.py @@ -0,0 +1,26 @@ +import bpy +from ..base import MitsubaNode + +class MitsubaNodeCheckerboardTexture(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba checkerboard texture + ''' + bl_idname = 'MitsubaNodeCheckerboardTexture' + bl_label = 'Checkerboard Texture' + + def init(self, context): + super().init(context) + self.add_input('MitsubaSocketColorTexture', 'Color 0', default=(0.4, 0.4, 0.4)) + self.add_input('MitsubaSocketColorTexture', 'Color 1', default=(0.2, 0.2, 0.2)) + self.add_input('MitsubaSocket2DTransform', 'Transform') + + self.outputs.new('MitsubaSocketColorTexture', 'Color') + + def to_dict(self, export_context): + params = { 'type': 'checkerboard' } + params['color0'] = self.inputs['Color 0'].to_dict(export_context) + params['color1'] = self.inputs['Color 1'].to_dict(export_context) + transform = self.inputs['Transform'].to_dict(export_context) + if transform is not None: + params['to_uv'] = transform + return params diff --git a/mitsuba-blender/nodes/transforms/__init__.py b/mitsuba-blender/nodes/transforms/__init__.py new file mode 100644 index 0000000..1ade1e4 --- /dev/null +++ b/mitsuba-blender/nodes/transforms/__init__.py @@ -0,0 +1,17 @@ +import bpy +from bpy.utils import register_class, unregister_class +from . import ( + transform2d +) + +classes = ( + transform2d.MitsubaNode2DTransform, +) + +def register(): + for cls in classes: + register_class(cls) + +def unregister(): + for cls in classes: + unregister_class(cls) diff --git a/mitsuba-blender/nodes/transforms/transform2d.py b/mitsuba-blender/nodes/transforms/transform2d.py new file mode 100644 index 0000000..f30f606 --- /dev/null +++ b/mitsuba-blender/nodes/transforms/transform2d.py @@ -0,0 +1,47 @@ +import bpy +from bpy.props import FloatProperty +from ..base import MitsubaNode +from ...utils import math as math_utils + +import math + +class MitsubaNode2DTransform(bpy.types.Node, MitsubaNode): + ''' + Shader node representing a Mitsuba 2d transform + ''' + bl_idname = 'MitsubaNode2DTransform' + bl_label = 'Transform 2D' + bl_width_default = 190 + + translate_x: FloatProperty(name='Translation X', default=0) + translate_y: FloatProperty(name='Translation Y', default=0) + + rotate: FloatProperty(name='Rotation', default=0, min=(-math.pi * 2), max=(math.pi * 2), + subtype='ANGLE', unit='ROTATION') + + scale_x: FloatProperty(name='Scale X', default=1) + scale_y: FloatProperty(name='Scale Y', default=1) + + def init(self, context): + super().init(context) + self.outputs.new('MitsubaSocket2DTransform', 'Transform') + + def draw_buttons(self, context, layout): + layout.label(text='Translate') + row = layout.row(align=True) + row.prop(self, 'translate_x', text='X') + row.prop(self, 'translate_y', text='Y') + + layout.label(text='Rotation') + layout.prop(self, 'rotate', text='') + + layout.label(text='Scale') + row = layout.row(align=True) + row.prop(self, 'scale_x', text='X') + row.prop(self, 'scale_y', text='Y') + + def to_dict(self, export_context): + translation = [self.translate_x, self.translate_y] + rotation = self.rotate + scale = [self.scale_x, self.scale_y] + return math_utils.compose_transform_2d(translation, rotation, scale) diff --git a/mitsuba-blender/operators/__init__.py b/mitsuba-blender/operators/__init__.py new file mode 100644 index 0000000..57c967f --- /dev/null +++ b/mitsuba-blender/operators/__init__.py @@ -0,0 +1,26 @@ +from bpy.utils import register_class, unregister_class + +from . import ( + material, nodetree, scene +) + +classes = ( + material.MITSUBA_OT_material_new, + material.MITSUBA_OT_material_unlink, + material.MITSUBA_OT_material_copy, + material.MITSUBA_OT_material_select, + nodetree.MITSUBA_OT_material_node_tree_show, + nodetree.MITSUBA_OT_material_node_tree_new, + nodetree.MITSUBA_OT_material_node_tree_set, + scene.MITSUBA_OT_scene_init_empty, + scene.MITSUBA_OT_scene_import, + scene.MITSUBA_OT_scene_export, +) + +def register(): + for cls in classes: + register_class(cls) + +def unregister(): + for cls in classes: + unregister_class(cls) diff --git a/mitsuba-blender/operators/material.py b/mitsuba-blender/operators/material.py new file mode 100644 index 0000000..ef64050 --- /dev/null +++ b/mitsuba-blender/operators/material.py @@ -0,0 +1,131 @@ +''' +Collection of operators available in Blender's material side panel +''' +import bpy +from bpy.props import EnumProperty + +from .utils import ( + init_mitsuba_material_node_tree, show_mitsuba_node_tree +) + +class MITSUBA_OT_material_new(bpy.types.Operator): + ''' + Operator that creates a new Mitsuba material + ''' + bl_idname = 'mitsuba.material_new' + bl_label = 'New' + bl_description = 'Create a new material and node tree' + bl_options = { 'UNDO' } + + @classmethod + def poll(cls, context): + return context.object + + def execute(self, context): + mat = bpy.data.materials.new(name='Material') + node_tree = bpy.data.node_groups.new(name=f'Nodes_{mat.name}', type='mitsuba_material_nodes') + init_mitsuba_material_node_tree(node_tree) + mat.mitsuba.node_tree = node_tree + + obj = context.active_object + if obj.material_slots: + obj.material_slots[obj.active_material_index].material = mat + else: + obj.data.materials.append(mat) + + show_mitsuba_node_tree(context, node_tree) + return {'FINISHED'} + +class MITSUBA_OT_material_unlink(bpy.types.Operator): + ''' + Operator that unlinks a Mitsuba material from the current object + ''' + bl_idname = 'mitsuba.material_unlink' + bl_label = '' + bl_description = 'Unlink data-block' + bl_options = { 'UNDO' } + + @classmethod + def poll(cls, context): + return context.object + + def execute(self, context): + obj = context.active_object + if obj.material_slots: + obj.material_slots[obj.active_material_index].material = None + return {'FINISHED'} + +class MITSUBA_OT_material_copy(bpy.types.Operator): + ''' + Operator that copies an existing Mitsuba material + ''' + bl_idname = 'mitsuba.material_copy' + bl_label = 'Copy' + bl_description = 'Create a copy of the material (also copying the nodetree)' + bl_options = {'UNDO'} + + @classmethod + def poll(cls, context): + return context.object + + def execute(self, context): + current_mat = context.active_object.active_material + + # Create a copy of the material + new_mat = current_mat.copy() + + current_node_tree = current_mat.mitsuba.node_tree + + if current_node_tree: + # Create a copy of the node_tree as well + new_node_tree = current_node_tree.copy() + new_node_tree.name = f'Nodes_{new_mat.name}' + # Assign new node_tree to the new material + new_mat.mitsuba.node_tree = new_node_tree + + context.active_object.active_material = new_mat + + return {'FINISHED'} + + +class MITSUBA_OT_material_select(bpy.types.Operator): + ''' + Operator that selects a material from a drop-down menu + ''' + bl_idname = 'mitsuba.material_select' + bl_label = '' + bl_property = 'material' + + callback_strings = [] + + def callback(self, context): + items = [] + + for index, mat in enumerate(bpy.data.materials): + #name = utils.get_name_with_lib(mat) + name = mat.name + # We can not show descriptions or icons here unfortunately + items.append((str(index), name, '')) + + # There is a known bug with using a callback, + # Python must keep a reference to the strings + # returned or Blender will misbehave or even crash. + MITSUBA_OT_material_select.callback_strings = items + return items + + material: EnumProperty(name='Materials', items=callback) + + @classmethod + def poll(cls, context): + return context.object + + def execute(self, context): + # Get the index of the selected material + mat_index = int(self.material) + mat = bpy.data.materials[mat_index] + context.object.active_material = mat + return {'FINISHED'} + + def invoke(self, context, event): + context.window_manager.invoke_search_popup(self) + return {'FINISHED'} diff --git a/mitsuba-blender/operators/nodetree.py b/mitsuba-blender/operators/nodetree.py new file mode 100644 index 0000000..a0d941f --- /dev/null +++ b/mitsuba-blender/operators/nodetree.py @@ -0,0 +1,88 @@ +import bpy +from bpy.props import IntProperty + +from .utils import ( + init_mitsuba_material_node_tree, show_mitsuba_node_tree +) + +class MITSUBA_OT_material_node_tree_show(bpy.types.Operator): + ''' + Operator that displays a Mitsuba node tree inside of the shader editor + ''' + bl_idname = 'mitsuba.material_node_tree_show' + bl_label = 'Show Nodes' + bl_description = 'Switch to the node tree of this material' + + @classmethod + def poll(cls, context): + obj = context.object + if not obj: + return False + + mat = obj.active_material + if not mat: + return False + + return mat.mitsuba.node_tree + + def execute(self, context): + mat = context.active_object.active_material + node_tree = mat.mitsuba.node_tree + + if show_mitsuba_node_tree(context, node_tree): + return {'FINISHED'} + + self.report({'ERROR'}, 'Open the node editor first') + return {'CANCELLED'} + +class MITSUBA_OT_material_node_tree_new(bpy.types.Operator): + ''' + Operator that creates a new Mitsuba node tree + ''' + bl_idname = 'mitsuba.material_node_tree_new' + bl_label = 'New' + bl_description = 'Create a material node tree' + bl_options = {'UNDO'} + + @classmethod + def poll(cls, context): + return context.object + + def execute(self, context): + mat = context.object.active_material + if mat: + name = f'Nodes_{mat.name}' + else: + name = 'Material Node Tree' + + node_tree = bpy.data.node_groups.new(name=name, type='mitsuba_material_nodes') + init_mitsuba_material_node_tree(node_tree) + + if mat: + mat.mitsuba.node_tree = node_tree + + show_mitsuba_node_tree(context, node_tree) + return {'FINISHED'} + +class MITSUBA_OT_material_node_tree_set(bpy.types.Operator): + ''' + Operator that sets the node tree of a given material + ''' + bl_idname = 'mitsuba.material_node_tree_set' + bl_label = '' + bl_description = 'Assign this node tree' + bl_options = {'UNDO'} + + node_tree_index: IntProperty() + + @classmethod + def poll(cls, context): + if not hasattr(context, 'material'): + return False + return context.material and not context.material.library + + def execute(self, context): + mat = context.material + node_tree = bpy.data.node_groups[self.node_tree_index] + mat.mitsuba.node_tree = node_tree + return {"FINISHED"} diff --git a/mitsuba-blender/io/__init__.py b/mitsuba-blender/operators/scene.py similarity index 53% rename from mitsuba-blender/io/__init__.py rename to mitsuba-blender/operators/scene.py index 3211f3c..4c15ffb 100644 --- a/mitsuba-blender/io/__init__.py +++ b/mitsuba-blender/operators/scene.py @@ -1,36 +1,56 @@ -if "bpy" in locals(): - import importlib - if "bl_utils" in locals(): - importlib.reload(bl_utils) - if "importer" in locals(): - importlib.reload(importer) - if "exporter" in locals(): - importlib.reload(exporter) - import bpy -from bpy.props import ( - StringProperty, - BoolProperty, - ) -from bpy_extras.io_utils import ( - ImportHelper, - ExportHelper, - orientation_helper, - axis_conversion +from bpy.props import StringProperty, BoolProperty +from bpy_extras.io_utils import ImportHelper, ExportHelper, orientation_helper, axis_conversion + +from ..importer import load_mitsuba_scene +from ..exporter import SceneConverter + +import traceback + +class MITSUBA_OT_scene_init_empty(bpy.types.Operator): + ''' + Operator that initializes a new empty scene + ''' + bl_idname = 'mitsuba.scene_init_empty' + bl_label = 'Init Empty Scene' + bl_description = 'Initialize a new empty scene' + bl_options = { 'UNDO' } + + name: StringProperty( + name = 'New Scene Name', + description = 'Name of the newly created scene. If a scene with the same name ' + 'already exists, it will be cleared.', + default = 'Mitsuba', ) -from . import bl_utils -from . import importer -from . import exporter + def execute(self, context): + # Create a temporary scene in order to guarantee that at least one scene + # exists. This is required by Blender. + tmp_scene = bpy.data.scenes.new('mi-tmp') + + # Check if the scene already exists + bl_scene = bpy.data.scenes.get(self.name) + if bl_scene is not None: + # Delete the scene if it exists + bpy.data.scenes.remove(bl_scene) + # Clear all orphaned data + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + + bpy.data.scenes.new(self.name) + + # Delete the temporary scene + bpy.data.scenes.remove(tmp_scene) + + return { 'FINISHED' } @orientation_helper(axis_forward='-Z', axis_up='Y') -class ImportMistuba(bpy.types.Operator, ImportHelper): - """Import a Mitsuba scene""" - bl_idname = "import_scene.mitsuba" - bl_label = "Mitsuba Import" +class MITSUBA_OT_scene_import(bpy.types.Operator, ImportHelper): + ''' Import a Mitsuba scene ''' + bl_idname = 'mitsuba.scene_import' + bl_label = 'Import Mitsuba Scene' - filename_ext = ".xml" - filter_glob: StringProperty(default="*.xml", options={'HIDDEN'}) + filename_ext = '.xml' + filter_glob: StringProperty(default='*.xml', options={'HIDDEN'}) override_scene: BoolProperty( name = 'Override Current Scene', @@ -39,6 +59,12 @@ class ImportMistuba(bpy.types.Operator, ImportHelper): default = True, ) + create_cycles_node_tree: BoolProperty( + name = 'Create Cycles Node Tree', + description = 'Convert materials into Cycles node trees (experimental).', + default = True, + ) + def execute(self, context): # Set blender to object mode if bpy.ops.object.mode_set.poll(): @@ -49,33 +75,33 @@ def execute(self, context): to_up=self.axis_up, ).to_4x4() - if self.override_scene: - # Clear the current scene - scene = bl_utils.init_empty_scene(context, name=bpy.context.scene.name) - else: - # Create a new scene for Mitsuba objects - scene = bl_utils.init_empty_scene(context, name='Mitsuba') + new_scene_name = bpy.context.scene.name if self.override_scene else 'Mitsuba' + bpy.ops.mitsuba.scene_init_empty(name=new_scene_name) + + scene = bpy.data.scenes.get(new_scene_name) collection = scene.collection + # Set the allocated scene as the currently active one + # NOTE: This needs to be done before trying to load the scene as + # the other way around leads to a segmentation fault. + bpy.context.window.scene = scene + try: - importer.load_mitsuba_scene(context, scene, collection, self.filepath, axis_mat) - except (RuntimeError, NotImplementedError) as e: - print(e) + load_mitsuba_scene(context, scene, collection, self.filepath, axis_mat, self.create_cycles_node_tree) + except RuntimeError: + traceback.print_exc() self.report({'ERROR'}, "Failed to load Mitsuba scene. See error log.") return {'CANCELLED'} - bpy.context.window.scene = scene - self.report({'INFO'}, "Scene imported successfully.") return {'FINISHED'} - @orientation_helper(axis_forward='-Z', axis_up='Y') -class ExportMitsuba(bpy.types.Operator, ExportHelper): +class MITSUBA_OT_scene_export(bpy.types.Operator, ExportHelper): """Export as a Mitsuba scene""" - bl_idname = "export_scene.mitsuba" - bl_label = "Mitsuba Export" + bl_idname = "mitsuba.scene_export" + bl_label = "Export Mitsuba Scene" filename_ext = ".xml" filter_glob: StringProperty(default="*.xml", options={'HIDDEN'}) @@ -108,7 +134,7 @@ def __init__(self): self.reset() def reset(self): - self.converter = exporter.SceneConverter() + self.converter = SceneConverter() def execute(self, context): # Conversion matrix to shift the "Up" Vector. This can be useful when exporting single objects to an existing mitsuba scene. @@ -145,30 +171,3 @@ def execute(self, context): self.reset() return {'FINISHED'} - - -def menu_export_func(self, context): - self.layout.operator(ExportMitsuba.bl_idname, text="Mitsuba (.xml)") - -def menu_import_func(self, context): - self.layout.operator(ImportMistuba.bl_idname, text="Mitsuba (.xml)") - - -classes = ( - ImportMistuba, - ExportMitsuba -) - -def register(): - for cls in classes: - bpy.utils.register_class(cls) - - bpy.types.TOPBAR_MT_file_export.append(menu_export_func) - bpy.types.TOPBAR_MT_file_import.append(menu_import_func) - -def unregister(): - for cls in classes: - bpy.utils.unregister_class(cls) - - bpy.types.TOPBAR_MT_file_export.remove(menu_export_func) - bpy.types.TOPBAR_MT_file_import.remove(menu_import_func) diff --git a/mitsuba-blender/operators/utils.py b/mitsuba-blender/operators/utils.py new file mode 100644 index 0000000..3cfa683 --- /dev/null +++ b/mitsuba-blender/operators/utils.py @@ -0,0 +1,23 @@ +import bpy + +def show_mitsuba_node_tree(context, node_tree): + for area in context.screen.areas: + if area.type == 'NODE_EDITOR': + for space in area.spaces: + if space.type == 'NODE_EDITOR' and not space.pin: + space.tree_type = node_tree.bl_idname + space.node_tree = node_tree + return True + return False + +def init_mitsuba_material_node_tree(node_tree): + nodes = node_tree.nodes + + output = nodes.new("MitsubaNodeOutputMaterial") + output.location = 300, 200 + output.select = False + + diffuse = nodes.new("MitsubaNodeDiffuseBSDF") + diffuse.location = 50, 200 + + node_tree.links.new(diffuse.outputs[0], output.inputs[0]) diff --git a/mitsuba-blender/properties/__init__.py b/mitsuba-blender/properties/__init__.py new file mode 100644 index 0000000..1056944 --- /dev/null +++ b/mitsuba-blender/properties/__init__.py @@ -0,0 +1,17 @@ +from bpy.utils import register_class, unregister_class + +from . import ( + material +) + +classes = ( + material.MitsubaMaterialProps, +) + +def register(): + for cls in classes: + register_class(cls) + +def unregister(): + for cls in classes: + unregister_class(cls) diff --git a/mitsuba-blender/properties/material.py b/mitsuba-blender/properties/material.py new file mode 100644 index 0000000..3312e90 --- /dev/null +++ b/mitsuba-blender/properties/material.py @@ -0,0 +1,21 @@ +import bpy +from bpy.types import PropertyGroup +from bpy.props import PointerProperty, EnumProperty, FloatProperty + +class MitsubaMaterialProps(PropertyGroup): + ''' + Custom properties for Mitsuba materials + ''' + node_tree: PointerProperty(name='Node Tree', type=bpy.types.NodeTree) + + @classmethod + def register(cls): + bpy.types.Material.mitsuba = PointerProperty( + name='Mitsuba Material Settings', + description='Mitsuba material settings', + type=cls, + ) + + @classmethod + def unregister(cls): + del bpy.types.Material.mitsuba diff --git a/mitsuba-blender/ui/__init__.py b/mitsuba-blender/ui/__init__.py new file mode 100644 index 0000000..c604a6d --- /dev/null +++ b/mitsuba-blender/ui/__init__.py @@ -0,0 +1,23 @@ +from bpy.utils import register_class, unregister_class +from . import ( + material, node_editor, exporter, importer +) + +classes = ( + material.MITSUBA_PT_context_material, + node_editor.MITSUBA_MATERIAL_MT_node_tree, +) + +def register(): + node_editor.register() + exporter.register() + importer.register() + for cls in classes: + register_class(cls) + +def unregister(): + node_editor.unregister() + exporter.unregister() + importer.unregister() + for cls in classes: + unregister_class(cls) diff --git a/mitsuba-blender/ui/exporter.py b/mitsuba-blender/ui/exporter.py new file mode 100644 index 0000000..1cc591d --- /dev/null +++ b/mitsuba-blender/ui/exporter.py @@ -0,0 +1,10 @@ +import bpy + +def mitsuba_menu_export(self, context): + self.layout.operator('mitsuba.scene_export', text="Mitsuba (.xml)") + +def register(): + bpy.types.TOPBAR_MT_file_export.append(mitsuba_menu_export) + +def unregister(): + bpy.types.TOPBAR_MT_file_export.remove(mitsuba_menu_export) diff --git a/mitsuba-blender/ui/importer.py b/mitsuba-blender/ui/importer.py new file mode 100644 index 0000000..e9d90ef --- /dev/null +++ b/mitsuba-blender/ui/importer.py @@ -0,0 +1,10 @@ +import bpy + +def mitsuba_menu_import(self, context): + self.layout.operator('mitsuba.scene_import', text="Mitsuba (.xml)") + +def register(): + bpy.types.TOPBAR_MT_file_import.append(mitsuba_menu_import) + +def unregister(): + bpy.types.TOPBAR_MT_file_import.remove(mitsuba_menu_import) diff --git a/mitsuba-blender/ui/material.py b/mitsuba-blender/ui/material.py new file mode 100644 index 0000000..ed8623d --- /dev/null +++ b/mitsuba-blender/ui/material.py @@ -0,0 +1,85 @@ +from bl_ui.properties_material import MaterialButtonsPanel +from bpy.types import Panel, Menu + +def mi_mat_template_ID(layout, material): + row = layout.row(align=True) + row.operator('mitsuba.material_select', icon='MATERIAL', text='') + + if material: + row.prop(material, 'name', text='') + if material.users > 1: + row.operator('mitsuba.material_copy', text=str(material.users)) + #row.prop(material, "use_fake_user", text="") + row.operator('mitsuba.material_copy', text='', icon='DUPLICATE') + row.operator('mitsuba.material_unlink', text='', icon='X') + else: + row.operator('mitsuba.material_new', text='New', icon='ADD') + return row + +class MITSUBA_PT_context_material(MaterialButtonsPanel, Panel): + ''' + Custom UI panel that displays Mitsuba material options + ''' + bl_label = '' + bl_options = { 'HIDE_HEADER' } + bl_order = 1 + COMPAT_ENGINES = { 'MITSUBA' } + + @classmethod + def poll(cls, context): + return (context.material or context.object) and context.scene.render.engine in cls.COMPAT_ENGINES + + def draw(self, context): + layout = self.layout + mat = context.material + obj = context.object + slot = context.material_slot + space = context.space_data + + if obj: + is_sortable = len(obj.material_slots) > 1 + rows = 1 + if (is_sortable): + rows = 4 + + row = layout.row() + + row.template_list('MATERIAL_UL_matslots', '', obj, 'material_slots', obj, 'active_material_index', rows=rows) + + col = row.column(align=True) + col.operator('object.material_slot_add', icon='ADD', text='') + col.operator('object.material_slot_remove', icon='REMOVE', text='') + + col.menu('MATERIAL_MT_context_menu', icon='DOWNARROW_HLT', text='') + + if is_sortable: + col.separator() + + col.operator('object.material_slot_move', icon='TRIA_UP', text='').direction = 'UP' + col.operator('object.material_slot_move', icon='TRIA_DOWN', text='').direction = 'DOWN' + + if obj.mode == 'EDIT': + row = layout.row(align=True) + row.operator('object.material_slot_assign', text='Assign') + row.operator('object.material_slot_select', text='Select') + row.operator('object.material_slot_deselect', text='Deselect') + + if obj: + # Note that we don't use layout.template_ID() because we can't + # control the copy operator in that template. + # So we mimic our own template_ID. + row = mi_mat_template_ID(layout, obj.active_material) + if slot: + row = row.row() + row.prop(slot, 'link', text='') + else: + row.label() + elif mat: + layout.template_ID(space, 'pin_id') + layout.separator() + + if mat: + if mat.mitsuba.node_tree: + layout.operator('mitsuba.material_node_tree_show', icon='NODETREE') + else: + layout.operator("mitsuba.material_node_tree_new", icon='NODETREE', text="Use Mitsuba Material Nodes") diff --git a/mitsuba-blender/ui/node_editor.py b/mitsuba-blender/ui/node_editor.py new file mode 100644 index 0000000..746d8c0 --- /dev/null +++ b/mitsuba-blender/ui/node_editor.py @@ -0,0 +1,244 @@ +import bpy +from bl_ui.space_node import NODE_HT_header, NODE_MT_editor_menus +from .material import mi_mat_template_ID + +class MITSUBA_MATERIAL_MT_node_tree(bpy.types.Menu): + bl_idname = 'MITSUBA_MATERIAL_MT_node_tree' + bl_label = 'Select Node Tree' + bl_description = 'Select a material node tree' + bl_options = {'UNDO'} + + @classmethod + def poll(cls, context): + if not hasattr(context, 'material'): + return False + return context.material and not context.material.library + + def draw(self, context): + layout = self.layout + source = bpy.data.node_groups + trees = [(index, tree, icon) + for index, tree in enumerate(source) + if tree.bl_idname == 'mitsuba_material_nodes'] + + row = layout.row() + col = row.column() + + if not trees: + # No node trees of this type in the scene yet + col.label(text="No material node trees available") + col.operator("mitsuba.material_node_tree_new", text="New Node Tree", icon='ADD') + + for j, (index, tree, icon) in enumerate(trees): + if j > 0 and j % 20 == 0: + col = row.column() + + text = tree.name + + op = col.operator('mitsuba.material_node_tree_set', text=text, icon='MATERIAL') + op.node_tree_index = index + +original_draw = None + +# Copied from bl_ui/space_node.py (Blender 2.92.0) +def mi_node_header_draw(panel, context): + layout = panel.layout + + scene = context.scene + snode = context.space_data + snode_id = snode.id + id_from = snode.id_from + tool_settings = context.tool_settings + is_compositor = snode.tree_type == 'CompositorNodeTree' + types_that_support_material = {'MESH', 'CURVE', 'SURFACE', 'FONT', 'META', + 'GPENCIL', 'VOLUME', 'HAIR', 'POINTCLOUD'} + + layout.template_header() + + # Now expanded via the 'ui_type' + # layout.prop(snode, "tree_type", text="") + + if snode.tree_type == 'ShaderNodeTree': + layout.prop(snode, "shader_type", text="") + + ob = context.object + if snode.shader_type == 'OBJECT' and ob: + ob_type = ob.type + + NODE_MT_editor_menus.draw_collapsible(context, layout) + + # No shader nodes for Eevee lights + if snode_id and not (context.engine == 'BLENDER_EEVEE' and ob_type == 'LIGHT'): + row = layout.row() + row.prop(snode_id, "use_nodes") + + layout.separator_spacer() + + # disable material slot buttons when pinned, cannot find correct slot within id_from (T36589) + # disable also when the selected object does not support materials + has_material_slots = not snode.pin and ob_type in types_that_support_material + + if ob_type != 'LIGHT': + row = layout.row() + row.enabled = has_material_slots + row.ui_units_x = 4 + row.popover(panel="NODE_PT_material_slots") + + row = layout.row() + row.enabled = has_material_slots + + # Show material.new when no active ID/slot exists + if not id_from and ob_type in types_that_support_material: + row.template_ID(ob, "active_material", new="material.new") + # Material ID, but not for Lights + if id_from and ob_type != 'LIGHT': + row.template_ID(id_from, "active_material", new="material.new") + + if snode.shader_type == 'WORLD': + NODE_MT_editor_menus.draw_collapsible(context, layout) + + if snode_id: + row = layout.row() + row.prop(snode_id, "use_nodes") + + layout.separator_spacer() + + row = layout.row() + row.enabled = not snode.pin + row.template_ID(scene, "world", new="world.new") + + if snode.shader_type == 'LINESTYLE': + view_layer = context.view_layer + lineset = view_layer.freestyle_settings.linesets.active + + if lineset is not None: + NODE_MT_editor_menus.draw_collapsible(context, layout) + + if snode_id: + row = layout.row() + row.prop(snode_id, "use_nodes") + + layout.separator_spacer() + + row = layout.row() + row.enabled = not snode.pin + row.template_ID(lineset, "linestyle", new="scene.freestyle_linestyle_new") + + elif snode.tree_type == 'TextureNodeTree': + layout.prop(snode, "texture_type", text="") + + NODE_MT_editor_menus.draw_collapsible(context, layout) + + if snode_id: + layout.prop(snode_id, "use_nodes") + + layout.separator_spacer() + + if id_from: + if snode.texture_type == 'BRUSH': + layout.template_ID(id_from, "texture", new="texture.new") + else: + layout.template_ID(id_from, "active_texture", new="texture.new") + + elif snode.tree_type == 'CompositorNodeTree': + + NODE_MT_editor_menus.draw_collapsible(context, layout) + + if snode_id: + layout.prop(snode_id, "use_nodes") + + elif snode.tree_type == 'GeometryNodeTree': + NODE_MT_editor_menus.draw_collapsible(context, layout) + layout.separator_spacer() + + ob = context.object + + row = layout.row() + if snode.pin: + row.enabled = False + row.template_ID(snode, "node_tree", new="node.new_geometry_node_group_assign") + elif ob: + active_modifier = ob.modifiers.active + if active_modifier and active_modifier.type == "NODES": + row.template_ID(active_modifier, "node_group", new="node.new_geometry_node_group_assign") + else: + row.template_ID(snode, "node_tree", new="node.new_geometry_nodes_modifier") + + ########################################################################################### + # Specialized Mitsuba code + elif snode.tree_type == 'mitsuba_material_nodes': + NODE_MT_editor_menus.draw_collapsible(context, layout) + + ob = context.object + + if ob: + layout.separator_spacer() + ob_type = ob.type + + has_material_slots = not snode.pin and ob_type in types_that_support_material + + row = layout.row() + row.enabled = has_material_slots + row.ui_units_x = 4 + row.popover(panel='NODE_PT_material_slots') + + row = layout.row() + row.enabled = has_material_slots + + # id_from is the material the node tree is attached to + mat = id_from if id_from else ob.active_material + mi_mat_template_ID(row, mat) + + if mat and not mat.mitsuba.node_tree: + layout.operator('mitsuba.material_node_tree_new', icon='NODETREE', text='Use Mitsuba Material Nodes') + # End of specialized Mitsuba code + ########################################################################################### + + else: + # Custom node tree is edited as independent ID block + NODE_MT_editor_menus.draw_collapsible(context, layout) + + layout.separator_spacer() + + layout.template_ID(snode, "node_tree", new="node.new_node_tree") + + # Put pin next to ID block + if not is_compositor: + layout.prop(snode, "pin", text="", emboss=False) + + layout.separator_spacer() + + # Put pin on the right for Compositing + if is_compositor: + layout.prop(snode, "pin", text="", emboss=False) + + layout.operator("node.tree_path_parent", text="", icon='FILE_PARENT') + + # Backdrop + if is_compositor: + row = layout.row(align=True) + row.prop(snode, "show_backdrop", toggle=True) + sub = row.row(align=True) + sub.active = snode.show_backdrop + sub.prop(snode, "backdrop_channels", icon_only=True, text="", expand=True) + + # Snap + row = layout.row(align=True) + row.prop(tool_settings, "use_snap", text="") + row.prop(tool_settings, "snap_node_element", icon_only=True) + if tool_settings.snap_node_element != 'GRID': + row.prop(tool_settings, "snap_target", text="") + +def mi_draw_switch(panel, context): + if context.scene.render.engine == 'MITSUBA' and context.space_data.tree_type == 'mitsuba_material_nodes': + mi_node_header_draw(panel, context) + else: + original_draw(panel, context) + +def register(): + global original_draw + original_draw = NODE_HT_header.draw + NODE_HT_header.draw = mi_draw_switch + +def unregister(): + NODE_HT_header.draw = original_draw diff --git a/mitsuba-blender/utils/__init__.py b/mitsuba-blender/utils/__init__.py new file mode 100644 index 0000000..e7071d1 --- /dev/null +++ b/mitsuba-blender/utils/__init__.py @@ -0,0 +1,40 @@ +import bpy + +import sys +import subprocess + +##################### +## PIP Utilities ## +##################### + +def pip_ensure(): + ''' Ensure that pip is available in the executing Python environment. ''' + result = subprocess.run([sys.executable, '-m', 'ensurepip'], capture_output=True) + return result.returncode == 0 + +def pip_has_package(package: str): + ''' Check if the executing Python environment has a specified package. ''' + result = subprocess.run([sys.executable, '-m', 'pip', 'show', package], capture_output=True) + return result.returncode == 0 + +def pip_install_package(package: str, version: str = None): + ''' Install a specified package in the executing Python environment. ''' + if version is None: + result = subprocess.run([sys.executable, '-m', 'pip', 'install', '--upgrade', package], capture_output=True) + else: + result = subprocess.run([sys.executable, '-m', 'pip', 'install', '--force-reinstall', f'{package}=={version}'], capture_output=True) + return result.returncode == 0 + +def pip_package_version(package: str): + ''' Get the version string of a specific pip package. ''' + result = subprocess.run([sys.executable, '-m', 'pip', 'list'], capture_output=True) + if result.returncode != 0: + return None + + output_str = result.stdout.decode('utf-8') + lines = output_str.splitlines(keepends=False) + for line in lines: + parts = line.split() + if len(parts) >= 2 and parts[0] == package: + return parts[1] + return None diff --git a/mitsuba-blender/utils/material.py b/mitsuba-blender/utils/material.py new file mode 100644 index 0000000..2eb09b6 --- /dev/null +++ b/mitsuba-blender/utils/material.py @@ -0,0 +1,7 @@ +from mathutils import Color + +def rgb_to_rgba(color): + return color + [1.0] + +def rgba_to_rgb(color): + return Color(color[0], color[1], color[2]) diff --git a/mitsuba-blender/utils/math.py b/mitsuba-blender/utils/math.py new file mode 100644 index 0000000..05943f9 --- /dev/null +++ b/mitsuba-blender/utils/math.py @@ -0,0 +1,18 @@ +def decompose_transform_2d(transform): + from drjit import transform_decompose, quat_to_euler + scale, quat, translation = transform_decompose(transform.matrix) + rotation = quat_to_euler(quat) + translation = [translation[0], translation[1]] + scale = [scale[0,0], scale[1,1]] + rotation = rotation[2] + return translation, rotation, scale + +def compose_transform_2d(translation, rotation, scale): + from drjit import transform_compose, euler_to_quat + s = [[scale[0], 0.0, 0.0], [0.0, scale[1], 0.0], [0.0, 0.0, 1.0]] + # FIXME: Dr.JIT does not understand this. + euler = [0.0, 0.0, rotation] + q = euler_to_quat(euler) + t = [translation[0], translation[1], 0.0] + transform = transform_compose(s, q, t) + return transform diff --git a/mitsuba-blender/utils/nodetree.py b/mitsuba-blender/utils/nodetree.py new file mode 100644 index 0000000..ff3081d --- /dev/null +++ b/mitsuba-blender/utils/nodetree.py @@ -0,0 +1,236 @@ +import bpy +from collections import OrderedDict + +def make_mitsuba_node_tree_name(bl_mat): + return f'Nodes_{bl_mat.name}' + +NODE_TREE_TYPE_TO_OUTPUT_NODE_TYPE = { + 'mitsuba_material_nodes': 'MitsubaNodeOutputMaterial', + 'mitsuba_world_nodes': 'MitsubaNodeOutputWorld', +} + +def get_active_output(node_tree): + output_node_type = NODE_TREE_TYPE_TO_OUTPUT_NODE_TYPE[node_tree.bl_idname] + for node in node_tree.nodes: + node_type = getattr(node, 'bl_idname', None) + if node_type == output_node_type and node.is_active: + return node + return None + +def get_output_nodes(node_tree): + output_node_type = NODE_TREE_TYPE_TO_OUTPUT_NODE_TYPE[node_tree.bl_idname] + nodes = [] + for node in node_tree.nodes: + node_type = getattr(node, 'bl_idname', None) + if node_type == output_node_type: + nodes.append(node) + return nodes + +class NodeWrapper: + ''' Utility wrapper around a Blender shader node ''' + def __init__(self, node_tree, node): + self.node_tree = node_tree + self.node = node + + def _get_socket_by_id(sockets, socket_id): + for socket in sockets: + if socket.identifier == socket_id: + return socket + return None + + def find_output_socket(self, out_socket_id=''): + ''' + Find a suitable output socket for the given socket ID. + ''' + outputs_count = len(self.node.outputs) + if outputs_count == 0: + raise RuntimeError('Node has no output.') + if out_socket_id == '' and outputs_count > 1: + raise RuntimeError(f'Cannot infer output socket. Node has {outputs_count} outputs.') + out_socket = self.node.outputs[0] + if out_socket_id != '': + out_socket = NodeWrapper._get_socket_by_id(self.node.outputs, out_socket_id) + if out_socket is None: + raise RuntimeError(f'Cannot find output node "{out_socket_id}".') + return out_socket + + def find_input_socket(self, in_socket_id): + in_socket = NodeWrapper._get_socket_by_id(self.node.inputs, in_socket_id) + if in_socket is None: + raise RuntimeError(f'Cannot find input node "{in_socket_id}".') + return in_socket + + def link_to(self, other_node, in_socket_id, out_socket_id=''): + ''' + Link the output of this node to the input of another. + ''' + out_socket = self.find_output_socket(out_socket_id) + in_socket = other_node.find_input_socket(in_socket_id) + if in_socket.is_linked: + raise RuntimeError(f'Input socket "{in_socket_id}" is already linked.') + self.node_tree.node_tree.links.new(out_socket, in_socket) + + def create_linked(self, other_type, in_socket_id, out_socket_id=''): + ''' + Create a new node and link it to one input of this node + ''' + other_node = self.node_tree.create_node(other_type) + other_node.link_to(self, in_socket_id, out_socket_id) + return other_node + + def delete_linked(self): + ''' + Delete all nodes connected to the inputs of this node + ''' + for input in self.node.inputs: + if input.is_linked: + for link in input.links: + node = NodeWrapper(self.node_tree, link.from_node) + node.delete_linked() + self.node_tree.delete_node(node) + + def set_property(self, property_name, value): + ''' + Set the value of either a node's property or input socket. + ''' + in_socket = NodeWrapper._get_socket_by_id(self.node.inputs, property_name) + if hasattr(self.node, property_name): + if in_socket is not None: + raise RuntimeError(f'Property "{property_name}" is ambiguous with similarly named socket.') + setattr(self.node, property_name, value) + elif in_socket is None: + raise RuntimeError(f'Node "{self.node.bl_idname}" does not have a property or socket with id "{property_name}".') + else: + if not hasattr(in_socket, 'default_value'): + raise RuntimeError(f'Socket "{property_name}" cannot hold a value.') + in_socket.default_value = value + +class NodeTreeWrapper: + ''' Utility wrapper around a Blender node tree ''' + def __init__(self, node_tree): + self.node_tree = node_tree + + @staticmethod + def init_cycles_material(bl_mat): + if not bl_mat.use_nodes: + bl_mat.use_nodes = True + return NodeTreeWrapper(bl_mat.node_tree) + + @staticmethod + def init_cycles_world(bl_world): + if not bl_world.use_nodes: + bl_world.use_nodes = True + return NodeTreeWrapper(bl_world.node_tree) + + @staticmethod + def init_mitsuba_material(bl_mat): + node_tree = bl_mat.mitsuba.node_tree + if node_tree is None: + node_tree = bpy.data.node_groups.new(name=make_mitsuba_node_tree_name(bl_mat), type='mitsuba_material_nodes') + bl_mat.mitsuba.node_tree = node_tree + return NodeTreeWrapper(node_tree) + + def clear(self): + for node in self.node_tree.nodes: + self.node_tree.nodes.remove(node) + + def create_node(self, type): + node = self.node_tree.nodes.new(type=type) + return NodeWrapper(self, node) + + def link_nodes(self, node_to, in_socket_id, node_from, out_socket_id=''): + node_from.link_to(node_to, in_socket_id, out_socket_id) + + def delete_node(self, node): + self.node_tree.nodes.remove(node.node) + + def delete_node_recursive(self, node): + node.delete_linked() + self.delete_node(node) + + def prettify(self): + ''' Formats the placement of material nodes in the shader editor. ''' + margin_x = 100 + margin_y = 50 + + def find_output_node(): + for node in self.node_tree.nodes: + if len(node.outputs) == 0: + return node + + def get_node_depths(): + def _traverse(node, graph=OrderedDict(), depth=0): + node_depth = depth + if node in graph: + current_node_depth = graph[node] + node_depth = depth if current_node_depth < depth else current_node_depth + graph[node] = node_depth + for input in node.inputs: + for link in input.links: + _traverse(link.from_node, graph, depth=depth+1) + return graph + + output_node = find_output_node() + graph = _traverse(output_node) + depths = [] + for node, depth in graph.items(): + while len(depths) <= depth: + depths.append([]) + depths[depth].append(node) + return depths + + def get_approximate_node_dimension(node): + ''' Get an approximation of a node's dimensions. + Nodes have dimensions attributes, however they are not updated until they are + displayed in the editor. Therefore, we cannot use them in this case as we create + and format the entire node tree in a script. + We use the default node width. The height is infered using the number of inputs + and outputs plus the header times a standard height of 24 units as an approximation. + This does not account for custom node properties. + ''' + # Hardcoded constant width + width = node.bl_width_default + height = 24 * (len(node.inputs) + len(node.outputs) + 1) + return (width, height) + + node_depths = get_node_depths() + tree_depth = len(node_depths) + + # 2D bbox, [min_x, min_y, max_x, max_y] + tree_bbox = [0.0, 0.0, 0.0, 0.0] + def expand_bbox(tree_bbox, other_bbox): + if other_bbox[0] < tree_bbox[0]: + tree_bbox[0] = other_bbox[0] + if other_bbox[1] < tree_bbox[1]: + tree_bbox[1] = other_bbox[1] + if other_bbox[2] > tree_bbox[2]: + tree_bbox[2] = other_bbox[2] + if other_bbox[3] > tree_bbox[3]: + tree_bbox[3] = other_bbox[3] + return tree_bbox + + current_x = 0.0 + for depth in range(tree_depth): + depth_width = 0.0 + depth_height = 0.0 + node_dims = [] + for node in node_depths[depth]: + node_width, node_height = get_approximate_node_dimension(node) + node_dims.append((node_width, node_height)) + if node_width > depth_width: + depth_width = node_width + depth_height += node_height + margin_y + + current_y = depth_height / 2.0 + for i, node in enumerate(node_depths[depth]): + node.location = (current_x, current_y) + node_width, node_height = node_dims[i] + tree_bbox = expand_bbox(tree_bbox, [current_x, current_y-node_height, current_x+node_width, current_y]) + current_y -= node_height + margin_y + + current_x -= depth_width + margin_x + + center = [(tree_bbox[0]+tree_bbox[2])/2.0, (tree_bbox[1]+tree_bbox[3])/2.0] + for node in self.node_tree.nodes: + current_location = node.location + node.location = (current_location[0]-center[0], current_location[1]-center[1]) diff --git a/scripts/install_addon.py b/scripts/install_addon.py new file mode 100644 index 0000000..1291dd6 --- /dev/null +++ b/scripts/install_addon.py @@ -0,0 +1,33 @@ +# This script installs the mitsuba-blender addon inside of the running Blender instance. +# To use this script, run it using Blender's command line arguments. +# E.g., blender.exe -b --python install_addon.py + +import sys +import os + +import bpy + +if __name__ == '__main__': + mi_addon_root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + mi_addon_dir = os.path.join(mi_addon_root_dir, 'mitsuba-blender') + bl_script_dir = None + for dir in bpy.utils.script_paths(use_user=True): + if dir.endswith('scripts'): + bl_script_dir = dir + if bl_script_dir is None: + raise RuntimeError('Cannot resolve Blender script directory') + bl_addon_dir = os.path.join(bl_script_dir, 'addons') + bl_mi_addon_dir = os.path.join(bl_addon_dir, 'mitsuba-blender') + + # Create a symlink from the addon to the Blender script folder + if not os.path.exists(bl_mi_addon_dir): + if sys.platform == 'win32': + import _winapi + _winapi.CreateJunction(str(mi_addon_dir), str(bl_mi_addon_dir)) + else: + os.symlink(mi_addon_dir, bl_mi_addon_dir, target_is_directory=True) + + if 'mitsuba-blender' not in bpy.context.preferences.addons: + if bpy.ops.preferences.addon_enable(module='mitsuba-blender') != {'FINISHED'}: + raise RuntimeError('Cannot enable mitsuba2-blender addon') + bpy.ops.wm.save_userpref() diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 7215265..d5283f1 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -5,9 +5,17 @@ import pytest class SetupPlugin: - def __init__(self): - mi_addon_root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - self.mi_addon_dir = os.path.join(mi_addon_root_dir, 'mitsuba-blender') + def __init__(self, args): + # If this flag is set, skip the add-on installation process. + # This assumes that the add-on is already installed in the executing Blender instance. + self.skip_install = args['--skip-install'] + + # If this flag is set, Blender's temporary directory is set to a local folder. + # This is useful to save crash logs in a common place across environments. + # This is intended to be used on CI environments only! + self.local_tmp = args['--local-tmp'] + + # Find Blender's add-on installation folder bl_script_dirs = bpy.utils.script_paths(use_user=True) self.bl_script_dir = None for dir in bl_script_dirs: @@ -15,20 +23,32 @@ def __init__(self): self.bl_script_dir = dir if self.bl_script_dir is None: raise RuntimeError('Cannot resolve Blender script directory') + + # Define relevant paths + self.mi_addon_root_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + self.mi_addon_dir = os.path.join(self.mi_addon_root_dir, 'mitsuba-blender') self.bl_addon_dir = os.path.join(self.bl_script_dir, 'addons') self.bl_mi_addon_dir = os.path.join(self.bl_addon_dir, 'mitsuba-blender') + self.bl_tmp_dir = os.path.join(self.mi_addon_root_dir, 'tmp') + + # Add the add-on directory to the system path. This is needed for computing coverage. sys.path.append(self.mi_addon_dir) def pytest_configure(self, config): - if os.path.exists(self.bl_mi_addon_dir): - os.remove(self.bl_mi_addon_dir) - - # Create a symlink from the addon to the Blender script folder - if sys.platform == 'win32': - import _winapi - _winapi.CreateJunction(str(self.mi_addon_dir), str(self.bl_mi_addon_dir)) - else: - os.symlink(self.mi_addon_dir, self.bl_mi_addon_dir, target_is_directory=True) + if self.local_tmp: + os.makedirs(self.bl_tmp_dir, exist_ok=True) + bpy.context.preferences.filepaths.temporary_directory = str(self.bl_tmp_dir) + + if not self.skip_install: + if os.path.exists(self.bl_mi_addon_dir): + os.remove(self.bl_mi_addon_dir) + + # Create a symlink from the addon to the Blender script folder + if sys.platform == 'win32': + import _winapi + _winapi.CreateJunction(str(self.mi_addon_dir), str(self.bl_mi_addon_dir)) + else: + os.symlink(self.mi_addon_dir, self.bl_mi_addon_dir, target_is_directory=True) if bpy.ops.preferences.addon_enable(module='mitsuba-blender') != {'FINISHED'}: raise RuntimeError('Cannot enable mitsuba2-blender addon') @@ -36,28 +56,46 @@ def pytest_configure(self, config): if not bpy.context.preferences.addons['mitsuba-blender'].preferences.is_mitsuba_initialized: raise RuntimeError('Failed to initialize Mitsuba library') + print(bpy.context.preferences.filepaths.temporary_directory) + def pytest_unconfigure(self): - bpy.ops.preferences.addon_disable(module='mitsuba-blender') - # Remove the symlink - os.remove(self.bl_mi_addon_dir) + if not self.skip_install: + bpy.ops.preferences.addon_disable(module='mitsuba-blender') + bpy.ops.wm.save_userpref() + # Remove the symlink + os.remove(self.bl_mi_addon_dir) def pytest_runtest_setup(self, item): bpy.ops.wm.read_homefile(use_empty=True) if 'mitsuba-blender' not in bpy.context.preferences.addons: raise RuntimeError("Plugin was disabled by test reset") -if __name__ == '__main__': +def main(args): pytest_args = ["tests"] try: - pytest_args += sys.argv[sys.argv.index('--')+1:] + pytest_args += args[args.index('--')+1:] except ValueError: pass + other_args = { + '--skip-install': False, + '--local-tmp': False, + } + + # Parse additional custom flags + temp_args = other_args.copy() + for arg in other_args.keys(): + if arg in pytest_args: + pytest_args.remove(arg) + temp_args[arg] = True + other_args = temp_args + try: - exit_code = pytest.main(pytest_args, plugins=[SetupPlugin()]) + return pytest.main(pytest_args, plugins=[SetupPlugin(other_args)]) except Exception as e: print(e) - exit_code = 1 + return 1 - sys.exit(exit_code) +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/tests/res/scenes/film_hdrfilm.xml b/tests/engine/scenes/film_hdrfilm.xml similarity index 100% rename from tests/res/scenes/film_hdrfilm.xml rename to tests/engine/scenes/film_hdrfilm.xml diff --git a/tests/res/scenes/film_hdrfilm_crop.xml b/tests/engine/scenes/film_hdrfilm_crop.xml similarity index 100% rename from tests/res/scenes/film_hdrfilm_crop.xml rename to tests/engine/scenes/film_hdrfilm_crop.xml diff --git a/tests/res/scenes/integrator_moment.xml b/tests/engine/scenes/integrator_moment.xml similarity index 85% rename from tests/res/scenes/integrator_moment.xml rename to tests/engine/scenes/integrator_moment.xml index 4a3803a..f39be41 100644 --- a/tests/res/scenes/integrator_moment.xml +++ b/tests/engine/scenes/integrator_moment.xml @@ -1,6 +1,6 @@ - + diff --git a/tests/res/scenes/integrator_path.xml b/tests/engine/scenes/integrator_path.xml similarity index 100% rename from tests/res/scenes/integrator_path.xml rename to tests/engine/scenes/integrator_path.xml diff --git a/tests/res/scenes/rfilter_box.xml b/tests/engine/scenes/rfilter_box.xml similarity index 100% rename from tests/res/scenes/rfilter_box.xml rename to tests/engine/scenes/rfilter_box.xml diff --git a/tests/res/scenes/rfilter_gaussian.xml b/tests/engine/scenes/rfilter_gaussian.xml similarity index 100% rename from tests/res/scenes/rfilter_gaussian.xml rename to tests/engine/scenes/rfilter_gaussian.xml diff --git a/tests/res/scenes/rfilter_tent.xml b/tests/engine/scenes/rfilter_tent.xml similarity index 100% rename from tests/res/scenes/rfilter_tent.xml rename to tests/engine/scenes/rfilter_tent.xml diff --git a/tests/res/scenes/sampler_independent.xml b/tests/engine/scenes/sampler_independent.xml similarity index 100% rename from tests/res/scenes/sampler_independent.xml rename to tests/engine/scenes/sampler_independent.xml diff --git a/tests/res/scenes/sampler_multijitter.xml b/tests/engine/scenes/sampler_multijitter.xml similarity index 100% rename from tests/res/scenes/sampler_multijitter.xml rename to tests/engine/scenes/sampler_multijitter.xml diff --git a/tests/res/scenes/sampler_stratified.xml b/tests/engine/scenes/sampler_stratified.xml similarity index 100% rename from tests/res/scenes/sampler_stratified.xml rename to tests/engine/scenes/sampler_stratified.xml diff --git a/tests/engine/test_films.py b/tests/engine/test_films.py new file mode 100644 index 0000000..e74ee01 --- /dev/null +++ b/tests/engine/test_films.py @@ -0,0 +1,13 @@ +import bpy + +import pytest + +from fixtures import * + +@pytest.mark.parametrize('scene, plugin', [ + ('scenes/film_hdrfilm.xml', 'hdrfilm'), + ('scenes/film_hdrfilm_crop.xml', 'hdrfilm'), +]) +@pytest.mark.skip(reason='Film export is not fully implemented yet') +def test_parsing_films(mitsuba_parser_tester, scene, plugin): + mitsuba_parser_tester.check_scene_plugin(scene, plugin) diff --git a/tests/engine/test_integrators.py b/tests/engine/test_integrators.py new file mode 100644 index 0000000..26e97df --- /dev/null +++ b/tests/engine/test_integrators.py @@ -0,0 +1,12 @@ +import bpy + +import pytest + +from fixtures import * + +@pytest.mark.parametrize('scene, plugin', [ + ('scenes/integrator_path.xml', 'path'), + ('scenes/integrator_moment.xml', 'moment'), +]) +def test_parsing_integrators(mitsuba_parser_tester, scene, plugin): + mitsuba_parser_tester.check_scene_plugin(scene, plugin) diff --git a/tests/engine/test_rfilters.py b/tests/engine/test_rfilters.py new file mode 100644 index 0000000..3566575 --- /dev/null +++ b/tests/engine/test_rfilters.py @@ -0,0 +1,13 @@ +import bpy + +import pytest + +from fixtures import * + +@pytest.mark.parametrize('scene, plugin', [ + ('scenes/rfilter_box.xml', 'box'), + ('scenes/rfilter_tent.xml', 'tent'), + ('scenes/rfilter_gaussian.xml', 'gaussian'), +]) +def test_parsing_rfilters(mitsuba_parser_tester, scene, plugin): + mitsuba_parser_tester.check_scene_plugin(scene, plugin) diff --git a/tests/engine/test_samplers.py b/tests/engine/test_samplers.py new file mode 100644 index 0000000..b4bbaf6 --- /dev/null +++ b/tests/engine/test_samplers.py @@ -0,0 +1,13 @@ +import bpy + +import pytest + +from fixtures import * + +@pytest.mark.parametrize('scene, plugin', [ + ('scenes/sampler_independent.xml', 'independent'), + ('scenes/sampler_stratified.xml', 'stratified'), + ('scenes/sampler_multijitter.xml', 'multijitter'), +]) +def test_parsing_samplers(mitsuba_parser_tester, scene, plugin): + mitsuba_parser_tester.check_scene_plugin(scene, plugin) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 0ebdeff..a27b7b3 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1,3 +1,5 @@ +import bpy + import pytest import numpy as np @@ -8,8 +10,9 @@ ################################ class ResourceResolver: - def __init__(self): - self.root = os.path.join(os.getcwd(), 'tests/res') + def __init__(self, function_path, function_name): + self.root = os.path.dirname(function_path) + self.function_name = function_name def get_absolute_resource_path(self, relative_path): return os.path.join(self.root, relative_path) @@ -19,21 +22,23 @@ def ensure_resource_dir(self, relative_dir): os.makedirs(absolute_dir, exist_ok=True) return absolute_dir + def ensure_output_dir(self): + return self.ensure_resource_dir(f'out/{self.function_name}') + @pytest.fixture -def resource_resolver(): - return ResourceResolver() +def resource_resolver(request): + return ResourceResolver(request.path, request.node.name.split('[')[0]) ################################## ## MitsubaSceneParser fixture ## ################################## -class MitsubaSceneParser: - def __init__(self): - self.props = None +class MitsubaPropsWrapper: + def __init__(self, props): + self.props = props - def load_xml(self, scene_file): - import mitsuba - self.props = mitsuba.xml_to_props(scene_file) + def __repr__(self): + return str(self.props) def get_props_by_name(self, plugin_name): for _, props in self.props: @@ -41,6 +46,18 @@ def get_props_by_name(self, plugin_name): return props return None + def get_props_by_id(self, plugin_id): + for _, props in self.props: + if props.id() == plugin_id: + return props + return None + +class MitsubaSceneParser: + def load_xml(self, scene_file): + import mitsuba + props = mitsuba.xml_to_props(scene_file) + return MitsubaPropsWrapper(props) + @pytest.fixture def mitsuba_scene_parser(): return MitsubaSceneParser() @@ -115,7 +132,7 @@ def stdnormal_cdf(x): return p_value def xyz_to_rgb_bmp(self, arr): - """Convert an XYZ image to RGB""" + ''' Convert an XYZ image to RGB ''' from mitsuba import Bitmap, Struct xyz_bmp = Bitmap(arr, Bitmap.PixelFormat.XYZ) return xyz_bmp.convert(Bitmap.PixelFormat.RGB, Struct.Type.Float32, False) @@ -151,3 +168,61 @@ def compare_scenes(self, xml_ref, xml_out, spp, resolution, output_dir, signific @pytest.fixture def mitsuba_scene_ztest(mitsuba_scene_renderer): return MitsubaRenderTester(mitsuba_scene_renderer) + +################################### +## MitsubaParserTester fixture ## +################################### + +class MitsubaParserTester: + def __init__(self, resolver, parser): + self.resolver = resolver + self.parser = parser + + def _check_plugin(self, ref_props, output_props, plugin_name): + from mitsuba import Properties, traverse + + ref_plugin_props = ref_props.get_props_by_name(plugin_name) + assert ref_plugin_props + output_plugin_props = output_props.get_props_by_name(plugin_name) + assert output_plugin_props + + for ref_plugin_prop_name in ref_plugin_props.property_names(): + ref_plugin_prop = ref_plugin_props.get(ref_plugin_prop_name) + ref_plugin_prop_type = ref_plugin_props.type(ref_plugin_prop_name) + + if ref_plugin_prop_type == Properties.Type.NamedReference: + ref_other_plugin_props = ref_props.get_props_by_id(ref_plugin_prop) + self._check_plugin(ref_props, output_props, ref_other_plugin_props.plugin_name()) + elif ref_plugin_prop_type == Properties.Type.Object: + assert output_plugin_props.has_property(ref_plugin_prop_name) + output_plugin_prop = output_plugin_props.get(ref_plugin_prop_name) + ref_plugin_prop_params = traverse(ref_plugin_prop) + output_plugin_prop_params = traverse(output_plugin_prop) + for (key, value) in ref_plugin_prop_params.items(): + assert key in output_plugin_prop_params + assert value == output_plugin_prop_params[key] + else: + assert output_plugin_props.has_property(ref_plugin_prop_name) + output_plugin_prop = output_plugin_props.get(ref_plugin_prop_name) + if ref_plugin_prop != output_plugin_prop: + print(ref_plugin_prop) + print(output_plugin_prop) + assert ref_plugin_prop == output_plugin_prop + + def check_scene_plugin(self, scene_file, plugin_name): + ref_scene_file = self.resolver.get_absolute_resource_path(scene_file) + ref_scene_name, _ = os.path.splitext(os.path.basename(ref_scene_file)) + test_output_dir = self.resolver.ensure_output_dir() + output_scene_file = os.path.join(test_output_dir, f'{ref_scene_name}_out.xml') + + assert bpy.ops.mitsuba.scene_import(filepath=ref_scene_file, create_cycles_node_tree=False) == {'FINISHED'} + assert bpy.ops.mitsuba.scene_export(filepath=output_scene_file, ignore_background=True) == {'FINISHED'} + + ref_props = self.parser.load_xml(ref_scene_file) + output_props = self.parser.load_xml(output_scene_file) + + self._check_plugin(ref_props, output_props, plugin_name) + +@pytest.fixture +def mitsuba_parser_tester(resource_resolver, mitsuba_scene_parser): + return MitsubaParserTester(resource_resolver, mitsuba_scene_parser) diff --git a/tests/res/scenes/empty.xml b/tests/importer/scenes/empty.xml similarity index 100% rename from tests/res/scenes/empty.xml rename to tests/importer/scenes/empty.xml diff --git a/tests/importer/test_importer.py b/tests/importer/test_importer.py new file mode 100644 index 0000000..0512432 --- /dev/null +++ b/tests/importer/test_importer.py @@ -0,0 +1,70 @@ +import bpy + +import pytest + +from fixtures import * + +@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) +def test_importer_initializes_mitsuba_renderer(resource_resolver, xml_scene): + scene_file = resource_resolver.get_absolute_resource_path(xml_scene) + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file) == {'FINISHED'} + + assert bpy.context.scene.render.engine == 'MITSUBA' + assert bpy.context.scene.mitsuba.variant == 'scalar_rgb' + +@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) +def test_importer_override_current_scene_conserves_name(resource_resolver, xml_scene): + scene_file = resource_resolver.get_absolute_resource_path(xml_scene) + + assert len(bpy.data.scenes) == 1 + scene_name_before = bpy.data.scenes[0].name + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file, override_scene=True) == {'FINISHED'} + + assert len(bpy.data.scenes) == 1 + assert bpy.data.scenes[0].name == scene_name_before + +@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) +def test_importer_multiple_scene_imports(resource_resolver, xml_scene): + scene_file = resource_resolver.get_absolute_resource_path(xml_scene) + + assert len(bpy.data.scenes) == 1 + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file, override_scene=False) == {'FINISHED'} + object_count_before = len(bpy.context.scene.objects) + + assert len(bpy.data.scenes) == 2 + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file, override_scene=False) == {'FINISHED'} + assert len(bpy.context.scene.objects) == object_count_before + + assert len(bpy.data.scenes) == 2 + +@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) +def test_importer_multiple_scene_import_override(resource_resolver, xml_scene): + scene_file = resource_resolver.get_absolute_resource_path(xml_scene) + + assert len(bpy.data.scenes) == 1 + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file, override_scene=True) == {'FINISHED'} + object_count_before = len(bpy.context.scene.objects) + + assert len(bpy.data.scenes) == 1 + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file, override_scene=True) == {'FINISHED'} + assert len(bpy.context.scene.objects) == object_count_before + + assert len(bpy.data.scenes) == 1 + +@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) +def test_importer_set_new_scene_as_active(resource_resolver, xml_scene): + scene_file = resource_resolver.get_absolute_resource_path(xml_scene) + + assert len(bpy.data.scenes) == 1 + scene_name_before = bpy.data.scenes[0].name + + assert bpy.ops.mitsuba.scene_import(filepath=scene_file, override_scene=False) == {'FINISHED'} + + assert len(bpy.data.scenes) == 2 + assert bpy.context.scene.name != scene_name_before diff --git a/tests/nodes/scenes/blendbsdf.xml b/tests/nodes/scenes/blendbsdf.xml new file mode 100644 index 0000000..83c4b16 --- /dev/null +++ b/tests/nodes/scenes/blendbsdf.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/bumpmap.xml b/tests/nodes/scenes/bumpmap.xml new file mode 100644 index 0000000..d653f86 --- /dev/null +++ b/tests/nodes/scenes/bumpmap.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/conductor.xml b/tests/nodes/scenes/conductor.xml new file mode 100644 index 0000000..e8c404e --- /dev/null +++ b/tests/nodes/scenes/conductor.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/nodes/scenes/dielectric.xml b/tests/nodes/scenes/dielectric.xml new file mode 100644 index 0000000..e19bc50 --- /dev/null +++ b/tests/nodes/scenes/dielectric.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/diffuse.xml b/tests/nodes/scenes/diffuse.xml new file mode 100644 index 0000000..88ca3a5 --- /dev/null +++ b/tests/nodes/scenes/diffuse.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/nodes/scenes/mask.xml b/tests/nodes/scenes/mask.xml new file mode 100644 index 0000000..8bbdd31 --- /dev/null +++ b/tests/nodes/scenes/mask.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/nodes/scenes/normalmap.xml b/tests/nodes/scenes/normalmap.xml new file mode 100644 index 0000000..11fed12 --- /dev/null +++ b/tests/nodes/scenes/normalmap.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/null.xml b/tests/nodes/scenes/null.xml new file mode 100644 index 0000000..b1e171c --- /dev/null +++ b/tests/nodes/scenes/null.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tests/nodes/scenes/plastic.xml b/tests/nodes/scenes/plastic.xml new file mode 100644 index 0000000..73c6e59 --- /dev/null +++ b/tests/nodes/scenes/plastic.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/principled.xml b/tests/nodes/scenes/principled.xml new file mode 100644 index 0000000..6002bcd --- /dev/null +++ b/tests/nodes/scenes/principled.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/roughconductor_anisotropic.xml b/tests/nodes/scenes/roughconductor_anisotropic.xml new file mode 100644 index 0000000..6f2192c --- /dev/null +++ b/tests/nodes/scenes/roughconductor_anisotropic.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/roughconductor_isotropic.xml b/tests/nodes/scenes/roughconductor_isotropic.xml new file mode 100644 index 0000000..57eccde --- /dev/null +++ b/tests/nodes/scenes/roughconductor_isotropic.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/roughdielectric_anisotropic.xml b/tests/nodes/scenes/roughdielectric_anisotropic.xml new file mode 100644 index 0000000..08718d7 --- /dev/null +++ b/tests/nodes/scenes/roughdielectric_anisotropic.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/roughdielectric_isotropic.xml b/tests/nodes/scenes/roughdielectric_isotropic.xml new file mode 100644 index 0000000..44c7497 --- /dev/null +++ b/tests/nodes/scenes/roughdielectric_isotropic.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/roughplastic.xml b/tests/nodes/scenes/roughplastic.xml new file mode 100644 index 0000000..922106a --- /dev/null +++ b/tests/nodes/scenes/roughplastic.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/textures/blank.png b/tests/nodes/scenes/textures/blank.png new file mode 100644 index 0000000000000000000000000000000000000000..f27263ad85ff9340f526f11a0bea91dfba0c5f55 GIT binary patch literal 128 zcmeAS@N?(olHy`uVBq!ia0vp^Od!kwBL7~QRScvUi-X*q7}lMWc?smOq&xaLGB9lH z=l+w(3gmMZctipf@f`+X#^d=bQh + + + + + + + + + + + + + diff --git a/tests/nodes/scenes/twosided_1.xml b/tests/nodes/scenes/twosided_1.xml new file mode 100644 index 0000000..03a3aba --- /dev/null +++ b/tests/nodes/scenes/twosided_1.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/nodes/scenes/twosided_2.xml b/tests/nodes/scenes/twosided_2.xml new file mode 100644 index 0000000..4e9c2b4 --- /dev/null +++ b/tests/nodes/scenes/twosided_2.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/nodes/test_materials.py b/tests/nodes/test_materials.py new file mode 100644 index 0000000..49833bf --- /dev/null +++ b/tests/nodes/test_materials.py @@ -0,0 +1,28 @@ +import bpy + +import pytest + +from fixtures import * + +@pytest.mark.parametrize('scene, plugin', [ + ('scenes/diffuse.xml', 'diffuse'), + # ('scenes/null.xml', 'null'), + # ('scenes/plastic.xml', 'plastic'), + # ('scenes/roughplastic.xml', 'roughplastic'), + # ('scenes/dielectric.xml', 'dielectric'), + # ('scenes/thindielectric.xml', 'thindielectric'), + # ('scenes/roughdielectric_anisotropic.xml', 'roughdielectric'), + # ('scenes/roughdielectric_isotropic.xml', 'roughdielectric'), + # ('scenes/conductor.xml', 'conductor'), + # ('scenes/roughconductor_anisotropic.xml', 'roughconductor'), + # ('scenes/roughconductor_isotropic.xml', 'roughconductor'), + # ('scenes/bumpmap.xml', 'bumpmap'), + # ('scenes/normalmap.xml', 'normalmap'), + # ('scenes/blendbsdf.xml', 'blendbsdf'), + # ('scenes/mask.xml', 'mask'), + # ('scenes/twosided_1.xml', 'twosided'), + # ('scenes/twosided_2.xml', 'twosided'), + # ('scenes/principled.xml', 'principled'), +]) +def test_parsing_materials(mitsuba_parser_tester, scene, plugin): + mitsuba_parser_tester.check_scene_plugin(scene, plugin) diff --git a/tests/res/scenes/meshes/Cube.ply b/tests/res/scenes/meshes/Cube.ply deleted file mode 100644 index 692550ac2fa6f4f40ff61c2121c2e6f78045de3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 641 zcmZvYT~5O=5QGikH^3cyf{GxX3pWWS-Kdt_By!!V;VCyOM`OlKO0clB@ywUm>?)n> z^SbpL6ZEHTjPFZlLvT_`v$Ceq#o{>^CmU&k-ir@%(8X)s`L=`Ub?sVo9#YV!;41~Y z6ztDqYEy|WUVa14ZiC)cYvZ*&uTfeoSjjfWqUBo5N_?)=@HW;_DGjaYp(DrrkurF) zVL4`~QF_jfC-%(@YxGkNQ%-D_`;Rf7_Woh$=iK)x2hTj`rc8k;=4M&uA{+boj=5o( zah(g)B`TUpWLG1(=1oI!LvxGA9qN8E6!^s|d%)un6?#wFswRdA diff --git a/tests/res/scenes/test1.xml b/tests/res/scenes/test1.xml deleted file mode 100644 index b1d84e0..0000000 --- a/tests/res/scenes/test1.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tests/test_addon.py b/tests/test_addon.py deleted file mode 100644 index 83b19a3..0000000 --- a/tests/test_addon.py +++ /dev/null @@ -1,17 +0,0 @@ -import bpy - -from mathutils import Matrix - -def test_prespective_sensor(): - import importlib - sensors = importlib.import_module("mitsuba-blender.io.importer.sensors") - assert sensors - common = importlib.import_module("mitsuba-blender.io.importer.common") - assert common - - from mitsuba import Properties - mi_sensor_props = Properties('perspective') - mi_context = common.MitsubaSceneImportContext(bpy.context, bpy.context.scene, bpy.context.scene.collection, '', mi_sensor_props, Matrix()) - - bl_camera, world_matrix = sensors.mi_perspective_to_bl_camera(mi_context, mi_sensor_props) - assert bl_camera.type == 'PERSP' diff --git a/tests/test_compare.py b/tests/test_compare.py index 3cbc3c4..de30c24 100644 --- a/tests/test_compare.py +++ b/tests/test_compare.py @@ -6,21 +6,19 @@ from fixtures import * -@pytest.mark.parametrize("xml_scene", ["scenes/test1.xml"]) -def test_round_trip(xml_scene, resource_resolver, mitsuba_scene_ztest): - resolution = (1280, 720) - sample_budget = int(2e6) - pixel_count = resolution[0] * resolution[1] - spp = sample_budget // pixel_count - - significance_level = 0.01 - - ref_scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - ref_scene_name, _ = os.path.splitext(os.path.basename(ref_scene_file)) - test_output_dir = resource_resolver.ensure_resource_dir(f'out/{ref_scene_name}') - output_scene_file = os.path.join(test_output_dir, f'{ref_scene_name}_out.xml') - - assert bpy.ops.import_scene.mitsuba(filepath=ref_scene_file) == {'FINISHED'} - assert bpy.ops.export_scene.mitsuba(filepath=output_scene_file, ignore_background=True) == {'FINISHED'} - - assert mitsuba_scene_ztest.compare_scenes(ref_scene_file, output_scene_file, spp, resolution, test_output_dir) +# @pytest.mark.parametrize("xml_scene", ["scenes/test1.xml"]) +# def test_round_trip(xml_scene, resource_resolver, mitsuba_scene_ztest): +# resolution = (1280, 720) +# sample_budget = int(2e6) +# pixel_count = resolution[0] * resolution[1] +# spp = sample_budget // pixel_count + +# ref_scene_file = resource_resolver.get_absolute_resource_path(xml_scene) +# ref_scene_name, _ = os.path.splitext(os.path.basename(ref_scene_file)) +# test_output_dir = resource_resolver.ensure_resource_dir(f'out/{ref_scene_name}') +# output_scene_file = os.path.join(test_output_dir, f'{ref_scene_name}_out.xml') + +# assert bpy.ops.mitsuba.scene_import(filepath=ref_scene_file) == {'FINISHED'} +# assert bpy.ops.mitsuba.scene_export(filepath=output_scene_file, ignore_background=True) == {'FINISHED'} + +# assert mitsuba_scene_ztest.compare_scenes(ref_scene_file, output_scene_file, spp, resolution, test_output_dir) diff --git a/tests/test_importer.py b/tests/test_importer.py deleted file mode 100644 index feb8f50..0000000 --- a/tests/test_importer.py +++ /dev/null @@ -1,229 +0,0 @@ -import bpy - -import pytest - -from fixtures import * - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_override_current_scene_conserves_name(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert len(bpy.data.scenes) == 1 - scene_name_before = bpy.data.scenes[0].name - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=True) == {'FINISHED'} - - assert len(bpy.data.scenes) == 1 - assert bpy.data.scenes[0].name == scene_name_before - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_multiple_scene_imports(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert len(bpy.data.scenes) == 1 - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=False) == {'FINISHED'} - object_count_before = len(bpy.context.scene.objects) - - assert len(bpy.data.scenes) == 2 - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=False) == {'FINISHED'} - assert len(bpy.context.scene.objects) == object_count_before - - assert len(bpy.data.scenes) == 2 - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_set_new_scene_as_active(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert len(bpy.data.scenes) == 1 - scene_name_before = bpy.data.scenes[0].name - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file, override_scene=False) == {'FINISHED'} - - assert len(bpy.data.scenes) == 2 - assert bpy.context.scene.name != scene_name_before - -@pytest.mark.parametrize("xml_scene", ["scenes/empty.xml"]) -def test_importer_initializes_mitsuba_renderer(resource_resolver, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - assert bpy.context.scene.render.engine == 'MITSUBA' - assert bpy.context.scene.mitsuba.variant == 'scalar_rgb' - -@pytest.mark.parametrize("xml_scene", ["scenes/integrator_path.xml"]) -def test_importer_path_integrator(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_integrator = mitsuba_scene_parser.get_props_by_name('path') - assert mi_integrator - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_scene = bpy.context.scene - assert bl_scene.mitsuba.active_integrator == 'path' - assert bl_scene.mitsuba.available_integrators.path.max_depth == mi_integrator.get('max_depth') - assert bl_scene.mitsuba.available_integrators.path.rr_depth == mi_integrator.get('rr_depth') - assert bl_scene.mitsuba.available_integrators.path.hide_emitters == mi_integrator.get('hide_emitters') - - # assert len(mi_integrator.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/integrator_moment.xml"]) -def test_importer_moment_integrator(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_integrator = mitsuba_scene_parser.get_props_by_name('moment') - assert mi_integrator - - mi_child_integrator = mitsuba_scene_parser.get_props_by_name('path') - assert mi_child_integrator - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_scene = bpy.context.scene - assert bl_scene.mitsuba.active_integrator == 'moment' - assert bl_scene.mitsuba.available_integrators.moment.integrators.count == 1 - bl_child_integrator = bl_scene.mitsuba.available_integrators.moment.integrators.collection[0] - assert bl_child_integrator.active_integrator == 'path' - assert bl_child_integrator.available_integrators.path.max_depth == mi_child_integrator.get('max_depth') - assert bl_child_integrator.available_integrators.path.rr_depth == mi_child_integrator.get('rr_depth') - assert bl_child_integrator.available_integrators.path.hide_emitters == mi_child_integrator.get('hide_emitters') - - # assert len(mi_integrator.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/sampler_independent.xml"]) -def test_importer_independent_sampler(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_sampler = mitsuba_scene_parser.get_props_by_name('independent') - assert mi_sampler - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_sampler == 'independent' - assert bl_camera.samplers.independent.sample_count == mi_sampler.get('sample_count') - assert bl_camera.samplers.independent.seed == mi_sampler.get('seed') - - # assert len(mi_sampler.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/sampler_stratified.xml"]) -def test_importer_stratified_sampler(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_sampler = mitsuba_scene_parser.get_props_by_name('stratified') - assert mi_sampler - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_sampler == 'stratified' - assert bl_camera.samplers.stratified.sample_count == mi_sampler.get('sample_count') - assert bl_camera.samplers.stratified.seed == mi_sampler.get('seed') - assert bl_camera.samplers.stratified.jitter == mi_sampler.get('jitter') - - # assert len(mi_sampler.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/sampler_multijitter.xml"]) -def test_importer_multijitter_sampler(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_sampler = mitsuba_scene_parser.get_props_by_name('multijitter') - assert mi_sampler - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_sampler == 'multijitter' - assert bl_camera.samplers.multijitter.sample_count == mi_sampler.get('sample_count') - assert bl_camera.samplers.multijitter.seed == mi_sampler.get('seed') - assert bl_camera.samplers.multijitter.jitter == mi_sampler.get('jitter') - - # assert len(mi_sampler.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/rfilter_box.xml"]) -def test_importer_box_rfilter(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_rfilter = mitsuba_scene_parser.get_props_by_name('box') - assert mi_rfilter - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_rfilter == 'box' - - bl_cycles = bpy.context.scene.cycles - assert bl_cycles.pixel_filter_type == 'BOX' - - # assert len(mi_rfilter.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/rfilter_tent.xml"]) -def test_importer_tent_rfilter(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_rfilter = mitsuba_scene_parser.get_props_by_name('tent') - assert mi_rfilter - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_rfilter == 'tent' - - # assert len(mi_rfilter.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/rfilter_gaussian.xml"]) -def test_importer_gaussian_rfilter(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_rfilter = mitsuba_scene_parser.get_props_by_name('gaussian') - assert mi_rfilter - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_camera = bpy.context.scene.camera.data.mitsuba - assert bl_camera.active_rfilter == 'gaussian' - assert bl_camera.rfilters.gaussian.stddev == mi_rfilter.get('stddev') - - bl_cycles = bpy.context.scene.cycles - assert bl_cycles.pixel_filter_type == 'GAUSSIAN' - - # assert len(mi_rfilter.unqueried()) == 0 - -@pytest.mark.parametrize("xml_scene", ["scenes/film_hdrfilm.xml", "scenes/film_hdrfilm_crop.xml"]) -def test_importer_hdrfilm_film(resource_resolver, mitsuba_scene_parser, xml_scene): - scene_file = resource_resolver.get_absolute_resource_path(xml_scene) - mitsuba_scene_parser.load_xml(scene_file) - - mi_film = mitsuba_scene_parser.get_props_by_name('hdrfilm') - assert mi_film - - assert bpy.ops.import_scene.mitsuba(filepath=scene_file) == {'FINISHED'} - - bl_render = bpy.context.scene.render - assert bl_render.resolution_percentage == 100 - assert bl_render.resolution_x == mi_film.get('width') - assert bl_render.resolution_y == mi_film.get('height') - assert bl_render.image_settings.file_format == 'OPEN_EXR' - assert bl_render.image_settings.color_mode == 'RGBA' - assert bl_render.image_settings.color_depth == '32' - if 'crop' in xml_scene: - assert bl_render.use_border == True - assert bl_render.border_min_x == 0.0 - assert bl_render.border_min_y == 0.0 - assert bl_render.border_max_x == 0.5 - assert bl_render.border_max_y == 0.5 - else: - assert bl_render.use_border == False - - # assert len(mi_film.unqueried()) == 0 \ No newline at end of file diff --git a/tests/test_mitsuba.py b/tests/test_mitsuba.py deleted file mode 100644 index 670d717..0000000 --- a/tests/test_mitsuba.py +++ /dev/null @@ -1,4 +0,0 @@ - -def test_mitsuba_has_correct_variant(): - import mitsuba - assert mitsuba.variant() == 'scalar_rgb' diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py deleted file mode 100644 index ca35b71..0000000 --- a/tests/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import mi_scene_utils \ No newline at end of file diff --git a/tests/utils/mi_scene_utils.py b/tests/utils/mi_scene_utils.py deleted file mode 100644 index b675985..0000000 --- a/tests/utils/mi_scene_utils.py +++ /dev/null @@ -1,38 +0,0 @@ -import os -import numpy as np - -def _bitmap_extract(bmp, require_variance=True): - from mitsuba import Bitmap, Struct - """Extract different channels from moment integrator AOVs""" - # AVOs from the moment integrator are in XYZ (float32) - split = bmp.split() - if len(split) == 1: - if require_variance: - raise RuntimeError( - 'Could not extract variance image from bitmap. ' - 'Did you wrap the integrator into a `moment` integrator?\n{}'.format(bmp)) - b_root = split[0][1] - if b_root.channel_count() >= 3 and b_root.pixel_format() != Bitmap.PixelFormat.XYZ: - b_root = b_root.convert(Bitmap.PixelFormat.XYZ, Struct.Type.Float32, False) - return np.array(b_root, copy=True), None - else: - img = np.array(split[1][1], copy=False) - img_m2 = np.array(split[2][1], copy=False) - return img, img_m2 - img * img - -def xyz_to_rgb_bmp(arr): - """Convert an XYZ image to RGB""" - from mitsuba import Bitmap, Struct - xyz_bmp = Bitmap(arr, Bitmap.PixelFormat.XYZ) - return xyz_bmp.convert(Bitmap.PixelFormat.RGB, Struct.Type.Float32, False) - -def render_scene(scene_file, spp, res): - from mitsuba import load_file - - scene = load_file(scene_file, spp=spp, resx=res[0], resy=res[1]) - scene.integrator().render(scene, seed=0, develop=False) - - bmp = scene.sensors()[0].film().bitmap(raw=False) - img, var_img = _bitmap_extract(bmp) - - return img, var_img From d9ef32b6da37d973663696f795c5f61bfdd669e9 Mon Sep 17 00:00:00 2001 From: Dorian Ros Date: Sat, 15 Oct 2022 13:32:10 +0200 Subject: [PATCH 2/5] Uniformize naming --- mitsuba-blender/__init__.py | 26 +++++++++++++------------- mitsuba-blender/utils/__init__.py | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mitsuba-blender/__init__.py b/mitsuba-blender/__init__.py index baf78f3..d60ff7f 100644 --- a/mitsuba-blender/__init__.py +++ b/mitsuba-blender/__init__.py @@ -23,7 +23,7 @@ engine, nodes, properties, operators, ui ) -from .utils import pip_ensure, pip_install_package, pip_package_version +from .utils import pip_ensure, pip_package_install, pip_package_version DEPS_MITSUBA_VERSION = '3.0.1' @@ -74,14 +74,14 @@ def register_addon(context): else: prefs.status_message = 'Failed to load custom Mitsuba. Please verify the path to the build directory.' elif prefs.is_mitsuba_installed: - if prefs.has_valid_dependencies_version: + if prefs.has_valid_mitsuba_version: could_init_mitsuba = init_mitsuba() if could_init_mitsuba: prefs.status_message = f'Found pip Mitsuba v{get_mitsuba_version_string()}.' else: prefs.status_message = 'Failed to load Mitsuba package.' else: - prefs.status_message = f'Found pip Mitsuba v{prefs.installed_dependencies_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' + prefs.status_message = f'Found pip Mitsuba v{prefs.installed_mitsuba_version}. Supported version is v{DEPS_MITSUBA_VERSION}.' else: prefs.status_message = 'Mitsuba dependencies not installed.' @@ -125,16 +125,16 @@ class MITSUBA_OT_download_package_dependencies(Operator): @classmethod def poll(cls, context): prefs = get_addon_preferences(context) - return not prefs.is_mitsuba_installed or not prefs.has_valid_dependencies_version + return not prefs.is_mitsuba_installed or not prefs.has_valid_mitsuba_version def execute(self, context): - if not pip_install_package('mitsuba', version=DEPS_MITSUBA_VERSION): + if not pip_package_install('mitsuba', version=DEPS_MITSUBA_VERSION): self.report({'ERROR'}, 'Failed to download Mitsuba package with pip.') return {'CANCELLED'} prefs = get_addon_preferences(context) prefs.is_mitsuba_installed = True - prefs.installed_dependencies_version = DEPS_MITSUBA_VERSION + prefs.installed_mitsuba_version = DEPS_MITSUBA_VERSION reload_addon(context) @@ -155,16 +155,16 @@ class MitsubaPreferences(AddonPreferences): name = 'Is a Blender restart required', ) - def update_installed_dependencies_version(self, context): - self.has_valid_dependencies_version = self.installed_dependencies_version == DEPS_MITSUBA_VERSION + def update_installed_mitsuba_version(self, context): + self.has_valid_mitsuba_version = self.installed_mitsuba_version == DEPS_MITSUBA_VERSION - installed_dependencies_version : StringProperty( + installed_mitsuba_version : StringProperty( name = 'Installed Mitsuba dependencies version string', default = '', - update = update_installed_dependencies_version, + update = update_installed_mitsuba_version, ) - has_valid_dependencies_version : BoolProperty( + has_valid_mitsuba_version : BoolProperty( name = 'Has the correct version of dependencies' ) @@ -278,7 +278,7 @@ def draw(self, context): row.label(text=self.status_message, icon=icon) download_operator_text = 'Install Mitsuba' - if self.is_mitsuba_installed and not self.has_valid_dependencies_version: + if self.is_mitsuba_installed and not self.has_valid_mitsuba_version: download_operator_text = 'Update Mitsuba' layout.operator(MITSUBA_OT_download_package_dependencies.bl_idname, text=download_operator_text) @@ -305,7 +305,7 @@ def register(): prefs.is_mitsuba_initialized = False mitsuba_installed_version = pip_package_version('mitsuba') prefs.is_mitsuba_installed = mitsuba_installed_version != None - prefs.installed_dependencies_version = mitsuba_installed_version if mitsuba_installed_version is not None else '' + prefs.installed_mitsuba_version = mitsuba_installed_version if mitsuba_installed_version is not None else '' prefs.is_restart_required = False if register_addon(context): diff --git a/mitsuba-blender/utils/__init__.py b/mitsuba-blender/utils/__init__.py index e7071d1..73639ee 100644 --- a/mitsuba-blender/utils/__init__.py +++ b/mitsuba-blender/utils/__init__.py @@ -12,12 +12,12 @@ def pip_ensure(): result = subprocess.run([sys.executable, '-m', 'ensurepip'], capture_output=True) return result.returncode == 0 -def pip_has_package(package: str): +def pip_package_is_installed(package: str): ''' Check if the executing Python environment has a specified package. ''' result = subprocess.run([sys.executable, '-m', 'pip', 'show', package], capture_output=True) return result.returncode == 0 -def pip_install_package(package: str, version: str = None): +def pip_package_install(package: str, version: str = None): ''' Install a specified package in the executing Python environment. ''' if version is None: result = subprocess.run([sys.executable, '-m', 'pip', 'install', '--upgrade', package], capture_output=True) From 9c9d1c63899650d7023f94b93cdf9cde2953a1ec Mon Sep 17 00:00:00 2001 From: Dorian Ros Date: Sat, 15 Oct 2022 14:51:16 +0200 Subject: [PATCH 3/5] Simple node tree test --- mitsuba-blender/nodes/materials/__init__.py | 28 ++++++------- mitsuba-blender/nodes/materials/nodetree.py | 42 ++++++++++---------- mitsuba-blender/nodes/sockets.py | 12 +++--- mitsuba-blender/nodes/textures/__init__.py | 4 +- mitsuba-blender/nodes/transforms/__init__.py | 2 +- scripts/run_tests.py | 8 ++-- tests/nodes/test_materials.py | 4 +- 7 files changed, 50 insertions(+), 50 deletions(-) diff --git a/mitsuba-blender/nodes/materials/__init__.py b/mitsuba-blender/nodes/materials/__init__.py index f794e7e..7017951 100644 --- a/mitsuba-blender/nodes/materials/__init__.py +++ b/mitsuba-blender/nodes/materials/__init__.py @@ -10,21 +10,21 @@ classes = ( nodetree.MitsubaNodeTreeMaterial, output.MitsubaNodeOutputMaterial, - twosided.MitsubaNodeTwosidedBSDF, - diffuse.MitsubaNodeDiffuseBSDF, - dielectric.MitsubaNodeDielectricBSDF, - dielectric.MitsubaNodeThinDielectricBSDF, - dielectric.MitsubaNodeRoughDielectricBSDF, - conductor.MitsubaNodeConductorBSDF, - conductor.MitsubaNodeRoughConductorBSDF, - plastic.MitsubaNodePlasticBSDF, - plastic.MitsubaNodeRoughPlasticBSDF, - bumpmap.MitsubaNodeBumpMapBSDF, - normalmap.MitsubaNodeNormalMapBSDF, - blend.MitsubaNodeBlendBSDF, - mask.MitsubaNodeMaskBSDF, + # twosided.MitsubaNodeTwosidedBSDF, + # diffuse.MitsubaNodeDiffuseBSDF, + # dielectric.MitsubaNodeDielectricBSDF, + # dielectric.MitsubaNodeThinDielectricBSDF, + # dielectric.MitsubaNodeRoughDielectricBSDF, + # conductor.MitsubaNodeConductorBSDF, + # conductor.MitsubaNodeRoughConductorBSDF, + # plastic.MitsubaNodePlasticBSDF, + # plastic.MitsubaNodeRoughPlasticBSDF, + # bumpmap.MitsubaNodeBumpMapBSDF, + # normalmap.MitsubaNodeNormalMapBSDF, + # blend.MitsubaNodeBlendBSDF, + # mask.MitsubaNodeMaskBSDF, null.MitsubaNodeNullBSDF, - principled.MitsubaNodePrincipledBSDF, + # principled.MitsubaNodePrincipledBSDF, ) def register(): diff --git a/mitsuba-blender/nodes/materials/nodetree.py b/mitsuba-blender/nodes/materials/nodetree.py index 64a6fee..ded0ace 100644 --- a/mitsuba-blender/nodes/materials/nodetree.py +++ b/mitsuba-blender/nodes/materials/nodetree.py @@ -32,33 +32,33 @@ def poll(cls, context): mitsuba_node_categories_material = [ MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_BSDF', 'BSDFs', items=[ - NodeItem('MitsubaNodeTwosidedBSDF', label='Twosided'), - NodeItem('MitsubaNodeDiffuseBSDF', label='Diffuse'), - NodeItem('MitsubaNodeDielectricBSDF', label='Dielectric'), - NodeItem('MitsubaNodeThinDielectricBSDF', label='Thin Dielectric'), - NodeItem('MitsubaNodeRoughDielectricBSDF', label='Rough Dielectric'), - NodeItem('MitsubaNodeConductorBSDF', label='Conductor'), - NodeItem('MitsubaNodeRoughConductorBSDF', label='Rough Conductor'), - NodeItem('MitsubaNodePlasticBSDF', label='Plastic'), - NodeItem('MitsubaNodeRoughPlasticBSDF', label='Rough Plastic'), - NodeItem('MitsubaNodeBumpMapBSDF', label='Bump Map'), - NodeItem('MitsubaNodeNormalMapBSDF', label='Normal Map'), - NodeItem('MitsubaNodeBlendBSDF', label='Blend'), - NodeItem('MitsubaNodeMaskBSDF', label='Opacity Mask'), + # NodeItem('MitsubaNodeTwosidedBSDF', label='Twosided'), + # NodeItem('MitsubaNodeDiffuseBSDF', label='Diffuse'), + # NodeItem('MitsubaNodeDielectricBSDF', label='Dielectric'), + # NodeItem('MitsubaNodeThinDielectricBSDF', label='Thin Dielectric'), + # NodeItem('MitsubaNodeRoughDielectricBSDF', label='Rough Dielectric'), + # NodeItem('MitsubaNodeConductorBSDF', label='Conductor'), + # NodeItem('MitsubaNodeRoughConductorBSDF', label='Rough Conductor'), + # NodeItem('MitsubaNodePlasticBSDF', label='Plastic'), + # NodeItem('MitsubaNodeRoughPlasticBSDF', label='Rough Plastic'), + # NodeItem('MitsubaNodeBumpMapBSDF', label='Bump Map'), + # NodeItem('MitsubaNodeNormalMapBSDF', label='Normal Map'), + # NodeItem('MitsubaNodeBlendBSDF', label='Blend'), + # NodeItem('MitsubaNodeMaskBSDF', label='Opacity Mask'), NodeItem('MitsubaNodeNullBSDF', label='Null'), - NodeItem('MitsubaNodePrincipledBSDF', label='Principled'), + # NodeItem('MitsubaNodePrincipledBSDF', label='Principled'), ]), - MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_TEXTURE', 'Textures', items=[ - NodeItem('MitsubaNodeBitmapTexture', label='Bitmap'), - NodeItem('MitsubaNodeCheckerboardTexture', label='Checkerboard'), - ]), + # MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_TEXTURE', 'Textures', items=[ + # NodeItem('MitsubaNodeBitmapTexture', label='Bitmap'), + # NodeItem('MitsubaNodeCheckerboardTexture', label='Checkerboard'), + # ]), MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_OUTPUT', 'Output', items=[ NodeItem('MitsubaNodeOutputMaterial', label='Output'), ]), - MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_TRANSFORM', 'Transforms', items=[ - NodeItem('MitsubaNode2DTransform', label='Transform 2D'), - ]), + # MitsubaNodeCategoryMaterial('MITSUBA_MATERIAL_TRANSFORM', 'Transforms', items=[ + # NodeItem('MitsubaNode2DTransform', label='Transform 2D'), + # ]), ] diff --git a/mitsuba-blender/nodes/sockets.py b/mitsuba-blender/nodes/sockets.py index 847a0e6..d925e08 100644 --- a/mitsuba-blender/nodes/sockets.py +++ b/mitsuba-blender/nodes/sockets.py @@ -73,12 +73,12 @@ class MitsubaSocket2DTransform(bpy.types.NodeSocket, MitsubaSocket): classes = ( MitsubaSocketBSDF, - MitsubaSocketColorTexture, - MitsubaSocketNormalMap, - MitsubaSocketFloatTextureNoDefault, - MitsubaSocketFloatTextureUnbounded, - MitsubaSocketFloatTextureBounded0to1, - MitsubaSocket2DTransform, + # MitsubaSocketColorTexture, + # MitsubaSocketNormalMap, + # MitsubaSocketFloatTextureNoDefault, + # MitsubaSocketFloatTextureUnbounded, + # MitsubaSocketFloatTextureBounded0to1, + # MitsubaSocket2DTransform, ) def register(): diff --git a/mitsuba-blender/nodes/textures/__init__.py b/mitsuba-blender/nodes/textures/__init__.py index 995842f..2fc5fd0 100644 --- a/mitsuba-blender/nodes/textures/__init__.py +++ b/mitsuba-blender/nodes/textures/__init__.py @@ -5,8 +5,8 @@ ) classes = ( - bitmap.MitsubaNodeBitmapTexture, - checkerboard.MitsubaNodeCheckerboardTexture, + # bitmap.MitsubaNodeBitmapTexture, + # checkerboard.MitsubaNodeCheckerboardTexture, ) def register(): diff --git a/mitsuba-blender/nodes/transforms/__init__.py b/mitsuba-blender/nodes/transforms/__init__.py index 1ade1e4..903bbe6 100644 --- a/mitsuba-blender/nodes/transforms/__init__.py +++ b/mitsuba-blender/nodes/transforms/__init__.py @@ -5,7 +5,7 @@ ) classes = ( - transform2d.MitsubaNode2DTransform, + # transform2d.MitsubaNode2DTransform, ) def register(): diff --git a/scripts/run_tests.py b/scripts/run_tests.py index d5283f1..71c1b1d 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -8,7 +8,7 @@ class SetupPlugin: def __init__(self, args): # If this flag is set, skip the add-on installation process. # This assumes that the add-on is already installed in the executing Blender instance. - self.skip_install = args['--skip-install'] + self.no_install = args['--no-install'] # If this flag is set, Blender's temporary directory is set to a local folder. # This is useful to save crash logs in a common place across environments. @@ -39,7 +39,7 @@ def pytest_configure(self, config): os.makedirs(self.bl_tmp_dir, exist_ok=True) bpy.context.preferences.filepaths.temporary_directory = str(self.bl_tmp_dir) - if not self.skip_install: + if not self.no_install: if os.path.exists(self.bl_mi_addon_dir): os.remove(self.bl_mi_addon_dir) @@ -59,7 +59,7 @@ def pytest_configure(self, config): print(bpy.context.preferences.filepaths.temporary_directory) def pytest_unconfigure(self): - if not self.skip_install: + if not self.no_install: bpy.ops.preferences.addon_disable(module='mitsuba-blender') bpy.ops.wm.save_userpref() # Remove the symlink @@ -79,7 +79,7 @@ def main(args): pass other_args = { - '--skip-install': False, + '--no-install': False, '--local-tmp': False, } diff --git a/tests/nodes/test_materials.py b/tests/nodes/test_materials.py index 49833bf..5dd8c52 100644 --- a/tests/nodes/test_materials.py +++ b/tests/nodes/test_materials.py @@ -5,8 +5,8 @@ from fixtures import * @pytest.mark.parametrize('scene, plugin', [ - ('scenes/diffuse.xml', 'diffuse'), - # ('scenes/null.xml', 'null'), + # ('scenes/diffuse.xml', 'diffuse'), + ('scenes/null.xml', 'null'), # ('scenes/plastic.xml', 'plastic'), # ('scenes/roughplastic.xml', 'roughplastic'), # ('scenes/dielectric.xml', 'dielectric'), From 7e8bd447a409e66ba03d38e353d076d8ae14a978 Mon Sep 17 00:00:00 2001 From: Dorian Ros Date: Sun, 12 Nov 2023 22:58:36 +0100 Subject: [PATCH 4/5] Test pipeline with recent version of Blender and Mitsuba --- .github/workflows/test.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05415fc..83b32e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,18 +25,15 @@ jobs: environment: - { os: "ubuntu-latest", - mitsuba-version: "3.0.1" + mitsuba-version: "3.4.0" } - { os: "windows-latest", - mitsuba-version: "3.0.1" + mitsuba-version: "3.4.0" } blender: - { - version: "2.93" - } - - { - version: "3.3" + version: "3.6.5" } steps: From 398f0a39835ac5c20356f23cafeac6f1dd844cff Mon Sep 17 00:00:00 2001 From: Dorian Ros Date: Sun, 12 Nov 2023 23:02:18 +0100 Subject: [PATCH 5/5] Fix uninitialized variable --- mitsuba-blender/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mitsuba-blender/__init__.py b/mitsuba-blender/__init__.py index d60ff7f..be5a597 100644 --- a/mitsuba-blender/__init__.py +++ b/mitsuba-blender/__init__.py @@ -25,7 +25,7 @@ from .utils import pip_ensure, pip_package_install, pip_package_version -DEPS_MITSUBA_VERSION = '3.0.1' +DEPS_MITSUBA_VERSION = '3.4.0' def get_addon_preferences(context): return context.preferences.addons[__name__].preferences @@ -62,6 +62,7 @@ def register_addon(context): prefs = get_addon_preferences(context) prefs.status_message = '' + could_init_mitsuba = False if prefs.using_mitsuba_custom_path: prefs.update_additional_custom_paths(context) could_init_mitsuba = init_mitsuba()