Material texture path change Python

i am trying to copy all texture pictures, used in project, to one folder and than change the path to this folder.
As a starting point I used this topic: Access To Material Texture Path
and thanks to @Alberto_Tono I was able to find a copy all external used jepg files in my materials to one folder.
From The Building Coder: https://thebuildingcoder.typepad.com/blog/2019/04/set-material-texture-path-in-editscope.html
I found out there is a way to change path in material to my new path. But it is writen in C# and I’am still learning python. Is there a way you can help me to figure out what i am doing wrong?
Here is my code in python (for applying in Revit I’m using pyRevit)

import clr
import rpw
import sys
import os
import ntpath
from shutil import copy
clr.AddReference("RevitApi")
from Autodesk.Revit.DB import *
from Autodesk.Revit.UI import TaskDialog
from rpw.ui.forms import Console

doc = __revit__.ActiveUIDocument.Document

#Save file:
try:
folder = rpw.ui.forms.select_folder()
filePath = folder + '\\'+ doc.Title + '.rvt'
opt = SaveAsOptions()
opt.OverwriteExistingFile = True
#doc.SaveAs(filePath, opt)
except:
sys.exit()

#name of file from path
def fileName(path):
head, tail = ntpath.split(path)
return tail or ntpath.basename(head)

def newfolder(name, folder):
name = folder+"\\"+name
newfolder.path = name
if not os.path.exists(name):
    os.makedirs(name)

#Create New Folder
newfolder("Materials",folder)

#select material
materials = FilteredElementCollector(doc).OfCategory(BuiltInCategory.OST_Materials).\
        WhereElementIsNotElementType().ToElements()

for material in materials:
appearanceAssetId = material.AppearanceAssetId
assetElem = doc.GetElement(appearanceAssetId)
try:
    with Transaction(doc, "Change jpeg path") as t:
        t.Start()
        with Visual.AppearanceAssetEditScope(assetElem.Document) as editScope:
            editableAsset = editScope.Start(assetElem.Id)
            assetProperty = editableAsset.FindByName("generic_diffuse")
            connectedAsset = assetProperty.GetConnectedProperty(0)
            if connectedAsset.Name == "UnifiedBitmapSchema":
                assetPath = connectedAsset["unifiedbitmap_Bitmap"]
                assetPathValue = assetPath.Value
                #Copy material to new folder
                copy(assetPathValue,newfolder.path)
                texturePath = "{}\\{}"
                texturePathFormat = texturePath.format(folder,fileName(assetPathValue))
                #NOT WORKING
                assetPathValue = texturePathFormat
            editScope.Commit(True)
        t.Commit()
except:
    print("passing")
    pass

Hi @Mo_Ody,

You can also use the Material Change Texture Path node in Genius Loci package.
It is ready to be used, but with Dynamo not pyRevit. :grinning:

2 Likes

Dear @mo_ody

We could work together on the Python code and then publish the solution here to make it open source . We at www.sfcdi.org would love to support you.

Best regards

It is not 100% solved, but it is a start:

  • I used Dynamo, but installed the RevitPythonWrapper package so you wont have to change much when trying it in pyRevit.
  • Revit returns in some cases the relative path when looking for the bitmap path . I´ve added the default material library to the path. with os.path.join(default_lib, asset_path), which could cause problems if the files are not there. Jeremy gives some good pointer at the end of this blog post.
  • I had the issue of returning joined, duplicated paths from GetSingleConnectedAsset(). It´s solved for now by using the split() method. I assume there is cleaner way to receive only 1 path.

There is not much wrong with your code. You should put break points inside your code and return it partially to see if data goes from one method to the next in the right format. To debug your scripts faster, you could look into Exceptions and consider using Dynamo to “prototype” your scripts:

Capture

Here is the script:

import sys
rpw_path = IN[0]
sys.path.append(rpw_path)

from rpw import db, ui, revit, DB
from shutil import copy
import os

doc = revit.doc


def new_folder(name, folder):
    name = "\\".join((folder, name))
    new_folder.path = name
    if not os.path.exists(name):
        os.makedirs(name)


def copy_materials(document):
    """
    Copy all texture pictures used in project

    TODO:
        - Relative path handling
    """
    out = []	

    # Destination folder
    dest_folder = ui.forms.select_folder()
    new_folder("Materials", dest_folder)
    default_lib = "C:/Program Files (x86)/Common Files/Autodesk Shared/Materials/Textures"

    materials = db.Collector(of_class="Material", is_type=False).elements

    with db.Transaction("Change image path") as t:
        for material in materials:
            try:
                appearance_id = material.AppearanceAssetId
                asset = document.GetElement(appearance_id)
                if asset:
                    with DB.Visual.AppearanceAssetEditScope(document) as editScope:
                        edit_asset = editScope.Start(asset.Id)
                        generic_diff = edit_asset.FindByName("generic_diffuse")
                        if generic_diff:
                            connect_asset = generic_diff.GetSingleConnectedAsset()
                            if connect_asset:
                                asset_path = connect_asset["unifiedbitmap_Bitmap"].Value.split("|")[0]

                                # Copy files and handle relative path
                                if not os.path.exists(asset_path):
                                    asset_path = os.path.join(default_lib, asset_path)
                                copy(asset_path, new_folder.path)

                                # Change texture path
                                _, file_name = os.path.split(asset_path)
                                texture_path = os.path.join(new_folder.path, file_name)
                                connect_asset["unifiedbitmap_Bitmap"].Value = texture_path
                                out.append(texture_path)
                        editScope.Commit(True)
            except Exception as e:
                out.append((file_name, texture_path, e))
    return out

OUT = copy_materials(doc)
3 Likes

Hi guys thanks for help.
@Kibar I tried it up. And it’s working quite well. I had to change asset_path = asset_path.replace("/\","/") because of incorrect file path
I don’t know if your dynamo works 100% correct, because it gives me a error mesage. But when I run my code it’s like 70% success. The biggest issue is shared Autoced library where it copy image but not change the file path. but when I created a new RVT file with new materials everything worked as it should.

what I wanted to do first was to create a file with the rvt file with all the materials I could send (for example via email )and it going to work properly. So I tried create relative path to material like: /Material/name_of_jpeg.jpg

test_path = os.path.relpath(texture_path,newfolder.filePath)

but I think it’s not possible. Maybe I’ll just create a dynamo script to change a path in another user computer. Or Is there a better way?

I had the same issue setting the new value.
I ran some properties and methods of AssetPropertyString and it returns that the new value is invalid. I checked if it is a string and if the path of that string exists.

I won’t be able to look into it more until tonight/tomorrow. I have it set in pyRevit now aswell and will post it here later.

@Alberto_Tono 's suggestion of working with sfcdi could be very helpful here, especially if you want to extend the functionality (other properties such as bump, or some kind of image batch manager).

Hey @Mo_Ody,
Sorry for coming back at this so late.
I have been working on it in pyRevit and tried to find the error by splitting it into functions and making changes to how the path to the bitmap is retrieved.

The exception is raised on only 1 image in my case and it is the same image when I use it on a different project. My guess is that Revit prevents “deleting”/changing the asset property, because the value is the correct path as String. The reason could be related to this post on stack overflow.

Here is the Exception:

Autodesk.Revit.Exceptions.ArgumentException: The input value is invalid for this AssetPropertyString property.
Parameter name: value
at Autodesk.Revit.DB.Visual.AssetPropertyString.set_Value(String value)
at CallSite.Target(Closure , CallSite , Object , Object )
at material_bitmap_copy_and_set$3884(Closure , PythonFunction , Object , Object )

You can skip the exception by checking the property for IsValidValue() as commented out in the script.

Here is the content of my script.py file:

from System import Environment
from rpw import db, ui, revit, DB, exceptions
from pyrevit import forms, script
from shutil import copy
import os


def main():
    material_collector = db.Collector(of_class="Material", is_type=False)
    materials = material_collector.get_elements()

    # Input destination folder
    destination_folder = forms.pick_folder(title="DESTINATION FOLDER")
    n_folder = os.path.join(destination_folder, "Materials")
    if not os.path.exists(n_folder):
        os.makedirs(n_folder)

    # Effective Output
    max_value = len(materials)
    with forms.ProgressBar(title="Material...\
            ({value} of {max_value})", cancellable="True") as pb:
        with db.Transaction("Copy and change rendering texture path") as t:
            for count, material in enumerate(materials):

                # ProgressBar update
                if pb.cancelled:
                    break
                else:
                    pb.update_progress(count, max_value)

                # Copy materials to new folder
                # Then set material asset to copy
                material_bitmap_copy_and_set(material, n_folder)


def file_copy_by_path(file_path, destination_folder):
    """
    Copies a file to a folder
    and returns the new path

    file_path -- str (absolute path)
    destination_folder -- str (absolute path)
    """

    try:
        copy(file_path, destination_folder)
    except EnvironmentError as e:
        # File exists at destination
        pass
    except Exception as e:
        # Other exceptions
        print(e)

    file_name = os.path.basename(file_path)
    new_path = os.path.join(destination_folder, file_name)

    return new_path


def material_bitmap_copy_and_set(material, new_folder):
    asset_id = material.AppearanceAssetId

    if asset_id.InvalidElementId != -1:
        # Color and Transparency only appearances
        # will return an invalid element ids (-1)
        asset_element = material.Document.GetElement(asset_id)
        if asset_element is not None:
            with DB.Visual.AppearanceAssetEditScope(asset_element.Document) as edit_scope:

                # Return an editable copy of the appearance asset
                editable_asset = edit_scope.Start(asset_element.Id)
                generic_diff = DB.Visual.Generic.GenericDiffuse
                generic_diff_property = editable_asset.FindByName(generic_diff)
                if generic_diff_property is not None:

                    # Find the connected asset
                    connected_asset = generic_diff_property.GetSingleConnectedAsset()
                    if connected_asset is not None:

                        # Find the target asset property
                        uni_bitmap = DB.Visual.UnifiedBitmap.UnifiedbitmapBitmap
                        bitmap_property = connected_asset.FindByName(uni_bitmap)

                        # Get the absolute path to the asset property
                        bitmap_path = get_bitmap(bitmap_property.Value)

                        # Copy bitmap to new locatioon
                        new_bitmap_path = file_copy_by_path(bitmap_path, new_folder)

                        try:
                            # if bitmap_property.IsValidValue(new_bitmap_path):
                            bitmap_property.Value = new_bitmap_path
                            print("SUCCESS for [{}]".format(material.Name))
                            print("\tNew bitmap-> {}".format(new_bitmap_path))
                            print(20 * "-")
                            edit_scope.Commit(True)

                        except exceptions.RevitExceptions.ArgumentException as e:
                            print("FAIL for [{}]".format(material.Name))
                            print("\tValue not accepted->({})".format(new_bitmap_path))
                            print("\tException->: {}".format(e))
                            print("\tCurrent bitmap->: {}".format(bitmap_path))
                            print(20 * "-")
                            edit_scope.Cancel()

                        except Exception as e:
                            # Other exceptions
                            print("FAIL for {}".format(material.Name))
                            print("\tException->: {}".format(bitmap_path))
                            print(20 * "-")
                            edit_scope.Cancel()


def get_bitmap(property_value):
    """
    Returns the absolute path of the given
    AsserPropertyString.Value
    --> Path is relative of inside default Material Library

    Input raltive path like:
    '1/path/file | 2/path/file | ...'

    Output:
    'absolute/path/to/file'
    """

    bitmap_paths = property_value.split("|")
    abs_path = []
    library_paths = revit.app.GetLibraryPaths().Values

    for bitmap_path in bitmap_paths:
        if os.path.isabs(bitmap_path):
            # abs_path = bitmap_path
            abs_path = os.path.abspath(bitmap_path)
            break
        else:
            for lib_path in library_paths:
                if os.path.dirname(os.path.relpath(bitmap_path)) in os.path.abspath(lib_path):
                    abs_path = os.path.join(os.path.abspath(lib_path), os.path.basename(bitmap_path))
                    break

    return abs_path


if __name__ == "__main__":
    main()
2 Likes

woow.thanks a lot! As a beginner, I learned a lot from this code. :nerd_face:
But this script didn’t work for me. an error popped up immediately after it started:


So I went through the code and found out that the error is causing the get_bitmap function specifically abs_path was returning [ ] for not absolute path

and I also don’t know if using lib_path in this get_bitmap function has sense, because it’s a Revit folder in which there is no autodesk shared library and also no images at all.

so I changed it the way you showed me before:

def get_bitmap(property_value):
"""
Returns the absolute path of the given
AsserPropertyString.Value
--> Path is relative of inside default Material Library

Input raltive path like:
'1/path/file | 2/path/file | ...'

Output:
'absolute/path/to/file'
"""
default_lib = "C:\Program Files (x86)\Common Files\Autodesk Shared\Materials\Textures"
bitmap_paths = property_value.split("|")
abs_path = []
library_paths = revit.app.GetLibraryPaths().Values

for bitmap_path in bitmap_paths:
    if os.path.isabs(bitmap_path):
        # abs_path = bitmap_path
        abs_path = os.path.abspath(bitmap_path) 
    else:
        abs_path = os.path.join(default_lib,bitmap_path)
             
return abs_pat

and it’s worked, but success is comparable to the previous code :smile: but it will probably be the error you are talking about


anyway, thank you very much you helped me a lot

1 Like

Great to see that you´ve found your way through the code :smiley: .

Yes, it was an attempt on iterating through the library path, and it needs some fixing.