0

This is for a script I'm running in Blender, but the question pertains to the Python part of it. It's not specific to Blender.

The script is originally from this answer, and it replaces a given material (the key) with its newer equivalent (the value).

Here's the code:

import bpy

objects = bpy.context.selected_objects

mat_dict =  {
  "SOLID-WHITE": "Sld_WHITE",
  "SOLID-BLACK": "Sld_BLACK",
  "SOLID-BLUE": "Sld_BLUE"
}

for obj in objects:
    for slot in obj.material_slots:
        slot.material = bpy.data.materials[mat_dict[slot.material.name]]

The snag is, how to handle duplicates when the scene may have not only objects with the material "SOLID-WHITE", but also "SOLID-WHITE.001", "SOLID-WHITE.002", and so on.

I was looking at this answer to a question about wildcards in Python and it seems fnmatch might well well-suited for this task.

I've tried working fnmatch into the last line of the code. I've also tried wrapping the dictionary keys with it (very WET, I know). Neither of these approaches has worked.

How can I run a wildcard match on each dictionary key?

So for example, whether an object has "SOLID-WHITE" or "SOLID-WHITE"-dot-some-number, it will still be replaced with "Sld_WHITE"?

Mentalist
  • 1,530
  • 1
  • 18
  • 32

2 Answers2

1

There are two ways you can approach this. You can make a smart dictionary that matches vague names. Or you can change the key that is used to look up the a color.

Here is an example of the first approach using fnmatch. this approach changes the lookup time complexity from O(1) to O(n) when a color contains a number. this approach extends UserDict with a __missing__ method. the __missing__ method gets called if the key is not found in the dictionary. it compares every key with the given key using fnmatch.

from collections import UserDict
import fnmatch
import bpy

objects = bpy.context.selected_objects

class Colors(UserDict):
    def __missing__(self, key):
        for color in self.keys():
            if fnmatch.fnmatch(key, color + "*"):
                return self[color]
        raise KeyError(f"could not match {key}")

mat_dict = Colors({
  "SOLID-WHITE": "Sld_WHITE",
  "SOLID-BLACK": "Sld_BLACK",
  "SOLID-BLUE": "Sld_BLUE"
})

for obj in objects:
    for slot in obj.material_slots:
        slot.material = bpy.data.materials[mat_dict[slot.material.name]]

Here is an example of the second approach using regex.

import re
import bpy

objects = bpy.context.selected_objects

mat_dict =  {
  "SOLID-WHITE": "Sld_WHITE",
  "SOLID-BLACK": "Sld_BLACK",
  "SOLID-BLUE": "Sld_BLUE"
}

pattern = re.compile(r"([A-Z\-]+)(?:\.\d+)?")
# matches any number of capital letters and dashes
# can be followed by a dot followed by any number of digits
# this pattern can match the following strings
# ["AAAAA", "----", "AA-AA.00005"]


for obj in objects:
    for slot in obj.material_slots:
        match = pattern.fullmatch(slot.material.name)
        if match:
            slot.material = bpy.data.materials[mat_dict[match.group(1)]]
        else:
            slot.material = bpy.data.materials[mat_dict[slot.material.name]]
steviestickman
  • 1,132
  • 6
  • 17
  • Thank you for offering these two different approaches! In #1 your smart dictionary approach, can you please explain what's happening with `UserDict` and also `def __missing__(self, key)`? Also, in #2 your regex approach, I get this error when I run it: `in AttributeError: 're.Pattern' object has no attribute 'full_match'` Any suggestions? – Mentalist Oct 28 '20 at 21:35
  • Also, when I run #1 it successfully replaces the black and white materials, but fails on blue: `line 12, in __missing__ KeyError: 'could not match SOLID-AQUA.002'` – Mentalist Oct 28 '20 at 21:39
  • the problem with the second approach was that i did not remember the name of `re.pattern.fullmatch` i have edit my answer to reflect that issue. i have also added a bit more description for the first approach. – steviestickman Oct 28 '20 at 21:49
  • `"SOLID-AQUA"` should be a key in mat_dict else it can't find `"SOLID-AQUA.002"` if every material uses the same pattern eg. `SOLID-YELLOW.005` becomes `Sld_YELLOW` then another approach is possible that does not use a dictionary at all. – steviestickman Oct 28 '20 at 21:50
  • Ah, sorry. There is an object in the scene with the material **SOLID-AQUA.002**, and since **SOLID-AQUA** had not yet been added to the dictionary, it could not be replaced. So it's okay that it didn't get matched because I anticipate there may be materials that aren't in the dictionary and don't need replacing, but did the failed match also cause **SOLID-BLUE.002** to not get replaced? (Thank you for explaining about `UserDict` and `__missing__`!) – Mentalist Oct 28 '20 at 22:20
1

I have no clue about Blender so I'm not sure if I'm getting the problem right, but how about the following?

mat_dict =  {
  "SOLID-WHITE": "Sld_WHITE",
  "SOLID-BLACK": "Sld_BLACK",
  "SOLID-BLUE": "Sld_BLUE"
}

def get_new_material(old_material):
    for k, v in mat_dict.items():
        # .split(".")[0] extracts the part to the left of the dot (if there is one)
        if old_material.split(".")[0] == k:
            return v
    return old_material

for obj in objects:
    for slot in obj.material_slots:
        new_material = get_new_material(slot.material.name)
        slot.material = bpy.data.materials[new_material]

Instead of the .split(".")[0] you could use or re.match by storing regexes as keys in your dictionary. As you noticed in the comment, startswith could match too much, and the same would be the case for fnmatch.

Examples of the above function in action:

In [3]: get_new_material("SOLID-WHITE.001")
Out[3]: 'Sld_WHITE'

In [4]: get_new_material("SOLID-WHITE")
Out[4]: 'Sld_WHITE'

In [5]: get_new_material("SOLID-BLACK")
Out[5]: 'Sld_BLACK'

In [6]: get_new_material("test")
Out[6]: 'test'
Czaporka
  • 2,190
  • 3
  • 10
  • 23
  • Thank you for contributing this! It works great except for one thing... If there is a color **SOLID-RED** and another color **SOLID-REDDISH_BROWN**, they will both be replaced with **Sld_RED** because `startswith` doesn't care what comes after. It's not checking for a *dot-number*, and will just take any ending. – Mentalist Oct 28 '20 at 22:08
  • 1
    Okay I have updated the answer and replaced the `startswith` with code that should extract the part to the left of the dot. It will also work if there is no dot. If your case is more complicated than this, i.e. the names can contain multiple dots, then I guess I would replace the keys in `mat_dict` with regexes, then in the function I'd replace the `if` with `if re.match(k, old_material):`. – Czaporka Oct 28 '20 at 22:50
  • It's almost perfect! The only problem is that even though it catches **SOLID-WHITE.001**, it doesn't catch **SOLID-WHITE**. It is expecting a trailing number on each material. I'm sorry if I didn't explain that clearly enough. The first copy of the material is index 0 and has no trailing numbers; the next is index 1 and ends in **.001** – Mentalist Oct 29 '20 at 00:44
  • Giving it another look now, it might be as simple as needing to add `else: return k` after the `if` conditional. But unfortunately I can't test it right this moment to see if my logic is correct. – Mentalist Oct 29 '20 at 01:09
  • The above function returns `Sld_WHITE` both for `SOLID-WHITE.001` and for `SOLID-WHITE`. Have you tested it? Adding an `else: return k` would mean that if there is no match, it would just return the first key from the dict regardless of the argument. – Czaporka Oct 29 '20 at 07:59
  • I've updated the answer with sample results of calling the function. Let me know if they look correct. – Czaporka Oct 29 '20 at 08:17
  • I'm sorry, I made a mistake when testing earlier due to a change I had made to my list at that time. User error on my part. Your code works perfectly! Answer accepted. And the `for k, v in mat_dict.items():` part is easy for a noob like me to understand and modify, which is empowering. I now understand how to get *keys* and *values* into variables. Thanks again! – Mentalist Oct 29 '20 at 13:22