> [!abstract] Summary
> **Houdini shelf script** — a PySide6 dialog that finds and replaces texture paths across all nodes (grouped by current path). Drop the script on a shelf and trigger it whenever you need to mass-retarget textures (server → local, version bumps, etc.).
---
- Script to put on shelf
```python
import hou
from collections import defaultdict
from PySide6 import QtCore, QtWidgets
class TextureSearchReplaceGroupedDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Grouped Texture Path Search / Replace")
self.setWindowFlag(QtCore.Qt.Window, True)
self.setModal(False)
self.resize(900, 560)
self.grouped_entries = {}
self.info_label = QtWidgets.QLabel("Select nodes, then click Refresh.")
self.path_list = QtWidgets.QListWidget()
self.path_list.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.search_edit = QtWidgets.QLineEdit()
self.replace_edit = QtWidgets.QLineEdit()
self.search_edit.setPlaceholderText("Search text")
self.replace_edit.setPlaceholderText("Replace text")
self.refresh_btn = QtWidgets.QPushButton("Refresh From Selection")
self.select_all_btn = QtWidgets.QPushButton("Select All")
self.apply_btn = QtWidgets.QPushButton("Apply Replace")
self.close_btn = QtWidgets.QPushButton("Close")
form = QtWidgets.QFormLayout()
form.addRow("Search", self.search_edit)
form.addRow("Replace", self.replace_edit)
button_row = QtWidgets.QHBoxLayout()
button_row.addWidget(self.refresh_btn)
button_row.addWidget(self.select_all_btn)
button_row.addStretch()
button_row.addWidget(self.apply_btn)
button_row.addWidget(self.close_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(self.info_label)
layout.addWidget(self.path_list)
layout.addLayout(form)
layout.addLayout(button_row)
self.refresh_btn.clicked.connect(self.refresh_from_selection)
self.select_all_btn.clicked.connect(self.path_list.selectAll)
self.apply_btn.clicked.connect(self.apply_replace)
self.close_btn.clicked.connect(self.close)
self.refresh_from_selection()
def is_string_parm(self, parm):
try:
return parm.parmTemplate().type() == hou.parmTemplateType.String
except Exception:
return False
def get_string_value(self, parm):
try:
return parm.unexpandedString()
except hou.OperationFailed:
try:
return parm.evalAsString()
except Exception:
return None
def looks_like_texture_parm(self, parm, value):
parm_name = parm.name().lower()
keywords = [
"file", "filename", "tex", "texture", "map",
"albedo", "diffuse", "rough", "normal", "bump",
"spec", "displace", "displacement", "opacity", "mask",
"emissive", "metal", "ior", "coat"
]
extensions = (
".tex", ".tx", ".rat", ".exr", ".png", ".jpg", ".jpeg",
".tif", ".tiff", ".bmp", ".hdr", ".tga"
)
value_l = value.lower()
if any(k in parm_name for k in keywords):
return True
if any(ext in value_l for ext in extensions):
return True
if "/" in value or "\\" in value:
return True
return False
def refresh_from_selection(self):
self.path_list.clear()
self.grouped_entries = {}
nodes = hou.selectedNodes()
if not nodes:
self.info_label.setText("No nodes selected.")
return
grouped = defaultdict(list)
for node in nodes:
for parm in node.parms():
if not self.is_string_parm(parm):
continue
value = self.get_string_value(parm)
if not value:
continue
if not self.looks_like_texture_parm(parm, value):
continue
grouped[value].append((node, parm))
self.grouped_entries = dict(grouped)
sorted_values = sorted(self.grouped_entries.keys(), key=lambda x: x.lower())
for value in sorted_values:
users = self.grouped_entries[value]
label = "[{} uses] {}".format(len(users), value)
item = QtWidgets.QListWidgetItem(label)
item.setData(QtCore.Qt.UserRole, value)
tooltip_lines = []
for node, parm in users[:30]:
tooltip_lines.append("{} | {}".format(node.path(), parm.name()))
if len(users) > 30:
tooltip_lines.append("... and {} more".format(len(users) - 30))
item.setToolTip("\n".join(tooltip_lines))
self.path_list.addItem(item)
total_links = sum(len(v) for v in self.grouped_entries.values())
self.info_label.setText(
"Found {} unique texture path(s) across {} parm link(s).".format(
len(self.grouped_entries), total_links
)
)
def apply_replace(self):
search_text = self.search_edit.text()
replace_text = self.replace_edit.text()
if not search_text:
hou.ui.setStatusMessage(
"Search text cannot be empty.",
severity=hou.severityType.Warning
)
return
selected_items = self.path_list.selectedItems()
if not selected_items:
hou.ui.setStatusMessage(
"Select at least one grouped texture path.",
severity=hou.severityType.Warning
)
return
changed = []
skipped = []
with hou.undos.group("Grouped Texture Path Search Replace"):
for item in selected_items:
original_value = item.data(QtCore.Qt.UserRole)
users = self.grouped_entries.get(original_value, [])
if search_text not in original_value:
continue
new_value = original_value.replace(search_text, replace_text)
if new_value == original_value:
continue
for node, parm in users:
current_value = self.get_string_value(parm)
if current_value != original_value:
skipped.append(
"{} | {} changed since refresh".format(node.path(), parm.name())
)
continue
try:
parm.set(new_value)
changed.append((node.path(), parm.name(), original_value, new_value))
except Exception as e:
skipped.append(
"{} | {} could not be set ({})".format(node.path(), parm.name(), e)
)
self.refresh_from_selection()
if not changed and not skipped:
hou.ui.setStatusMessage(
"No matching text found in selected grouped paths.",
severity=hou.severityType.Message
)
return
message = "Updated {} parm(s).".format(len(changed))
if skipped:
message += " Skipped {} parm(s).".format(len(skipped))
hou.ui.setStatusMessage(
message,
severity=hou.severityType.ImportantMessage
)
details = []
for node_path, parm_name, old_value, new_value in changed[:200]:
details.append(
"{} | {}\nOLD: {}\nNEW: {}\n".format(
node_path, parm_name, old_value, new_value
)
)
if len(changed) > 200:
details.append("... {} more updated parm(s)\n".format(len(changed) - 200))
for item in skipped[:200]:
details.append("SKIPPED: {}\n".format(item))
if len(skipped) > 200:
details.append("... {} more skipped parm(s)\n".format(len(skipped) - 200))
hou.ui.displayMessage(
message,
title="Grouped Texture Path Replace",
details="\n".join(details),
details_expanded=False
)
_dialog = None
def show_grouped_texture_search_replace_dialog():
global _dialog
try:
if _dialog is not None:
_dialog.close()
_dialog.deleteLater()
except Exception:
pass
_dialog = TextureSearchReplaceGroupedDialog(parent=hou.qt.mainWindow())
_dialog.show()
_dialog.raise_()
_dialog.activateWindow()
show_grouped_texture_search_replace_dialog()
```