git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commit730e12c74b599e550fb6ea103d0e61e744022710
parent9dc971e4ab
authorLucas Galante <[email protected]>
date2026-09-17 15:29
network editor: Tab adds a node at the hc cursor, wired by the cell

addNodeAtCursor opens the Tab menu with node_position at the cursor cell
and the wiring decided by what the cell holds: one wire crossing an empty
cell and the node splices into it; a node and the new node takes the
cell wired inline above it on input 0, the row from there down shifting
one step (done once the node exists, so cancelling the menu changes
nothing, and re-seating the node the menu tool nudged aside); anything
else, an empty cell included, places unwired. Bound to Tab in the
network editor through NetworkViewMenu.xml, and the nodegraph hook
takes unmodified Tab straight away so it works before a restart.

The wire test samples a curve between the connector positions rather
than calling networkItemsInBox, which segfaulted the live session when
its pick records were stale.

Verified live before the rewrite: wiring on an occupied cell and the row
shift, and an empty cell placing unwired. The wire case, the corner
offset and the re-seat still need a live pass.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 NetworkViewMenu.xml                  |   8 ++
 hc_hotkeys.json                      |   1 +
 python3.13libs/hc/hcnetworkeditor.py | 181 ++++++++++++++++++++++++++++++++++-
 python3.13libs/nodegraphhooks.py     |  23 +++++
 4 files changed, 212 insertions(+), 1 deletion(-)

diff --git a/NetworkViewMenu.xml b/NetworkViewMenu.xml
index 0a57cdb..06fd68c 100644
--- a/NetworkViewMenu.xml
+++ b/NetworkViewMenu.xml
@@ -9,6 +9,14 @@
         <label>Navigate (Current)</label>
       </titleItem>
 
+      <scriptItem id="pane.wsheet.hc_add_node_at_cursor">
+        <label>Add Node at Cursor</label>
+        <scriptCode><![CDATA[
+from hc import HCNetworkEditor
+HCNetworkEditor(kwargs['editor']).addNodeAtCursor()
+]]></scriptCode>
+      </scriptItem>
+
       <scriptItem id="pane.wsheet.hc_nav_up">
         <label>Navigate Up</label>
         <scriptCode><![CDATA[
diff --git a/hc_hotkeys.json b/hc_hotkeys.json
index 3fbdfaa..b372556 100644
--- a/hc_hotkeys.json
+++ b/hc_hotkeys.json
@@ -29,6 +29,7 @@
         "h.pane.parms.hc_focus_prev_parameter": "shift+tab",
         "h.pane.parms.edit_expression": "alt+e",
         "h.pane.editparms.selectall": "alt+h",
+        "h.pane.wsheet.hc_add_node_at_cursor": "tab",
         "h.pane.wsheet.hc_nav_up": "k",
         "h.pane.wsheet.hc_nav_down": "j",
         "h.pane.wsheet.hc_nav_left": "h",
diff --git a/python3.13libs/hc/hcnetworkeditor.py b/python3.13libs/hc/hcnetworkeditor.py
index 10e326f..18679d6 100644
--- a/python3.13libs/hc/hcnetworkeditor.py
+++ b/python3.13libs/hc/hcnetworkeditor.py
@@ -1,4 +1,4 @@
-import hou, math, os, types
+import hou, math, os, time, types
 from . import hcnetcursorimage, hcstate
 from .hcpathtab import HCPathTab
 from .hcsettings import HCSettings
@@ -12,6 +12,9 @@ _overlays = hcstate.Store("hcnetcursor_overlay", hcstate.NETWORK)
 _grid_prefs = hcstate.Store("grid_pref_sync", hcstate.PANE)
 # Per-pane: the snapping prefs last written (see _syncSnapPrefs).
 _snap_prefs = hcstate.Store("snap_pref_sync", hcstate.PANE)
+#: An insert the Tab menu is about to make on top of a node: the cell to
+#: clear and the node in it (see addNodeAtCursor / finishPendingInsert).
+_pending_inserts = hcstate.Store("hcnetcursor_pending_insert", hcstate.NETWORK)
 
 # The network editor's `gridmode` pref, labelled as Houdini's own Show Grid
 # radio in NetworkViewMenu.xml labels it.
@@ -33,6 +36,32 @@ SNAP_PREFS_HARD = {"gridsnapping": "1", "dosnapping": "0", "snapradius": "1000"}
 SNAP_PREFS_SOFT = {"gridsnapping": "1", "dosnapping": "1", "snapradius": "0.1"}
 
 
+def _wireCrossesRect(start, start_dir, end, end_dir, rect, spacing=0.2):
+    """Whether a wire from `start` to `end` passes through `rect`.
+
+    The wire is modelled as a cubic curve leaving `start` along `start_dir`
+    and arriving at `end` along `end_dir`, with the handles a third of the
+    endpoint distance long -- close to the curve Houdini draws, and exact
+    for the straight vertical wires of a gridded chain. It is sampled
+    every `spacing` network units, so a cell shorter than that on a side
+    could be stepped over; cells are grid cells, never that small.
+    """
+    start = hou.Vector2(start[0], start[1])
+    end = hou.Vector2(end[0], end[1])
+    reach = (end - start).length() / 3.0
+    c1 = start + hou.Vector2(start_dir[0], start_dir[1]) * reach
+    c2 = end + hou.Vector2(end_dir[0], end_dir[1]) * reach
+    samples = max(8, int((end - start).length() / spacing) + 1)
+    for i in range(samples + 1):
+        t = i / samples
+        u = 1.0 - t
+        point = (start * (u * u * u) + c1 * (3 * u * u * t)
+                 + c2 * (3 * u * t * t) + end * (t * t * t))
+        if rect.contains(point):
+            return True
+    return False
+
+
 class HCNetworkEditor(HCPathTab):
     def __init__(self, hou_tab):
         self.hou_tab = hou_tab
@@ -1165,6 +1194,156 @@ class HCNetworkEditor(HCPathTab):
     def hcnetcursorCenter(self):
         return self.hcnetcursorRect().center()
 
+    def _nodesInRect(self, rect):
+        """Children whose centre lies in `rect` (network units)."""
+        offset = self._nodeOffset()
+        found = []
+        for node in self.hou_tab.pwd().children():
+            if rect.contains(node.position() + offset):
+                found.append(node)
+        return found
+
+    def nodesInHcnetcursor(self):
+        """Nodes whose centre is in the hc cursor cell."""
+        return self._nodesInRect(self.hcnetcursorRect())
+
+    def wiresInHcnetcursor(self, nodes=None):
+        """Wires crossing the hc cursor cell, as hou.NodeConnection objects.
+
+        Every connection into a child of the network is tested against the
+        cell by sampling its curve between the connector positions the
+        editor reports (itemOutputPos / itemInputPos, leaving along
+        itemOutputDir and arriving along itemInputDir, as Houdini's own
+        preview wires are built). A wire that enters the cell only to reach
+        a node sitting in it is not "crossing" it: those are dropped, so a
+        cell on a node reports no wires and an empty cell a wire passes
+        through reports that wire. `nodes` is nodesInHcnetcursor(), passed
+        in when the caller already has it.
+
+        This deliberately does not use networkItemsInBox. That reads the
+        editor's pick records, which are only rebuilt on redraw, and it
+        crashed Houdini when called in the same tick that had destroyed
+        nodes in the network (OPUIgetWireInput on a stale record).
+        """
+        if nodes is None:
+            nodes = self.nodesInHcnetcursor()
+        rect = self.hcnetcursorRect()
+        ed = self.hou_tab
+        wires = []
+        for node in ed.pwd().children():
+            for conn in node.inputConnections():
+                if conn.inputItem() in nodes or conn.outputItem() in nodes:
+                    continue
+                try:
+                    start = ed.itemOutputPos(conn.inputItem(), conn.inputItemOutputIndex())
+                    start_dir = ed.itemOutputDir(conn.inputItem(), conn.inputItemOutputIndex())
+                    end = ed.itemInputPos(conn.outputItem(), conn.inputIndex())
+                    end_dir = ed.itemInputDir(conn.outputItem(), conn.inputIndex())
+                except hou.Error:
+                    continue
+                if _wireCrossesRect(start, start_dir, end, end_dir, rect):
+                    wires.append(conn)
+        return wires
+
+    @command("Add Node at Cursor")
+    def addNodeAtCursor(self, key="Tab"):
+        """Open the Tab menu with the new node bound for the hc cursor cell.
+
+        What the cell holds decides the wiring. An empty cell: the node is
+        placed there and left unwired, whatever is selected. One wire
+        crossing an otherwise empty cell: the node splices into it. One
+        node: the new node takes the cell, wired inline above that node on
+        its first input, and the row from the cell down shifts one step to
+        make room (that shift waits until the node exists, see
+        finishPendingInsert, so cancelling the menu changes nothing). More
+        than one node or wire, or a node and a wire together: placed and
+        left unwired, since guessing would be worse.
+
+        `key` is what opened the menu; Houdini closes the menu when it is
+        pressed again. The menu tool puts the node's bottom-left corner at
+        node_position (OnCreated's grid snap runs before that), so the
+        centre offset is taken off the cell centre here.
+        """
+        rect = self.hcnetcursorRect()
+        nodes = self.nodesInHcnetcursor()
+        wires = self.wiresInHcnetcursor(nodes)
+        kwargs = {"node_position": rect.center() - self._nodeOffset()}
+        _pending_inserts.pop(self.hou_tab)
+        if len(nodes) == 1 and not wires:
+            target = nodes[0]
+            kwargs["dest_item"] = target
+            kwargs["dest_connector_index"] = 0
+            for conn in target.inputConnections():
+                if conn.inputIndex() == 0:
+                    kwargs["src_item"] = conn.inputItem()
+                    kwargs["src_connector_index"] = conn.inputItemOutputIndex()
+                    break
+            _pending_inserts.set(self.hou_tab, {
+                "rect": (rect.min()[0], rect.min()[1], rect.max()[0], rect.max()[1]),
+                "target": target.path(),
+                "children": {n.path() for n in self.hou_tab.pwd().children()},
+                "time": time.time(),
+            })
+        elif not nodes and len(wires) == 1:
+            wire = wires[0]
+            kwargs["src_item"] = wire.inputItem()
+            kwargs["src_connector_index"] = wire.inputItemOutputIndex()
+            kwargs["dest_item"] = wire.outputItem()
+            kwargs["dest_connector_index"] = wire.inputIndex()
+        self.hou_tab.openTabMenu(key=key, **kwargs)
+
+    #: Seconds a pending insert may wait for its node before it is dropped.
+    PENDING_INSERT_TIMEOUT = 60.0
+
+    def finishPendingInsert(self, uievent):
+        """Make room for a node the Tab menu just put on top of another.
+
+        nodegraphhooks calls this on every network editor event. The menu
+        is a popup, so the editor sees nothing until it has closed; the
+        first event after that finds either a child that was not there
+        when the menu opened -- the insert happened -- or none, meaning
+        the menu was cancelled. On an insert, every node from the cell's
+        row down moves one grid step down, the new nodes excepted, which
+        opens exactly the row the cell is in and keeps every other row as
+        it was; then the new node goes into the cell. That last move is
+        needed because the menu tool, finding the cell occupied, nudges
+        the node it creates half a step sideways; that is also why the
+        new node is found by not being in the recorded set of children
+        rather than by where it is. A cancel is forgotten on the next
+        click or key, or after PENDING_INSERT_TIMEOUT, so a node made in
+        that cell later by other means does not trigger a shift.
+        """
+        pending = _pending_inserts.get(self.hou_tab)
+        if pending is None:
+            return
+        pwd = self.hou_tab.pwd()
+        new_nodes = [n for n in pwd.children() if n.path() not in pending["children"]]
+        if new_nodes:
+            _pending_inserts.pop(self.hou_tab)
+            rect = hou.BoundingRect(*pending["rect"])
+            step = self._gridStep()
+            offset = self._nodeOffset()
+            # The one wired into the target is the node to seat in the cell;
+            # a tool that makes several nodes seats the first otherwise.
+            target = hou.node(pending["target"])
+            seated = new_nodes[0]
+            for node in new_nodes:
+                if target is not None and target in node.outputs():
+                    seated = node
+                    break
+            with hou.undos.group("Make room for inserted node"):
+                for node in pwd.children():
+                    if node in new_nodes:
+                        continue
+                    if (node.position() + offset)[1] < rect.max()[1]:
+                        node.setPosition(node.position() - hou.Vector2(0, step[1]))
+                seated.setPosition(rect.center() - offset)
+            return
+        eventtype = str(getattr(uievent, "eventtype", "")).lower()
+        if (eventtype in ("mousedown", "keyhit")
+                or time.time() - pending["time"] > self.PENDING_INSERT_TIMEOUT):
+            _pending_inserts.pop(self.hou_tab)
+
     def pasteAtCursor(self):
         """Paste nodes from clipboard centered on the hc cursor position."""
         cursor = self.hcnetcursorCenter()
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index a236151..d3254a6 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -186,6 +186,9 @@ def createEventHandler(uievent, pending_actions):
             # Runs on every event, mouse moves included -- this is a no-op
             # unless the drawn overlay would actually differ.
             hc_editor.updateCurrentNodeOverlay()
+        # A node the Tab menu inserted on top of another needs the row below
+        # it moved out of the way; a dict lookup when nothing is pending.
+        hc_editor.finishPendingInsert(uievent)
 
     if isinstance(uievent, MouseEvent):
         if editor is not None and uievent.eventtype == 'mousedown':
@@ -231,6 +234,26 @@ def createEventHandler(uievent, pending_actions):
             if not has_modifier and not (eventtype.endswith("up") or "release" in eventtype):
                 hc_editor.frameHcnetcursor()
                 return None, True
+        # Unmodified Tab opens the Tab menu for the hc cursor cell instead of
+        # the mouse position (NetworkViewMenu.xml's hc_add_node_at_cursor
+        # does the same once a restart has registered it; here it works
+        # straight after Reload HC). Any modifier, or the cursor turned off,
+        # leaves Tab to Houdini.
+        if (
+            editor is not None
+            and eventtype == 'keyhit'
+            and (key == "tab" or rawkey == "tab")
+            and HCSettings().hcnetcursorEnabled()
+        ):
+            modifierstate = getattr(uievent, "modifierstate", None)
+            has_modifier = (
+                bool(getattr(modifierstate, "ctrl", False)) or
+                bool(getattr(modifierstate, "shift", False)) or
+                bool(getattr(modifierstate, "alt", False))
+            )
+            if not has_modifier:
+                hc_editor.addNodeAtCursor(key=getattr(uievent, "rawkey", "Tab"))
+                return None, True
         # Keyboard events are primarily handled via hc_hotkeys.json and
         # NetworkViewMenu.xml, but this hook remains for future
         # context-sensitive overrides.