Difference between revisions of "Houdini UI Customization"

From bernie's
Jump to navigation Jump to search
 
(10 intermediate revisions by the same user not shown)
Line 1: Line 1:
Various things I do to my Houdini
Various things I do to my Houdini


== Shelf Tools ==
Quick reminder to myself. When testing out things, call '''menurefresh''' in Hscript textport to refresh different menus (notably PARMmenu.xml)
==== Shelf Tools ====
I find it odd that Sidefx makes you go through shelf items for various UI functions that have nothing to do with the shelf, but that's the way the cookie crumbles. Also maybe I'm nostalgic but despite it's flaws the maya shelves were kinda easier to understand.
I find it odd that Sidefx makes you go through shelf items for various UI functions that have nothing to do with the shelf, but that's the way the cookie crumbles. Also maybe I'm nostalgic but despite it's flaws the maya shelves were kinda easier to understand.
As I understand it single shelf items in houdini can be stored in different files, which means if you fumble around you can easily have two items with the same name but saved in two different places (I think. I'm confused).
As I understand it single shelf items in houdini can be stored in different files, which means if you fumble around you can easily have two items with the same name but saved in two different places (I think. I'm confused).
Just make sure you keep an eyeball on where your shelf tool is stored (often in Default.shelf or some random production folder if you use Rez like us)


== Parameters as network view comments ==


=== Toggle Viewport Background Color ===
https://i.imgur.com/ZddFaRC.gif
 
This tool will show specific node parameter values as node comments. Good for quick overview without having to open the parameter spreadsheet.
I've bound it to F10 and use it often.
 
Each node type can have its own specific properties.
I've added a right click menu item so that I can automatically add the parameters to a config file in Houdini.
 
This is 95% chatGPT code and it's absolutely great at it.
 
In my <code>PARMmenu.xml</code>
<pre>
<scriptItem id="tool_attribute_as_comment">
    <modifyItem><insertAfter>motion_effects_menu</insertAfter></modifyItem>
    <label>Add Parameter as Attribute Comment</label>
    <scriptCode><![CDATA[import tool_attribute_as_comment;tool_attribute_as_comment.edit_attribute_config_rclick(kwargs)]]></scriptCode>
</scriptItem>
</pre>
 
In my shelf item
<pre>
import tool_attribute_as_comment
tool_attribute_as_comment.display_next_attribute_as_comment()
</pre>
 
In my <code>python3.11libs/tool_attribute_as_comment.py</code>
<pre>
import hou
import os
import json
 
# Path setup
prefs_dir = hou.getenv("HOUDINI_USER_PREF_DIR")
json_path = os.path.join(prefs_dir, "attribute_display_config.json")
 
# Persistent index store
if not hasattr(hou.session, "attr_display_index"):
    hou.session.attr_display_index = {}
 
# Ensure config file exists
def ensure_config_file():
    if not os.path.exists(json_path):
        with open(json_path, "w") as f:
            json.dump({}, f, indent=4)
 
# Helper: load config safely and normalize attribute entries
def load_config():
    ensure_config_file()
    try:
        with open(json_path, "r") as f:
            raw = json.load(f)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to read config:\n{e}")
        return {}
 
    # Normalize format: ensure each node-type maps to a list of dicts {name, label}
    normalized = {}
    for node_type, attrs in raw.items():
        if attrs is None:
            normalized[node_type] = []
            continue
 
        # If single string -> make list
        if isinstance(attrs, str):
            attrs = [attrs]
 
        if not isinstance(attrs, list):
            # unexpected type, skip defensively
            normalized[node_type] = []
            continue
 
        normalized_list = []
        for entry in attrs:
            if isinstance(entry, str):
                normalized_list.append({"name": entry, "label": entry})
            elif isinstance(entry, dict):
                name = entry.get("name") or entry.get("parm") or entry.get("param")
                label = entry.get("label") or name
                if name:
                    normalized_list.append({"name": name, "label": label})
                # if no name, ignore malformed entry
            else:
                # unknown entry type; ignore
                continue
 
        normalized[node_type] = normalized_list
 
    return normalized
 
# Helper: save config (assumes caller provides well-formed dict)
def save_config(config):
    try:
        # Save exactly what the caller provided. If you prefer a stable format,
        # you can re-serialize to the normalized format before saving.
        with open(json_path, "w") as f:
            json.dump(config, f, indent=4)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to save config:\n{e}")
 
# Main display function
def display_next_attribute_as_comment():
    attr_config = load_config()
    selected_nodes = hou.selectedNodes()
 
    if not selected_nodes:
        hou.ui.setStatusMessage("No nodes selected.")
        return
 
    for node in selected_nodes:
        node_type = node.type().name()
        node_path = node.path()
 
        attrs = attr_config.get(node_type)
        # attrs should already be normalized by load_config -> list of dicts
        if not attrs:
            node.setComment("No configured attributes in 'attribute_display_config.json'")
            node.setGenericFlag(hou.nodeFlag.DisplayComment, True)
            # ensure index is reset for this node type
            hou.session.attr_display_index[node_path] = 0
            continue
 
        # Get current index, default 0
        current_index = hou.session.attr_display_index.get(node_path, 0)
        total_attrs = len(attrs)
 
        # If we've reached or passed the end, show empty comment and reset
        if current_index >= total_attrs:
            node.setComment("")  # empty comment as requested
            node.setGenericFlag(hou.nodeFlag.DisplayComment, True)
            hou.session.attr_display_index[node_path] = 0
            continue
 
        # Get two attributes at a time
        end_index = current_index + 2
        selected_attrs = attrs[current_index:end_index]
        comment_lines = []
 
        for attr_entry in selected_attrs:
            # attr_entry is a dict with keys 'name' and 'label'
            attr_name = attr_entry.get("name")
            attr_label = attr_entry.get("label", attr_name)
 
            try:
                parm = node.parm(attr_name)
                if parm is not None:
                    # obtain a friendly label if none provided: try parm template label
                    if not attr_label:
                        try:
                            tmpl = parm.parmTemplate()
                            attr_label = tmpl.label() if tmpl is not None else attr_name
                        except Exception:
                            attr_label = attr_name
                    attr_value = parm.eval()
                    comment_lines.append(f"{attr_label}: {attr_value}")
                else:
                    comment_lines.append(f"{attr_label}: [Parm not found]")
            except Exception as e:
                comment_lines.append(f"{attr_label}: Error - {str(e)}")
 
        # Update the comment on the node
        node.setComment("\n".join(comment_lines))
        node.setGenericFlag(hou.nodeFlag.DisplayComment, True)
 
        # Store the updated index for next cycle (advance by 2)
        hou.session.attr_display_index[node_path] = end_index
 
def edit_attribute_config_rclick(kwargs):
    """
    Prepare arguments for the next function this is accessed using Houdini's right click menu on a parm.
    """
 
    action = "add"
    if kwargs['altclick'] == True:
        action = "remove"
 
    for parm in kwargs['parms']:
        type = parm.node().type().name()
        name = parm.name()
        label = parm.description()
        edit_attribute_config(type, name, attr_label=label, action=action)
 
# Helper: edit attribute config file (add/remove)
def edit_attribute_config(node_type, attr_name, attr_label=None, action="add"):
    """
    Edit attribute_display_config.json.
    action: "add" or "remove"
    Example:
        edit_attribute_config("geo", "shop_materialpath", "Material", "add")
        edit_attribute_config("geo", "shop_materialpath", action="remove")
    """
    # Load raw file (not normalized) so we preserve user's format as much as possible
    ensure_config_file()
    try:
        with open(json_path, "r") as f:
            raw = json.load(f)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to read config for editing:\n{e}")
        return
 
    # Ensure the node_type exists and is a list
    node_list = raw.get(node_type)
    if node_list is None:
        node_list = []
    elif isinstance(node_list, str):
        node_list = [node_list]
    elif not isinstance(node_list, list):
        # Unexpected; replace with empty list
        node_list = []
 
    if action == "add":
        # Avoid duplicates (check by name)
        names = set()
        for ent in node_list:
            if isinstance(ent, str):
                names.add(ent)
            elif isinstance(ent, dict):
                n = ent.get("name")
                if n:
                    names.add(n)
 
        if attr_name in names:
            hou.ui.setStatusMessage(f"{attr_name} already present for {node_type}")
            return
 
        # Add as dict so label is preserved
        entry = {"name": attr_name, "label": attr_label or attr_name}
        node_list.append(entry)
 
    elif action == "remove":
        new_list = []
        for ent in node_list:
            if isinstance(ent, str):
                if ent != attr_name:
                    new_list.append(ent)
            elif isinstance(ent, dict):
                if ent.get("name") != attr_name:
                    new_list.append(ent)
            else:
                # unknown type, keep it just in case
                new_list.append(ent)
        node_list = new_list
    else:
        hou.ui.setStatusMessage(f"Invalid action: {action}")
        return
 
    raw[node_type] = node_list
 
    try:
        with open(json_path, "w") as f:
            json.dump(raw, f, indent=4)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to write config:\n{e}")
        return
 
    hou.ui.setStatusMessage(f"Config updated for {node_type}: {action} {attr_name}")
 
# New: reset functions
def reset_all_indexes():
    """Reset index for all nodes: clears the session store."""
    hou.session.attr_display_index = {}
    hou.ui.setStatusMessage("Attribute display indexes reset for all nodes.")
 
def reset_selected_nodes_indexes():
    """Reset index for currently selected nodes only."""
    sel = hou.selectedNodes()
    if not sel:
        hou.ui.setStatusMessage("No nodes selected to reset.")
        return
    for n in sel:
        path = n.path()
        if path in hou.session.attr_display_index:
            del hou.session.attr_display_index[path]
    hou.ui.setStatusMessage("Attribute display indexes reset for selected nodes.")
 
# Run it
#display_next_attribute_as_comment()
</pre>
 
 
== Selection to bounding box to delete ==
 
Very often I manually select prims to delete a few local faces, then change something upstream, then my blast is all mangled. This will create a bounding box of the currently selected geo, group them with it and then blast the group. It's never perfect because of how bounding boxes operate (points/prims/othogonal to world axes), but it's very useful in my daily work.
 
Also Gemini did 90% of the coding and actually does error catching. It's a lot better than me :0
 
https://i.imgur.com/xMUPRr3.gif
 
<pre>
import hou, toolutils
 
def create_group_from_bbox():
    """
    Creates a 'group' node and sets its bounding region parameters
    based on the bounding box of the current geo selection
    """
    try:
        selected_nodes = hou.selectedNodes()
        geo = toolutils.sceneViewer().selectGeometry()
 
        # Check if exactly one node is selected.
        if len(selected_nodes) != 1:
            print("Please select a single geometry node to get the bounding box from.")
            return
 
        selected_node = selected_nodes[0]
       
        # Check if the selected node is a geometry node (SOP).
        if selected_node.type().category() != hou.sopNodeTypeCategory():
            print("The selected node is not a geometry node (SOP). Please select a valid node.")
            return
 
        hou.clearAllSelected()
 
        bbox = geo.boundingBox()
        parent_node = selected_node.parent()
        selected_node.setSelected(0)
        group_node = parent_node.createNode("groupcreate", "bbox_group")
       
        pos = [selected_node.position()[0],selected_node.position()[1]-1]
        group_node.setPosition(pos)
        group_node.setInput(0, selected_node)
        group_node.parm("groupbounding").set(1)
        group_node.parmTuple("size").set(bbox.sizevec())
        group_node.parmTuple("t").set(bbox.center())
       
        blast = parent_node.createNode("blast", "bbox_blast")
        blast.setInput(0, group_node)
        pos[1] = pos[1] - 1
        blast.setPosition(pos)
        blast.parm('group').set(group_node.parm("groupname").eval())
 
        group_node.setSelected(1)
        blast.setDisplayFlag(1)
        blast.setSelected(1)
        blast.setRenderFlag(1)
        group_node.setCurrent(True, clear_all_selected=True)
       
    except hou.Error as e:
        print(f"An error occurred: {e}")
 
create_group_from_bbox()
</pre>
 
== Toggle Viewport Background Color ==
Bound it to F6
Bound it to F6
<pre>
<pre>
Line 32: Line 378:
hou.ui.setStatusMessage("Cycled background to %s" % bgs[bg].upper() )
hou.ui.setStatusMessage("Cycled background to %s" % bgs[bg].upper() )
</pre>
</pre>
=== Toggle Auto / Manual scene update ===
== Toggle Auto / Manual scene update ==
Bound to F11, I use it every day.
Bound to F11, I use it every day.
<pre>
<pre>
Line 42: Line 388:
     hou.setUpdateMode(hou.updateMode.AutoUpdate)
     hou.setUpdateMode(hou.updateMode.AutoUpdate)
</pre>
</pre>
=== Copy auto Paste-Merge ===
== Auto create a wrangle node ==
I'm pretty sure this exists already but why not reinvent the wheel. Creates an attr wrangle and keeps inputs/outputs, with the code box already in focus. I only use the 'P' parameter viewer (the one that pops up in your network view) so other desktops might not work.
 
bound to ctrl-shift-w
<pre>
 
# TODO  fix some weird inputs/outputs configurations
 
import hou
sn = hou.selectedNodes()
if sn:
    w = sn[-1].parent().createNode('attribwrangle')
    outputs = sn[-1].outputs()
   
    w.setInput(0,sn[-1])
    w.setCurrent(True, clear_all_selected=True)
    w.setSelected(True,clear_all_selected=True, show_asset_if_selected=True)
    w.setDisplayFlag(True)
    sn[-1].setDisplayFlag(False)
    if outputs:
        for output in outputs:
            output.setInput(0,w)
else:
    #should not error out if it's launched in a shelf above a network plane
    w = hou.ui.curDesktop().paneTabUnderCursor().pwd().createNode('attribwrangle')   
 
w.moveToGoodPosition(relative_to_inputs=True, move_inputs=False, move_outputs=True, move_unconnected=False)   
 
networkview = hou.ui.curDesktop().paneTabUnderCursor()
networkview.setCurrentNode(w,True)
networkview.parmMoveFocusTo('snippet')
</pre>
 
== Copy auto Paste-Merge ==
Copy from https://berniebernie.fr/wiki/Houdini_Python#Paste_clipboard_nodes_to_object_merges
Copy from https://berniebernie.fr/wiki/Houdini_Python#Paste_clipboard_nodes_to_object_merges


Line 78: Line 457:
</pre>
</pre>


===Screenshot to background image===
==Screenshot to background image==


https://i.imgur.com/kuc1PnO.gif
https://i.imgur.com/kuc1PnO.gif


Customize your capture command line, no/little error checking 'cause I'm not a professional programmer.
Customize your capture command line, no error checking 'cause I'm not a professional programmer, aka it works on my machine.
 
'''''19.5 edit''''': looks like the update broke some things ? Will look into it when I have time but it should be an easy fix.


 
Should work with windows (hardcoded path to [https://www.donationcoder.com/software/mouser/popular-apps/minicap minicap] ) and linux centos with xfce-screenshoter
TODO: make it work under linux/xfce:
import subprocess
a = subprocess.run('mv "$(xfce4-screenshooter -ro ls)" -v ~/Downloads', capture_output=True, shell=True)
print(a.stdout.decode())


<pre>
<pre>
#python 3.5+
import hou
import hou
import os
import os
Line 98: Line 472:
import nodegraphutils as utils
import nodegraphutils as utils
from time import gmtime, strftime
from time import gmtime, strftime
from os.path import expanduser
home = expanduser("~")
from pathlib import Path
exe = home+"\Downloads\exe\MiniCap.exe"


widthRatio = 4                      # change to make screenshot bigger or smaller, this is ~x4 node width
widthRatio = 4                      # change to make screenshot bigger or smaller, this is ~x4 node width


def takeScreenShot(savePath):
def takeScreenShot(savePath):
     '''change to your preferred capture app /!\ no error checking for now '''
     '''change to your preferred capture app /!\ no error checking for now '''
     subprocess.check_call([r"C:\Users\me\Downloads\exe\MiniCap.exe","-captureregselect","-save",savePath,"-exit"])
     subprocess.check_call([exe,"-captureregselect","-save",savePath,"-exit"])
   
    #following is older code for linux i'll update it if I linux again
    '''
    Path(os.path.dirname(Path(savePath))).mkdir(parents=True, exist_ok=True)
    if platform == "linux" or platform == "linux2":
        savepath = os.path.dirname(Path(savePath)) #screenshot dir   
        screenshotPNG = subprocess.check_output('mv "$(xfce4-screenshooter -ro ls)" -v '+savepath,shell=True)
        screenshotPNG = screenshotPNG.decode().strip().split('\n')[0].split(' -> ')[1].replace("'","") #helluva nasty oneliner
        os.chdir(savepath)
        os.rename(os.path.basename(screenshotPNG),os.path.basename(savePath))
       
        # linux
    elif platform == "darwin":
        exit
    elif platform == "win32":
        subprocess.check_call([r"C:\Users\bernie\Documents\houdini18.0\config\exe\MiniCap.exe","-captureregselect","-save",savePath,"-exit"])
    '''


def removeBackgroundImage(**kwargs):
def removeBackgroundImage(**kwargs):
Line 140: Line 536:
hippath = str(hou.getenv('HIP')) + '/screenshots'
hippath = str(hou.getenv('HIP')) + '/screenshots'
screenshotName = hipname + '.' + timestamp + '.png'
screenshotName = hipname + '.' + timestamp + '.png'
pythonpath = hippath+'/'+screenshotName
systempath = hippath + '\\' + screenshotName
systempath = hippath + '\\' + screenshotName
systempath = Path(systempath)
print(systempath)
houdinipath = '$HIP/screenshots/'+screenshotName
houdinipath = '$HIP/screenshots/'+screenshotName
try:
    os.makedirs(os.path.dirname(systempath))
except OSError:
    if not os.path.isdir(os.path.dirname(systempath)):
        raise


#take screenshot with capture region
#take screenshot with capture region
Line 177: Line 572:
nullNode.move([lastSel.size()[0]*2,-lastSel.size()[1]*2])
nullNode.move([lastSel.size()[0]*2,-lastSel.size()[1]*2])


rez = hou.imageResolution(systempath)
rez = hou.imageResolution(pythonpath)
ratio = 1.0*rez[1]/rez[0]
ratio = 1.0*rez[1]/rez[0]
rect = hou.BoundingRect(0,-lastSel.size()[1]*1.1,widthRatio,-widthRatio*ratio-lastSel.size()[1]*1.1)
rect = hou.BoundingRect(0,-lastSel.size()[1]*1.1,widthRatio,-widthRatio*ratio-lastSel.size()[1]*1.1)

Latest revision as of 15:26, 12 November 2025

Various things I do to my Houdini

Quick reminder to myself. When testing out things, call menurefresh in Hscript textport to refresh different menus (notably PARMmenu.xml)

Shelf Tools

I find it odd that Sidefx makes you go through shelf items for various UI functions that have nothing to do with the shelf, but that's the way the cookie crumbles. Also maybe I'm nostalgic but despite it's flaws the maya shelves were kinda easier to understand. As I understand it single shelf items in houdini can be stored in different files, which means if you fumble around you can easily have two items with the same name but saved in two different places (I think. I'm confused). Just make sure you keep an eyeball on where your shelf tool is stored (often in Default.shelf or some random production folder if you use Rez like us)

Parameters as network view comments

ZddFaRC.gif

This tool will show specific node parameter values as node comments. Good for quick overview without having to open the parameter spreadsheet. I've bound it to F10 and use it often.

Each node type can have its own specific properties. I've added a right click menu item so that I can automatically add the parameters to a config file in Houdini.

This is 95% chatGPT code and it's absolutely great at it.

In my PARMmenu.xml

<scriptItem id="tool_attribute_as_comment">
    <modifyItem><insertAfter>motion_effects_menu</insertAfter></modifyItem>
    <label>Add Parameter as Attribute Comment</label>
    <scriptCode><![CDATA[import tool_attribute_as_comment;tool_attribute_as_comment.edit_attribute_config_rclick(kwargs)]]></scriptCode>
</scriptItem>

In my shelf item

import tool_attribute_as_comment
tool_attribute_as_comment.display_next_attribute_as_comment()

In my python3.11libs/tool_attribute_as_comment.py

import hou
import os
import json

# Path setup
prefs_dir = hou.getenv("HOUDINI_USER_PREF_DIR")
json_path = os.path.join(prefs_dir, "attribute_display_config.json")

# Persistent index store
if not hasattr(hou.session, "attr_display_index"):
    hou.session.attr_display_index = {}

# Ensure config file exists
def ensure_config_file():
    if not os.path.exists(json_path):
        with open(json_path, "w") as f:
            json.dump({}, f, indent=4)

# Helper: load config safely and normalize attribute entries
def load_config():
    ensure_config_file()
    try:
        with open(json_path, "r") as f:
            raw = json.load(f)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to read config:\n{e}")
        return {}

    # Normalize format: ensure each node-type maps to a list of dicts {name, label}
    normalized = {}
    for node_type, attrs in raw.items():
        if attrs is None:
            normalized[node_type] = []
            continue

        # If single string -> make list
        if isinstance(attrs, str):
            attrs = [attrs]

        if not isinstance(attrs, list):
            # unexpected type, skip defensively
            normalized[node_type] = []
            continue

        normalized_list = []
        for entry in attrs:
            if isinstance(entry, str):
                normalized_list.append({"name": entry, "label": entry})
            elif isinstance(entry, dict):
                name = entry.get("name") or entry.get("parm") or entry.get("param")
                label = entry.get("label") or name
                if name:
                    normalized_list.append({"name": name, "label": label})
                # if no name, ignore malformed entry
            else:
                # unknown entry type; ignore
                continue

        normalized[node_type] = normalized_list

    return normalized

# Helper: save config (assumes caller provides well-formed dict)
def save_config(config):
    try:
        # Save exactly what the caller provided. If you prefer a stable format,
        # you can re-serialize to the normalized format before saving.
        with open(json_path, "w") as f:
            json.dump(config, f, indent=4)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to save config:\n{e}")

# Main display function
def display_next_attribute_as_comment():
    attr_config = load_config()
    selected_nodes = hou.selectedNodes()

    if not selected_nodes:
        hou.ui.setStatusMessage("No nodes selected.")
        return

    for node in selected_nodes:
        node_type = node.type().name()
        node_path = node.path()

        attrs = attr_config.get(node_type)
        # attrs should already be normalized by load_config -> list of dicts
        if not attrs:
            node.setComment("No configured attributes in 'attribute_display_config.json'")
            node.setGenericFlag(hou.nodeFlag.DisplayComment, True)
            # ensure index is reset for this node type
            hou.session.attr_display_index[node_path] = 0
            continue

        # Get current index, default 0
        current_index = hou.session.attr_display_index.get(node_path, 0)
        total_attrs = len(attrs)

        # If we've reached or passed the end, show empty comment and reset
        if current_index >= total_attrs:
            node.setComment("")  # empty comment as requested
            node.setGenericFlag(hou.nodeFlag.DisplayComment, True)
            hou.session.attr_display_index[node_path] = 0
            continue

        # Get two attributes at a time
        end_index = current_index + 2
        selected_attrs = attrs[current_index:end_index]
        comment_lines = []

        for attr_entry in selected_attrs:
            # attr_entry is a dict with keys 'name' and 'label'
            attr_name = attr_entry.get("name")
            attr_label = attr_entry.get("label", attr_name)

            try:
                parm = node.parm(attr_name)
                if parm is not None:
                    # obtain a friendly label if none provided: try parm template label
                    if not attr_label:
                        try:
                            tmpl = parm.parmTemplate()
                            attr_label = tmpl.label() if tmpl is not None else attr_name
                        except Exception:
                            attr_label = attr_name
                    attr_value = parm.eval()
                    comment_lines.append(f"{attr_label}: {attr_value}")
                else:
                    comment_lines.append(f"{attr_label}: [Parm not found]")
            except Exception as e:
                comment_lines.append(f"{attr_label}: Error - {str(e)}")

        # Update the comment on the node
        node.setComment("\n".join(comment_lines))
        node.setGenericFlag(hou.nodeFlag.DisplayComment, True)

        # Store the updated index for next cycle (advance by 2)
        hou.session.attr_display_index[node_path] = end_index

def edit_attribute_config_rclick(kwargs):
    """
    Prepare arguments for the next function this is accessed using Houdini's right click menu on a parm.
    """

    action = "add"
    if kwargs['altclick'] == True:
        action = "remove"

    for parm in kwargs['parms']:
        type = parm.node().type().name()
        name = parm.name()
        label = parm.description()
        edit_attribute_config(type, name, attr_label=label, action=action)

# Helper: edit attribute config file (add/remove)
def edit_attribute_config(node_type, attr_name, attr_label=None, action="add"):
    """
    Edit attribute_display_config.json.
    action: "add" or "remove"
    Example:
        edit_attribute_config("geo", "shop_materialpath", "Material", "add")
        edit_attribute_config("geo", "shop_materialpath", action="remove")
    """
    # Load raw file (not normalized) so we preserve user's format as much as possible
    ensure_config_file()
    try:
        with open(json_path, "r") as f:
            raw = json.load(f)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to read config for editing:\n{e}")
        return

    # Ensure the node_type exists and is a list
    node_list = raw.get(node_type)
    if node_list is None:
        node_list = []
    elif isinstance(node_list, str):
        node_list = [node_list]
    elif not isinstance(node_list, list):
        # Unexpected; replace with empty list
        node_list = []

    if action == "add":
        # Avoid duplicates (check by name)
        names = set()
        for ent in node_list:
            if isinstance(ent, str):
                names.add(ent)
            elif isinstance(ent, dict):
                n = ent.get("name")
                if n:
                    names.add(n)

        if attr_name in names:
            hou.ui.setStatusMessage(f"{attr_name} already present for {node_type}")
            return

        # Add as dict so label is preserved
        entry = {"name": attr_name, "label": attr_label or attr_name}
        node_list.append(entry)

    elif action == "remove":
        new_list = []
        for ent in node_list:
            if isinstance(ent, str):
                if ent != attr_name:
                    new_list.append(ent)
            elif isinstance(ent, dict):
                if ent.get("name") != attr_name:
                    new_list.append(ent)
            else:
                # unknown type, keep it just in case
                new_list.append(ent)
        node_list = new_list
    else:
        hou.ui.setStatusMessage(f"Invalid action: {action}")
        return

    raw[node_type] = node_list

    try:
        with open(json_path, "w") as f:
            json.dump(raw, f, indent=4)
    except Exception as e:
        hou.ui.setStatusMessage(f"Failed to write config:\n{e}")
        return

    hou.ui.setStatusMessage(f"Config updated for {node_type}: {action} {attr_name}")

# New: reset functions
def reset_all_indexes():
    """Reset index for all nodes: clears the session store."""
    hou.session.attr_display_index = {}
    hou.ui.setStatusMessage("Attribute display indexes reset for all nodes.")

def reset_selected_nodes_indexes():
    """Reset index for currently selected nodes only."""
    sel = hou.selectedNodes()
    if not sel:
        hou.ui.setStatusMessage("No nodes selected to reset.")
        return
    for n in sel:
        path = n.path()
        if path in hou.session.attr_display_index:
            del hou.session.attr_display_index[path]
    hou.ui.setStatusMessage("Attribute display indexes reset for selected nodes.")

# Run it
#display_next_attribute_as_comment()


Selection to bounding box to delete

Very often I manually select prims to delete a few local faces, then change something upstream, then my blast is all mangled. This will create a bounding box of the currently selected geo, group them with it and then blast the group. It's never perfect because of how bounding boxes operate (points/prims/othogonal to world axes), but it's very useful in my daily work.

Also Gemini did 90% of the coding and actually does error catching. It's a lot better than me :0

xMUPRr3.gif

import hou, toolutils

def create_group_from_bbox():
    """
    Creates a 'group' node and sets its bounding region parameters
    based on the bounding box of the current geo selection
    """
    try:
        selected_nodes = hou.selectedNodes()
        geo = toolutils.sceneViewer().selectGeometry()

        # Check if exactly one node is selected.
        if len(selected_nodes) != 1:
            print("Please select a single geometry node to get the bounding box from.")
            return

        selected_node = selected_nodes[0]
        
        # Check if the selected node is a geometry node (SOP).
        if selected_node.type().category() != hou.sopNodeTypeCategory():
            print("The selected node is not a geometry node (SOP). Please select a valid node.")
            return

        hou.clearAllSelected()

        bbox = geo.boundingBox()
        parent_node = selected_node.parent()
        selected_node.setSelected(0)
        group_node = parent_node.createNode("groupcreate", "bbox_group")
        
        pos = [selected_node.position()[0],selected_node.position()[1]-1]
        group_node.setPosition(pos)
        group_node.setInput(0, selected_node)
        group_node.parm("groupbounding").set(1)
        group_node.parmTuple("size").set(bbox.sizevec())
        group_node.parmTuple("t").set(bbox.center())
        
        blast = parent_node.createNode("blast", "bbox_blast")
        blast.setInput(0, group_node)
        pos[1] = pos[1] - 1
        blast.setPosition(pos)
        blast.parm('group').set(group_node.parm("groupname").eval())

        group_node.setSelected(1)
        blast.setDisplayFlag(1)
        blast.setSelected(1)
        blast.setRenderFlag(1)
        group_node.setCurrent(True, clear_all_selected=True)
        
    except hou.Error as e:
        print(f"An error occurred: {e}")

create_group_from_bbox()

Toggle Viewport Background Color

Bound it to F6

import sys
import toolutils

bg = None

try:
    # cycle next bg
    if kwargs['ctrlclick']: raise
    bgs = hou.session.bg[:]
    bgs = bgs[1:]+bgs[:1]
    if kwargs['shiftclick']: bgs = ['bw', 'light', 'wb']
    bg = bgs[0]
    hou.session.bg = bgs
except:
    # set up default bg vars
    hou.session.bg = ['wb', 'bw', 'light']
    bg = hou.session.bg[0]

bgs = { 'wb':'dark', 'bw':'grey', 'light':'light' }

hou.hscript("viewdisplay -B %s *" % bg)
hou.ui.setStatusMessage("Cycled background to %s" % bgs[bg].upper() )

Toggle Auto / Manual scene update

Bound to F11, I use it every day.

import hou
mode = hou.updateModeSetting().name()
if mode == 'AutoUpdate':
    hou.setUpdateMode(hou.updateMode.Manual)
if mode == 'Manual':
    hou.setUpdateMode(hou.updateMode.AutoUpdate)

Auto create a wrangle node

I'm pretty sure this exists already but why not reinvent the wheel. Creates an attr wrangle and keeps inputs/outputs, with the code box already in focus. I only use the 'P' parameter viewer (the one that pops up in your network view) so other desktops might not work.

bound to ctrl-shift-w


# TODO  fix some weird inputs/outputs configurations

import hou
sn = hou.selectedNodes()
if sn:
    w = sn[-1].parent().createNode('attribwrangle')
    outputs = sn[-1].outputs()
    
    w.setInput(0,sn[-1])
    w.setCurrent(True, clear_all_selected=True)
    w.setSelected(True,clear_all_selected=True, show_asset_if_selected=True)
    w.setDisplayFlag(True)
    sn[-1].setDisplayFlag(False)
    if outputs:
        for output in outputs:
            output.setInput(0,w)
else:
    #should not error out if it's launched in a shelf above a network plane
    w = hou.ui.curDesktop().paneTabUnderCursor().pwd().createNode('attribwrangle')    

w.moveToGoodPosition(relative_to_inputs=True, move_inputs=False, move_outputs=True, move_unconnected=False)    

networkview = hou.ui.curDesktop().paneTabUnderCursor()
networkview.setCurrentNode(w,True)
networkview.parmMoveFocusTo('snippet')

Copy auto Paste-Merge

Copy from https://berniebernie.fr/wiki/Houdini_Python#Paste_clipboard_nodes_to_object_merges

Allow to copy nodes elsewhere with an object merge. Bound it to alt-v (so ctrl-c, alt-v in another place)

M7N0Stq.gif

# this snippet will paste nodes in clipboard to object merges
# use it with a shortcut (I overrode 'alt-v' in network pane context)

import hou

network = hou.ui.curDesktop().paneTabUnderCursor()
networkpath = network.pwd().path()
pos = network.cursorPosition()

clipboard = hou.ui.getTextFromClipboard()

n = 0

if clipboard:
    list = clipboard.split()
    for item in list:
        if hou.node(item) != None:
            merge = hou.node(networkpath).createNode('object_merge','merge_'+item.split('/')[-1])
            merge.parm('objpath1').set(str(item))
            merge.setPosition(pos)
            merge.move([n*2,0])
            if n == 0:
                merge.setSelected(True,True)
            else:
                merge.setSelected(True,False)
            n = n + 1

Screenshot to background image

kuc1PnO.gif

Customize your capture command line, no error checking 'cause I'm not a professional programmer, aka it works on my machine.

Should work with windows (hardcoded path to minicap ) and linux centos with xfce-screenshoter

#python 3.5+
import hou
import os
import subprocess
import nodegraphutils as utils
from time import gmtime, strftime
from os.path import expanduser
home = expanduser("~")
from pathlib import Path

exe = home+"\Downloads\exe\MiniCap.exe"

widthRatio = 4                      # change to make screenshot bigger or smaller, this is ~x4 node width

def takeScreenShot(savePath):
    '''change to your preferred capture app /!\ no error checking for now '''
    subprocess.check_call([exe,"-captureregselect","-save",savePath,"-exit"])
    
    #following is older code for linux i'll update it if I linux again
    '''
    Path(os.path.dirname(Path(savePath))).mkdir(parents=True, exist_ok=True)
    if platform == "linux" or platform == "linux2":
        savepath = os.path.dirname(Path(savePath)) #screenshot dir    
        screenshotPNG = subprocess.check_output('mv "$(xfce4-screenshooter -ro ls)" -v '+savepath,shell=True)
        screenshotPNG = screenshotPNG.decode().strip().split('\n')[0].split(' -> ')[1].replace("'","") #helluva nasty oneliner
        os.chdir(savepath)
        os.rename(os.path.basename(screenshotPNG),os.path.basename(savePath))
        
        # linux
    elif platform == "darwin":
        exit
    elif platform == "win32":
        subprocess.check_call([r"C:\Users\bernie\Documents\houdini18.0\config\exe\MiniCap.exe","-captureregselect","-save",savePath,"-exit"])
    '''

def removeBackgroundImage(**kwargs):
    ''' erases bg image from tuples of backgroundImages() if it can find it, updates bg '''
    deletingNode = [x[1] for x in  kwargs.items()][0]
    image = deletingNode.parm('houdinipath').eval()
    editor = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
    backgroundImagesDic = editor.backgroundImages()
    backgroundImagesDic = tuple(x for x in backgroundImagesDic if x.path() != image)
    editor.setBackgroundImages(backgroundImagesDic)
    utils.saveBackgroundImages(editor.pwd(), backgroundImagesDic)

def changeBackgroundImageBrightness(event_type,**kwargs):
    ''' changes brightness/visibility if template or bypass flags are checked -- its poorly written/thought but i was tired'''
    nullNode = [x[1] for x in  kwargs.items()][0]
    image = nullNode.parm('houdinipath').eval()
    brightness = 1.0
    if nullNode.isBypassed():
        brightness = 0.0
    elif nullNode.isTemplateFlagSet():
        brightness = 0.5
    editor = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
    backgroundImagesDic = editor.backgroundImages()
    i = 0
    for item in backgroundImagesDic:
        if item.path() == image:
            backgroundImagesDic[i].setBrightness(brightness)
            break
        i = i + 1
    editor.setBackgroundImages(backgroundImagesDic)
    utils.saveBackgroundImages(editor.pwd(), backgroundImagesDic)
    
#generate unique(ish) path for screenshot
timestamp = strftime('%Y%m%d_%H%M%S', gmtime())
hipname = str(hou.getenv('HIPNAME'))
hippath = str(hou.getenv('HIP')) + '/screenshots'
screenshotName = hipname + '.' + timestamp + '.png'
pythonpath = hippath+'/'+screenshotName
systempath = hippath + '\\' + screenshotName
systempath = Path(systempath)

print(systempath)

houdinipath = '$HIP/screenshots/'+screenshotName

#take screenshot with capture region
takeScreenShot(systempath)


#set up background image plane
editor = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
image = hou.NetworkImage()
image.setPath(houdinipath)
sel = hou.selectedNodes()
nullNode = ''

if sel:
    lastSel = sel[-1]
    nullNode = lastSel.parent().createNode('null','screenshot')
    if lastSel.outputConnections():
        nullNode.setInput(0,lastSel)                   

else:
    nullNode = editor.pwd().createNode('null','screenshot') 
    nullNode.moveToGoodPosition()
    lastSel = nullNode

#configure image plane placement
nullNode.setUserData('nodeshape','task')
nullNode.setPosition(lastSel.position())
nullNode.setColor(hou.Color(.3,.3,.3))
nullNode.move([lastSel.size()[0]*2,-lastSel.size()[1]*2])

rez = hou.imageResolution(pythonpath)
ratio = 1.0*rez[1]/rez[0]
rect = hou.BoundingRect(0,-lastSel.size()[1]*1.1,widthRatio,-widthRatio*ratio-lastSel.size()[1]*1.1)
image.setRelativeToPath(nullNode.path())
image.setRect(rect)

#following is adding a spare parm with image path to be able to know which node corresponds to which background image
#could have used a user attribute or relativeToPath() and smarter logic but it works and it helps me visualize filepath

hou_parm_template_group = hou.ParmTemplateGroup()
hou_parm_template = hou.LabelParmTemplate("houdinipath", "Label", column_labels=(['\\'+houdinipath]))
hou_parm_template.hideLabel(True)
hou_parm_template_group.append(hou_parm_template)
nullNode.setParmTemplateGroup(hou_parm_template_group)


#attach a function that deletes the background image plane if the corresponding node is deleted (faster than doing it by hand)
nullNode.addEventCallback((hou.nodeEventType.BeingDeleted,), removeBackgroundImage)

#attach a function to change visibility or opacity if corresponding node flags are changed
nullNode.addEventCallback((hou.nodeEventType.FlagChanged,), changeBackgroundImageBrightness)

#add image to network background
backgroundImagesDic = editor.backgroundImages()
backgroundImagesDic = backgroundImagesDic + (image,)
editor.setBackgroundImages(backgroundImagesDic)
utils.saveBackgroundImages(editor.pwd(), backgroundImagesDic)

Other UI

Previews

Detach parameter window à la maya

3txLohO.gif

Auto add frame offset parm

ZXNrwvC.gif

Userdocs XMLs

This is for right click context menus. Use the hscript 'menurefresh' when developping so as not to relaunch H each time (like a reload(module) in python).

PARMmenu.xml

<?xml version="1.0" encoding="UTF-8"?>
<menuDocument>
	<menu>
		<subMenu id="bernie_rclick">
		<label>Bernie's</label>
			<modifyItem><insertAfter>motion_effects_menu</insertAfter></modifyItem>
			<scriptItem id="bernie_set_frameoffset_parm">
				<label>Add a frame offset parm to $F#</label>
				<context>
					<expression>
						import bernie_tools
						return bernie_tools.validate_sequence_parm(kwargs)
					</expression>
				</context>
				<scriptCode><![CDATA[import bernie_tools;reload(bernie_tools);bernie_tools.add_sequence_offset_spareparm(kwargs)]]></scriptCode>
			</scriptItem>
			<scriptItem id="bernie_set_frameoffset_parm">
				<label>Browse to...</label>
				<context>
					<expression>
						import bernie_tools
						reload(bernie_tools)
						return bernie_tools.validate_uri(kwargs)
					</expression>
				</context>
				<scriptCode><![CDATA[import bernie_tools;reload(bernie_tools);bernie_tools.browse_to(kwargs)]]></scriptCode>
			</scriptItem>
			<scriptItem id="bernie_open_parm_spreadsheet">
				<label>Open/Add in Parameter Spreadsheet</label>
				<scriptCode><![CDATA[import bernie_tools;reload(bernie_tools);bernie_tools.open_parm_spreadsheet(kwargs)]]></scriptCode>
			</scriptItem>
		</subMenu>
	</menu>
</menuDocument>

ParmGearMenu.xml

<?xml version="1.0" encoding="UTF-8"?>

<!--
 (long sidefx text)
-->

<menuDocument>
    <!-- menuDocument can only contain 1 menu element, whose id is 
         implicitly "root_menu"
      -->
    <menu>
        <scriptItem id="detach_parameter_window">
            <label>Detach Parameter Window...</label>
            <scriptCode><![CDATA[import bernie_tools;reload(bernie_tools);bernie_tools.detach_parameter_window(kwargs)]]></scriptCode>
        </scriptItem>
    </menu>
</menuDocument>

bernie_tools.py

py3

Some updates

import hou
import traceback
import re
import os


def return_first_parm(allKwargs):
	'''given a right click context with kwargs, return what we want, the parameter -- which can be locked or not'''
	parm = lockedparm = normalparm = False
	try:
		lockedparm = allKwargs['locked_parms'][0]
	except:
		pass
	try:
		normalparm = allKwargs['parms'][0]
	except:
		pass
	if lockedparm:
		parm = lockedparm
	elif normalparm:
		parm = normalparm
	else:
		print('bug')
	return parm

def validate_sequence_parm(kwargs):
	'''Checks if the first parm contains a value with $F'''
	parm = return_first_parm(kwargs)
	returnvalue = False
	try:
		returnvalue = '$F' in parm.rawValue() 
	except:
		print("ERROR: %s" % traceback.format_exc())
	return returnvalue
	
def validate_uri(kwargs):
	'''Checks if the given argument, or its parent or grandparent is either a file or folder'''
	parm = return_first_parm(kwargs)
	parm = parm.eval()
	parm = str(parm)
	returnvalue = False
	if os.path.isdir(parm) or os.path.isdir(os.path.dirname(parm)) or os.path.isdir(os.path.dirname(os.path.dirname(parm))):
		returnvalue = True
	return returnvalue

def browse_to(kwargs):
	'''opens a browser to this given path (if a file), or its parent if it doesn't exist'''
	parm = return_first_parm(kwargs)
	path = os.path.dirname(parm.eval())
	dir = ''
	if os.path.isdir(path):
		dir = path
	elif os.path.isdir(os.path.dirname(path)):
		dir = os.path.dirname(path)
	else:
		print('No directory found')
	if dir != '':
		os.startfile(os.path.realpath(dir))

def add_sequence_offset_spareparm(kwargs):
	'''Adds an int spare parm + file parm below a parm that has a file sequence expression of type '$F#' using hscript's padzero'''
	parm = return_first_parm(kwargs)
	path = os.path.dirname(parm.eval())
	parmval = parm.rawValue()
	matchObj = re.match( r'^(.*)\$F(\d*)(.*)', parmval, re.M|re.I)
	if matchObj:
		n = parm.node()
		parmGrp = n.parmTemplateGroup()
		existing_parm = parmGrp.find(parm.name())
		label = existing_parm.label()
		name = existing_parm.name()
		#create an offset parm with a similar name. No error checking!

		fileParmTemplate = hou.StringParmTemplate(name+'_file',label+'_file', 1, default_value=([parmval]),string_type=hou.stringParmType.FileReference)
		offsetParmTemplate = hou.IntParmTemplate(name+'_offset',label+'_offset', 1, default_expression=(["$F + 0"]))
		parmGrp.insertAfter(existing_parm, fileParmTemplate)
		parmGrp.insertAfter(fileParmTemplate, offsetParmTemplate)

		n.setParmTemplateGroup(parmGrp)
		#display the expression as it's most like we want to mmb through the offset
		n.parm(name+'_offset').showExpression(1)
		
		expression = "path=parm('"+name+"_file').rawValue().split('$F')\nframe=parm('"+name+"_offset').eval()\npad = int(path[1][0]) if path[1][1] is '.' else 0\nreturn path[0]+str(frame).zfill(pad)+'.'+path[1].split('.')[-1];"
		hou_keyframe = hou.StringKeyframe()
		hou_keyframe.setTime(1) #arbitrary keyframe needed to enable expression i think
		hou_keyframe.setExpression(expression, hou.exprLanguage.Python)
		parm.setKeyframe(hou_keyframe)


def detach_parameter_window(kwargs):
	'''Open a floating parameter pane for a particular node.'''
	node = kwargs['node']
	pane_tab = hou.ui.curDesktop().createFloatingPaneTab(hou.paneTabType.Parm)
	pane_tab.setCurrentNode(node)
	pane_tab.setPin(True)
	return pane_tab

def open_parm_spreadsheet(kwargs):
	'''Opens the parameter spreadsheet with the current node selection and the right-clicked parm, appends the parm if it is already opened'''
	# todo: keep existing parm mask to append to it. Can't retrieve current value AFAIK.
	
	#import pprint
	#pprint.pprint(kwargs)
	#selectedParms = return_first_parm(kwargs)
	
	parms = []
	for parm in kwargs['parms']:
		parms.append(parm)
	for parm in kwargs['locked_parms']:
		parms.append(parm)
	
	selectedParm = parms[0]

	parms = [x.name() for x in parms]

	
	nodepaths = " ".join([node.path() for node in hou.selectedNodes()])
	if not hou.selectedNodes():
		nodepaths = selectedParm.node().path()

	parmsheetP = selectedParm.name()
	parmsheetPaths = nodepaths

	if kwargs['ctrlclick']:
		rmi = hou.ui.readMultiInput('Edit the node path and parms.', ['Node(s)','Parm(s)'], buttons=('OK','Cancel'), severity=hou.severityType.Message, default_choice=0, close_choice=1, help='Node path can have wildcards', title='Parm Chooser', initial_contents=(nodepaths.split(' ', 1)[0] ,selectedParm.name()))
		if rmi[0]==0:
			parmsheetPaths = rmi[1][0]
			parmsheetP = rmi[1][1]

	ps = hou.ui.findPaneTab('parmsheet')
	if not ps:
		desktop = hou.ui.curDesktop()
		ps = desktop.createFloatingPaneTab(hou.paneTabType.ParmSpreadsheet)
	cmd = 'parmsheet -w 0 -p "'+parmsheetP+'" -o "'+parmsheetPaths+'" '+ps.name()
	hou.hscript(cmd)

def test(var):
	print(var)

py2

Need to update to py3 for H19

In my userdocs\houdini18.5\python2.7libs folder

#march2022 update: fuck putin and improved the add offset to sequence so that you can still choose a new file and keep the offset without having to delete the spare parms (for imageplane frame offset for instance)

import hou
import traceback
import re
import os


def return_first_parm(allKwargs):
	'''given a right click context with kwargs, return what we want, the parameter -- which can be locked or not'''
	parm = lockedparm = normalparm = False
	try:
		lockedparm = allKwargs['locked_parms'][0]
	except:
		pass
	try:
		normalparm = allKwargs['parms'][0]
	except:
		pass
	if lockedparm:
		parm = lockedparm
	elif normalparm:
		parm = normalparm
	else:
		print('bug')
	return parm

def validate_sequence_parm(kwargs):
	'''Checks if the first parm contains a value with $F'''
	parm = return_first_parm(kwargs)
	returnvalue = False
	try:
		returnvalue = '$F' in parm.rawValue() 
	except:
		print("ERROR: %s" % traceback.format_exc())
	return returnvalue
	
def validate_uri(kwargs):
	'''Checks if the given argument, or its parent or grandparent is either a file or folder'''
	parm = return_first_parm(kwargs)
	parm = parm.eval()
	parm = str(parm)
	returnvalue = False
	if os.path.isdir(parm) or os.path.isdir(os.path.dirname(parm)) or os.path.isdir(os.path.dirname(os.path.dirname(parm))):
		returnvalue = True
	return returnvalue

def browse_to(kwargs):
	'''opens a browser to this given path (if a file), or its parent if it doesn't exist'''
	parm = return_first_parm(kwargs)
	path = os.path.dirname(parm.eval())
	dir = ''
	if os.path.isdir(path):
		dir = path
	elif os.path.isdir(os.path.dirname(path)):
		dir = os.path.dirname(path)
	else:
		print('No directory found')
	if dir != '':
		os.startfile(os.path.realpath(dir))

def add_sequence_offset_spareparm(kwargs):
	'''Adds an int spare parm + file parm below a parm that has a file sequence expression of type '$F#' using hscript's padzero'''
	parm = return_first_parm(kwargs)
	path = os.path.dirname(parm.eval())
	parmval = parm.rawValue()
	matchObj = re.match( r'^(.*)\$F(\d*)(.*)', parmval, re.M|re.I)
	if matchObj:
		n = parm.node()
		parmGrp = n.parmTemplateGroup()
		existing_parm = parmGrp.find(parm.name())
		label = existing_parm.label()

		#create an offset parm with a similar name. No error checking!

		fileParmTemplate = hou.StringParmTemplate(label+'_file',label+'_file', 1, default_value=([parmval]),string_type=hou.stringParmType.FileReference)
		offsetParmTemplate = hou.IntParmTemplate(label+'_offset',label+'_offset', 1, default_expression=(["$F + 0"]))
		parmGrp.insertAfter(existing_parm, fileParmTemplate)
		parmGrp.insertAfter(fileParmTemplate, offsetParmTemplate)

		n.setParmTemplateGroup(parmGrp)
		#display the expression as it's most like we want to mmb through the offset
		n.parm(label+'_offset').showExpression(1)
		
		expression = "path=parm('"+label+"_file').rawValue().split('$F')\nframe=parm('"+label+"_offset').eval()\npad = int(path[1][0]) if path[1][1] is '.' else 0\nreturn path[0]+str(frame).zfill(pad)+'.'+path[1].split('.')[-1];"
		hou_keyframe = hou.StringKeyframe()
		hou_keyframe.setTime(1) #arbitrary keyframe needed to enable expression i think
		hou_keyframe.setExpression(expression, hou.exprLanguage.Python)
		parm.setKeyframe(hou_keyframe)

def detach_parameter_window(kwargs):
	'''Open a floating parameter pane for a particular node.'''
	node = kwargs['node']
	pane_tab = hou.ui.curDesktop().createFloatingPaneTab(hou.paneTabType.Parm)
	pane_tab.setCurrentNode(node)
	pane_tab.setPin(True)
	return pane_tab

def open_parm_spreadsheet(kwargs):
	'''Opens the parameter spreadsheet with the current node selection and the right-clicked parm, appends the parm if it is already opened'''
	#import pprint
	#pprint.pprint(kwargs)
	selectedParm = return_first_parm(kwargs)
	nodepaths = " ".join([node.path() for node in hou.selectedNodes()])
	if not hou.selectedNodes():
		nodepaths = selectedParm.node().path()
	#print(nodepaths)
	ps = hou.ui.findPaneTab('parmsheet')
	if not ps:
		desktop = hou.ui.curDesktop()
		ps = desktop.createFloatingPaneTab(hou.paneTabType.ParmSpreadsheet)
	cmd = 'parmsheet -w 1 -p "'+selectedParm.name()+'" -o "'+nodepaths+'" '+ps.name()
	hou.hscript(cmd)

def test(var):
	print(var)