> [!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() ```