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

python3.13libs/hc/hcnetworkeditor.py (70.2K)

   1 import hou, math, os, time, types
   2 from . import hcnetcursorimage, hcstate
   3 from .hcpathtab import HCPathTab
   4 from .hcsettings import HCSettings
   5 from .hccommands import command
   6 
   7 
   8 # Per-network: where the cursor sits, and what was last drawn for it.
   9 _cursors = hcstate.Store("hcnetcursor", hcstate.NETWORK)
  10 _overlays = hcstate.Store("hcnetcursor_overlay", hcstate.NETWORK)
  11 # Per-pane: the grid step last mirrored onto gridxstep/gridystep.
  12 _grid_prefs = hcstate.Store("grid_pref_sync", hcstate.PANE)
  13 # Per-pane: the snapping prefs last written (see _syncSnapPrefs).
  14 _snap_prefs = hcstate.Store("snap_pref_sync", hcstate.PANE)
  15 #: An insert the Tab menu is about to make on top of a node: the cell to
  16 #: clear and the node in it (see addNodeAtCursor / finishPendingInsert).
  17 _pending_inserts = hcstate.Store("hcnetcursor_pending_insert", hcstate.NETWORK)
  18 
  19 # The network editor's `gridmode` pref, labelled as Houdini's own Show Grid
  20 # radio in NetworkViewMenu.xml labels it.
  21 GRID_MODES = (
  22     ("No Grid", "0"),
  23     ("Grid Points", "1"),
  24     ("Grid Lines", "2"),
  25 )
  26 
  27 # Houdini's Snap to Grid is magnetic: nodegraphsnap.snapGrid only pulls an
  28 # item onto a grid line when its centre is already within `snapradius`
  29 # (0.1 network units by default) of one, so a node dropped anywhere else
  30 # stays free. With the radius wider than half of any grid step the nearest
  31 # line always captures, which is the hard snap the grid_snap setting asks
  32 # for. Snap to Visible Nodes competes for the same result, so it goes off
  33 # in that mode -- its alignment guides are redundant once everything is on
  34 # the grid anyway. The soft values are Houdini's own defaults.
  35 SNAP_PREFS_HARD = {"gridsnapping": "1", "dosnapping": "0", "snapradius": "1000"}
  36 SNAP_PREFS_SOFT = {"gridsnapping": "1", "dosnapping": "1", "snapradius": "0.1"}
  37 
  38 
  39 def _wireCrossesRect(start, start_dir, end, end_dir, rect, spacing=0.2):
  40     """Whether a wire from `start` to `end` passes through `rect`.
  41 
  42     The wire is modelled as a cubic curve leaving `start` along `start_dir`
  43     and arriving at `end` along `end_dir`, with the handles a third of the
  44     endpoint distance long -- close to the curve Houdini draws, and exact
  45     for the straight vertical wires of a gridded chain. It is sampled
  46     every `spacing` network units, so a cell shorter than that on a side
  47     could be stepped over; cells are grid cells, never that small.
  48     """
  49     start = hou.Vector2(start[0], start[1])
  50     end = hou.Vector2(end[0], end[1])
  51     reach = (end - start).length() / 3.0
  52     c1 = start + hou.Vector2(start_dir[0], start_dir[1]) * reach
  53     c2 = end + hou.Vector2(end_dir[0], end_dir[1]) * reach
  54     samples = max(8, int((end - start).length() / spacing) + 1)
  55     for i in range(samples + 1):
  56         t = i / samples
  57         u = 1.0 - t
  58         point = (start * (u * u * u) + c1 * (3 * u * u * t)
  59                  + c2 * (3 * u * t * t) + end * (t * t * t))
  60         if rect.contains(point):
  61             return True
  62     return False
  63 
  64 
  65 class HCNetworkEditor(HCPathTab):
  66     def __init__(self, hou_tab):
  67         self.hou_tab = hou_tab
  68         self.delta_t = 2
  69 
  70     def initialize(self):
  71         self.setMenuOpen(0)
  72         # Show the grid by default in hc-spawned network editors. Houdini's
  73         # grid snap is a no-op with the grid hidden, so this also underpins
  74         # _syncSnapPrefs.
  75         self.hou_tab.setPref('gridmode', '2')
  76         self._syncSnapPrefs()
  77 
  78         # Disable node previews in COP networks by default.
  79         if self.childCat() == 'Cop':
  80             # Set the global preference for new nodes
  81             try:
  82                 hou.pref.set('neteditor.cop_preview', False)
  83             except (AttributeError, hou.Error):
  84                 pass
  85             
  86             # Disable previews for existing nodes in this network
  87             for node in self.hou_tab.pwd().children():
  88                 try:
  89                     node.setGenericFlag(hou.nodeFlag.Thumbnail, False)
  90                 except (AttributeError, hou.Error):
  91                     pass
  92 
  93         self._syncHcnetcursor()
  94 
  95     def run(self, label, method_name, *args, **kwargs):
  96         """Call a method by name and flash the label in the network editor."""
  97         getattr(self, method_name)(*args, **kwargs)
  98         self.hou_tab.flashMessage(None, label, 1.0)
  99 
 100 
 101     """ Navigation """
 102 
 103 
 104     def navigateDirection(self, direction):
 105         self.moveHcnetcursor(direction)
 106 
 107     def _frameSelectionInView(self, margin_frac=0.1):
 108         """Pan or zoom the view so every selected node is visible with a
 109         margin proportional to the current viewport size on each side. Pans
 110         when the selection fits the current viewport; zooms out otherwise."""
 111         parent = self.hou_tab.pwd()
 112         selected = parent.selectedChildren()
 113         if not selected:
 114             return
 115 
 116         sel_rect = self.hou_tab.itemRect(selected[0])
 117         for n in selected[1:]:
 118             sel_rect.enlargeToContain(self.hou_tab.itemRect(n))
 119 
 120         view = self.hou_tab.visibleBounds()
 121         view_size = view.size()
 122         margin = hou.Vector2(view_size[0] * margin_frac,
 123                              view_size[1] * margin_frac)
 124         sel_rect.expand(margin)
 125 
 126         needed = sel_rect.size()
 127         if needed[0] > view_size[0] or needed[1] > view_size[1]:
 128             aspect = view_size[0] / view_size[1]
 129             nw, nh = needed[0], needed[1]
 130             if nw / nh > aspect:
 131                 nh = nw / aspect
 132             else:
 133                 nw = nh * aspect
 134             center = sel_rect.center()
 135             self.setBounds(hou.BoundingRect(
 136                 center[0] - nw / 2, center[1] - nh / 2,
 137                 center[0] + nw / 2, center[1] + nh / 2,
 138             ))
 139             return
 140 
 141         vmin, vmax = view.min(), view.max()
 142         smin, smax = sel_rect.min(), sel_rect.max()
 143         dx = 0.0
 144         if smin[0] < vmin[0]:
 145             dx = smin[0] - vmin[0]
 146         elif smax[0] > vmax[0]:
 147             dx = smax[0] - vmax[0]
 148         dy = 0.0
 149         if smin[1] < vmin[1]:
 150             dy = smin[1] - vmin[1]
 151         elif smax[1] > vmax[1]:
 152             dy = smax[1] - vmax[1]
 153         if dx or dy:
 154             view.translate(hou.Vector2(dx, dy))
 155             self.setBounds(view)
 156 
 157     def _currentNodeOverlayShapes(self):
 158         """Return an arrow pointing at the current node when it is off-screen."""
 159         ed = self.hou_tab
 160         current = self.currentNode()
 161         if current is None:
 162             return []
 163 
 164         rect = ed.itemRect(current)
 165         node_center = ed.posToScreen(hou.Vector2(rect.center()[0],
 166                                                  rect.center()[1]))
 167         sb = ed.screenBounds()
 168         w, h = sb.size()[0], sb.size()[1]
 169         margin = 30
 170 
 171         if (margin <= node_center[0] <= w - margin
 172                 and margin <= node_center[1] <= h - margin):
 173             return []
 174 
 175         cx, cy = w / 2.0, h / 2.0
 176         dx, dy = node_center[0] - cx, node_center[1] - cy
 177         if dx == 0.0 and dy == 0.0:
 178             return []
 179 
 180         tvals = []
 181         if dx != 0.0:
 182             for tx in (margin, w - margin):
 183                 tt = (tx - cx) / dx
 184                 if tt > 0:
 185                     tvals.append(tt)
 186         if dy != 0.0:
 187             for ty in (margin, h - margin):
 188                 tt = (ty - cy) / dy
 189                 if tt > 0:
 190                     tvals.append(tt)
 191         if not tvals:
 192             return []
 193         t = min(min(tvals), 1.0)
 194         tip_x, tip_y = cx + t * dx, cy + t * dy
 195 
 196         angle = math.atan2(dy, dx)
 197         # Dimensions for the triangle
 198         length = 28.0
 199         width_ratio = 0.7  # Half-width relative to length
 200         
 201         # Calculate triangle vertices
 202         # tip is at (tip_x, tip_y)
 203         # back corners are at a distance 'length' away along the angle
 204         p1 = hou.Vector2(tip_x, tip_y)
 205         p2 = hou.Vector2(
 206             tip_x - length * math.cos(angle - math.atan(width_ratio)),
 207             tip_y - length * math.sin(angle - math.atan(width_ratio))
 208         )
 209         p3 = hou.Vector2(
 210             tip_x - length * math.cos(angle + math.atan(width_ratio)),
 211             tip_y - length * math.sin(angle + math.atan(width_ratio))
 212         )
 213 
 214         color = HCSettings().color("node_graph", "current_node_arrow_color")
 215         width = 3.0
 216         return [
 217             hou.NetworkShapeLine(p1, p2, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
 218             hou.NetworkShapeLine(p2, p3, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
 219             hou.NetworkShapeLine(p3, p1, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
 220         ]
 221 
 222     def _hcnetcursorKey(self):
 223         return hcstate.networkKey(self.hou_tab)
 224 
 225     def _gridStep(self):
 226         """Return grid step from HCSettings, mirroring it onto the pane pref."""
 227         ng = HCSettings().nodeGraph()
 228         x = max(ng.get("grid_x_step", 2.0), 0.25)
 229         y = max(ng.get("grid_y_step", 1.0), 0.25)
 230         # Mirror onto the pane pref, but only when it actually changed --
 231         # this runs on every network editor UI event, and setPref is a HOM
 232         # write that is not free.
 233         if _grid_prefs.get(self.hou_tab) != (x, y):
 234             try:
 235                 self.hou_tab.setPref("gridxstep", str(x))
 236                 self.hou_tab.setPref("gridystep", str(y))
 237                 _grid_prefs.set(self.hou_tab, (x, y))
 238             except hou.Error:
 239                 pass
 240         self._syncSnapPrefs()
 241         return hou.Vector2(x, y)
 242 
 243     def _syncSnapPrefs(self):
 244         """Write the snapping prefs the grid_snap setting asks for.
 245 
 246         Runs from _gridStep on every network editor event, so it only touches
 247         the prefs when the wanted set differs from what was last written.
 248         """
 249         hard = HCSettings().gridSnapEnabled()
 250         wanted = SNAP_PREFS_HARD if hard else SNAP_PREFS_SOFT
 251         if _snap_prefs.get(self.hou_tab) == hard:
 252             return
 253         try:
 254             for name, value in wanted.items():
 255                 self.hou_tab.setPref(name, value)
 256             _snap_prefs.set(self.hou_tab, hard)
 257         except hou.Error:
 258             pass
 259 
 260     def _nodeOffset(self):
 261         """Return node center offset (pos + offset = center)."""
 262         ng = HCSettings().nodeGraph()
 263         return hou.Vector2(
 264             ng.get("node_center_offset_x", 0.5),
 265             ng.get("node_center_offset_y", 0.15),
 266         )
 267 
 268     def _initialHcnetcursorState(self):
 269         step = self._gridStep()
 270         anchor = None
 271         current = self.currentNode()
 272         if current is None:
 273             # Right after a hip load no child carries the current flag yet,
 274             # but the editor still names the node the file saved as current.
 275             # Measured live: currentNode() was growth_vis while every child's
 276             # isCurrent() was False. Anchoring on it puts the cursor where the
 277             # file left off rather than on whatever is nearest the view centre.
 278             current = self._editorCurrentChild()
 279         if current is not None:
 280             anchor = current.position()
 281         if anchor is None:
 282             nearest = self._nearestNodeToViewportCenter()
 283             if nearest is not None:
 284                 anchor = nearest.position()
 285         if anchor is None:
 286             anchor = self.bounds().center()
 287         center = self._snapToGrid(anchor)
 288         origin = hou.Vector2(center[0] - step[0] * 0.5,
 289                              center[1] - step[1] * 0.5)
 290         return {
 291             "origin": (origin[0], origin[1]),
 292             "grow_x": 0,
 293             "grow_y": 0,
 294         }
 295 
 296     def _editorCurrentChild(self):
 297         """hou.NetworkEditor.currentNode(), only when it is a child of pwd().
 298 
 299         The editor's answer is its last-known current node and can be stale
 300         (see currentNode); it names the network itself when nothing inside
 301         is current. Good enough as a fallback anchor, never as the truth.
 302         """
 303         try:
 304             node = self.hou_tab.currentNode()
 305         except hou.Error:
 306             return None
 307         if node is None or node == self.hou_tab.pwd():
 308             return None
 309         if node.parent() != self.hou_tab.pwd():
 310             return None
 311         return node
 312 
 313     def _hcnetcursorRectFromState(self, state):
 314         if "origin" not in state and "center" not in state:
 315             return hou.BoundingRect(
 316                 state['min'][0], state['min'][1],
 317                 state['max'][0], state['max'][1],
 318             )
 319 
 320         step = self._gridStep()
 321         grow_x = int(state.get("grow_x", 0))
 322         grow_y = int(state.get("grow_y", 0))
 323         if "origin" in state:
 324             origin = hou.Vector2(state["origin"][0], state["origin"][1])
 325         else:
 326             center = hou.Vector2(state["center"][0], state["center"][1])
 327             origin = hou.Vector2(center[0] - step[0] * 0.5,
 328                                  center[1] - step[1] * 0.5)
 329 
 330         min_x = origin[0]
 331         max_x = origin[0] + step[0]
 332         min_y = origin[1]
 333         max_y = origin[1] + step[1]
 334 
 335         if grow_x > 0:
 336             max_x += step[0] * grow_x
 337         elif grow_x < 0:
 338             min_x += step[0] * grow_x
 339 
 340         if grow_y > 0:
 341             max_y += step[1] * grow_y
 342         elif grow_y < 0:
 343             min_y += step[1] * grow_y
 344 
 345         return hou.BoundingRect(min_x, min_y, max_x, max_y)
 346 
 347     def _hcnetcursorState(self):
 348         state = _cursors.get(self.hou_tab)
 349         if state is None:
 350             state = _cursors.set(self.hou_tab, self._initialHcnetcursorState())
 351         elif "origin" not in state:
 352             rect = self._hcnetcursorRectFromState(state)
 353             min_v = rect.min()
 354             state = {
 355                 "origin": (min_v[0], min_v[1]),
 356                 "grow_x": 0,
 357                 "grow_y": 0,
 358             }
 359             _cursors.set(self.hou_tab, state)
 360         return dict(state)
 361 
 362     def _setHcnetcursorState(self, state):
 363         _cursors.set(self.hou_tab, {
 364             "origin": (state["origin"][0], state["origin"][1]),
 365             "grow_x": int(state.get("grow_x", 0)),
 366             "grow_y": int(state.get("grow_y", 0)),
 367         })
 368 
 369     def _snapCursorEdgeDown(self, value, step):
 370         return math.floor((value + step * 0.5) / step) * step - step * 0.5
 371 
 372     def _snapCursorEdgeUp(self, value, step):
 373         return math.ceil((value - step * 0.5) / step) * step + step * 0.5
 374 
 375     def _selectedNodesRect(self):
 376         selected = list(self.hou_tab.pwd().selectedChildren())
 377         if not selected:
 378             return None
 379 
 380         rect = self.hou_tab.itemRect(selected[0])
 381         for node in selected[1:]:
 382             rect.enlargeToContain(self.hou_tab.itemRect(node))
 383         return rect
 384 
 385     def fitHcnetcursorToSelectedNodes(self):
 386         rect = self._selectedNodesRect()
 387         if rect is None:
 388             return
 389 
 390         step = self._gridStep()
 391         min_v = rect.min()
 392         max_v = rect.max()
 393         snapped_min_x = self._snapCursorEdgeDown(min_v[0], step[0])
 394         snapped_max_x = self._snapCursorEdgeUp(max_v[0], step[0])
 395         snapped_min_y = self._snapCursorEdgeDown(min_v[1], step[1])
 396         snapped_max_y = self._snapCursorEdgeUp(max_v[1], step[1])
 397 
 398         width_cells = max(1, int(round((snapped_max_x - snapped_min_x) / step[0])))
 399         height_cells = max(1, int(round((snapped_max_y - snapped_min_y) / step[1])))
 400 
 401         self._setHcnetcursorState({
 402             "origin": (snapped_min_x, snapped_min_y),
 403             "grow_x": width_cells - 1,
 404             "grow_y": height_cells - 1,
 405         })
 406         self.updateCurrentNodeOverlay()
 407 
 408     def selectedNodesEnvelopeSignature(self):
 409         selected = list(self.hou_tab.pwd().selectedChildren())
 410         if not selected:
 411             return ()
 412 
 413         rect = self.hou_tab.itemRect(selected[0])
 414         for node in selected[1:]:
 415             rect.enlargeToContain(self.hou_tab.itemRect(node))
 416 
 417         min_v = rect.min()
 418         max_v = rect.max()
 419         return (
 420             tuple(sorted(node.path() for node in selected)),
 421             round(min_v[0], 4),
 422             round(min_v[1], 4),
 423             round(max_v[0], 4),
 424             round(max_v[1], 4),
 425         )
 426 
 427     def hcnetcursorRect(self):
 428         return self._hcnetcursorRectFromState(self._hcnetcursorState())
 429 
 430     def _frameRectInView(self, rect, margin_frac=0.1):
 431         view = self.hou_tab.visibleBounds()
 432         view_size = view.size()
 433         margin = hou.Vector2(view_size[0] * margin_frac,
 434                              view_size[1] * margin_frac)
 435         framed = hou.BoundingRect(rect.min()[0], rect.min()[1],
 436                                   rect.max()[0], rect.max()[1])
 437         framed.expand(margin)
 438 
 439         needed = framed.size()
 440         if needed[0] > view_size[0] or needed[1] > view_size[1]:
 441             aspect = view_size[0] / view_size[1]
 442             nw, nh = needed[0], needed[1]
 443             if nw / nh > aspect:
 444                 nh = nw / aspect
 445             else:
 446                 nw = nh * aspect
 447             center = framed.center()
 448             self.setBounds(hou.BoundingRect(
 449                 center[0] - nw / 2, center[1] - nh / 2,
 450                 center[0] + nw / 2, center[1] + nh / 2,
 451             ))
 452             return
 453 
 454         vmin, vmax = view.min(), view.max()
 455         rmin, rmax = framed.min(), framed.max()
 456         dx = 0.0
 457         if rmin[0] < vmin[0]:
 458             dx = rmin[0] - vmin[0]
 459         elif rmax[0] > vmax[0]:
 460             dx = rmax[0] - vmax[0]
 461         dy = 0.0
 462         if rmin[1] < vmin[1]:
 463             dy = rmin[1] - vmin[1]
 464         elif rmax[1] > vmax[1]:
 465             dy = rmax[1] - vmax[1]
 466         if dx or dy:
 467             view.translate(hou.Vector2(dx, dy))
 468             self.setBounds(view)
 469 
 470     def _hcnetcursorOverlayShapes(self):
 471         return []
 472 
 473     def _hcnetcursorImageFile(self, draw_rect):
 474         size = draw_rect.size()
 475         return hcnetcursorimage.image_file(
 476             size[0], size[1], HCSettings().hcnetcursorColorHex())
 477 
 478     def _hcnetcursorDrawRect(self, rect):
 479         """The rect the picture is stretched over.
 480 
 481         The nodes a cursor frames sit at its cells' centres, each spanning
 482         the node offset either side of its centre (a node is 1 by 0.3 units
 483         in 1.99 by 0.99 cells). The box is that block of nodes grown by the
 484         hcnetcursor_margin setting on every side, so the gap between box and
 485         node is the same left, right, top and bottom whatever the cell shape
 486         and however many cells the cursor covers. Scaling the cells instead
 487         gave a 1-by-2 cursor a square box that clipped its nodes top and
 488         bottom while leaving air at the sides. Selection and movement keep
 489         using the whole cells.
 490         """
 491         step = self._gridStep()
 492         offset = self._nodeOffset()
 493         margin = HCSettings().hcnetcursorMargin()
 494         rmin, rmax = rect.min(), rect.max()
 495         return hou.BoundingRect(
 496             rmin[0] + step[0] * 0.5 - offset[0] - margin,
 497             rmin[1] + step[1] * 0.5 - offset[1] - margin,
 498             rmax[0] - step[0] * 0.5 + offset[0] + margin,
 499             rmax[1] - step[1] * 0.5 + offset[1] + margin,
 500         )
 501 
 502     def _backgroundImagesWithoutHcnetcursor(self):
 503         return [image for image in self.hou_tab.backgroundImages()
 504                 if not hcnetcursorimage.is_cursor_image(image.path())]
 505 
 506     def _updateHcnetcursorBackground(self, rect, cursor_path):
 507         images = self._backgroundImagesWithoutHcnetcursor()
 508 
 509         cursor_image = hou.NetworkImage(cursor_path, rect)
 510         cursor_image.setBrightness(1.0)
 511         cursor_image.setRelativeToPath("")
 512         images.append(cursor_image)
 513         self.hou_tab.setBackgroundImages(tuple(images))
 514 
 515     def _overlaySignature(self, rect, cursor_path):
 516         """Everything the drawn overlay depends on, as a hashable tuple.
 517 
 518         The cursor box (a background image) moves with its rect; the
 519         off-screen arrow moves with the current node's *screen* position, so
 520         panning and zooming change it even when nothing in the scene did.
 521         Screen coordinates are rounded to whole pixels -- sub-pixel drift is
 522         not worth a redraw.
 523         """
 524         if rect is None:
 525             box = None
 526         else:
 527             rmin, rmax = rect.min(), rect.max()
 528             box = (round(rmin[0], 4), round(rmin[1], 4),
 529                    round(rmax[0], 4), round(rmax[1], 4), cursor_path)
 530 
 531         ed = self.hou_tab
 532         current = self.currentNode()
 533         if current is None:
 534             arrow = None
 535         else:
 536             center = ed.itemRect(current).center()
 537             screen = ed.posToScreen(hou.Vector2(center[0], center[1]))
 538             size = ed.screenBounds().size()
 539             arrow = (current.path(), round(screen[0]), round(screen[1]),
 540                      round(size[0]), round(size[1]))
 541 
 542         return (box, arrow)
 543 
 544     def updateCurrentNodeOverlay(self, force=False):
 545         """Redraw the hcnetcursor box and off-screen arrow.
 546 
 547         nodegraphhooks calls this from the network editor event handler, so it
 548         runs on every mouse move. Rebuilding the background image list and
 549         calling redraw() unconditionally made panning visibly stutter; skip
 550         the work when the resulting picture would be identical.
 551         """
 552         if self.hou_tab.pwd() is None:
 553             # Nothing to anchor the cursor to and nothing to draw an arrow at.
 554             return
 555         enabled = HCSettings().hcnetcursorEnabled()
 556         rect = self._hcnetcursorDrawRect(self.hcnetcursorRect()) if enabled else None
 557         cursor_path = self._hcnetcursorImageFile(rect) if enabled else None
 558 
 559         signature = self._overlaySignature(rect, cursor_path)
 560         if not force and _overlays.get(self.hou_tab) == signature:
 561             return
 562         _overlays.set(self.hou_tab, signature)
 563 
 564         if enabled:
 565             self._updateHcnetcursorBackground(rect, cursor_path)
 566         else:
 567             self.hou_tab.setBackgroundImages(tuple(self._backgroundImagesWithoutHcnetcursor()))
 568         self.hou_tab.setShapes(self._hcnetcursorOverlayShapes())
 569         self.hou_tab.setOverlayShapes(self._currentNodeOverlayShapes())
 570         self.hou_tab.redraw()
 571 
 572     def refreshHcnetcursor(self):
 573         """Reapply the cursor box and overlay without changing its state."""
 574         self.updateCurrentNodeOverlay(force=True)
 575 
 576     def _nearestNodeToViewportCenter(self):
 577         network = self.hou_tab.pwd()
 578         if network is None:
 579             return None
 580         bounds = self.bounds()
 581         center = bounds.center()
 582         best = None
 583         best_dist = float("inf")
 584         for node in network.children():
 585             npos = node.position()
 586             dx = npos[0] - center[0]
 587             dy = npos[1] - center[1]
 588             dist = (dx * dx + dy * dy) ** 0.5
 589             if dist < best_dist:
 590                 best = node
 591                 best_dist = dist
 592         return best
 593 
 594     def _syncHcnetcursor(self):
 595         rect = self.hcnetcursorRect()
 596         parent = self.hou_tab.pwd()
 597         selected = []
 598 
 599         rect_center = rect.center()
 600         for node in parent.children():
 601             inside = rect.intersects(self.hou_tab.itemRect(node))
 602             node.setSelected(inside)
 603             if inside:
 604                 selected.append(node)
 605 
 606         if selected:
 607             target = min(
 608                 selected,
 609                 key=lambda node: (
 610                     self.hou_tab.itemRect(node).center()[0] - rect_center[0]
 611                 ) ** 2 + (
 612                     self.hou_tab.itemRect(node).center()[1] - rect_center[1]
 613                 ) ** 2,
 614             )
 615             self.hou_tab.setCurrentNode(target, pick_node=False)
 616 
 617         self._frameRectInView(rect)
 618         self.updateCurrentNodeOverlay()
 619 
 620     def moveHcnetcursor(self, direction, distance=1):
 621         state = self._hcnetcursorState()
 622         step = self._gridStep()
 623         origin = hou.Vector2(state["origin"][0], state["origin"][1])
 624         delta = {
 625             'up': hou.Vector2(0, step[1] * distance),
 626             'down': hou.Vector2(0, step[1] * distance * -1),
 627             'left': hou.Vector2(step[0] * distance * -1, 0),
 628             'right': hou.Vector2(step[0] * distance, 0),
 629         }[direction]
 630         origin += delta
 631         state["origin"] = (origin[0], origin[1])
 632         self._setHcnetcursorState(state)
 633         self._syncHcnetcursor()
 634 
 635     def moveHcnetcursorToPosition(self, position):
 636         """Move the hcnetcursor to a network-space position, snapped to grid."""
 637         step = self._gridStep()
 638         snapped = self._snapToGrid(position)
 639         origin = hou.Vector2(snapped[0] - step[0] * 0.5,
 640                              snapped[1] - step[1] * 0.5)
 641         self._setHcnetcursorState({
 642             "origin": (origin[0], origin[1]),
 643             "grow_x": 0,
 644             "grow_y": 0,
 645         })
 646         self._syncHcnetcursor()
 647 
 648     def translateHcnetcursorByDelta(self, delta):
 649         state = self._hcnetcursorState()
 650         origin = hou.Vector2(state["origin"][0], state["origin"][1])
 651         origin += delta
 652         state["origin"] = (origin[0], origin[1])
 653         self._setHcnetcursorState(state)
 654         # The cursor rides along with nodes moved by the arrow keys; keep it
 655         # in view the same way a plain cursor move does.
 656         self._frameRectInView(self.hcnetcursorRect())
 657         self.updateCurrentNodeOverlay()
 658 
 659     def expandHcnetcursor(self, direction, distance=1):
 660         state = self._hcnetcursorState()
 661         if direction == 'right':
 662             state["grow_x"] += distance
 663         elif direction == 'left':
 664             state["grow_x"] -= distance
 665         elif direction == 'up':
 666             state["grow_y"] += distance
 667         elif direction == 'down':
 668             state["grow_y"] -= distance
 669         self._setHcnetcursorState(state)
 670         self._syncHcnetcursor()
 671 
 672 
 673     def resetHcnetcursor(self):
 674         """Drop the cursor state and rebuild it around the current node.
 675 
 676         After a hip load the store still holds the previous scene's cursor for
 677         this pane and path, or the overlay creates a fresh one lazily while
 678         drawing -- either way nothing re-ran the selection sync, so the node
 679         under the cursor sat unhighlighted until the first cursor key.
 680         """
 681         if not HCSettings().hcnetcursorEnabled():
 682             return
 683         _cursors.pop(self.hou_tab)
 684         _overlays.pop(self.hou_tab)
 685         self._syncHcnetcursor()
 686 
 687     def resetHcnetcursorSize(self):
 688         state = self._hcnetcursorState()
 689         state["grow_x"] = 0
 690         state["grow_y"] = 0
 691         self._setHcnetcursorState(state)
 692         self._syncHcnetcursor()
 693 
 694 
 695     """ Selection """
 696 
 697 
 698     def addToSelection(self, direction):
 699         self.expandHcnetcursor(direction)
 700 
 701     def currentNode(self):
 702         """The node that is current *now*, inside the displayed network, or None.
 703 
 704         hou.NetworkEditor.currentNode() does not answer that. It answers with
 705         the editor's last-known current node, which goes stale two ways: it
 706         keeps naming a node after that node has stopped being current, and when
 707         the editor has no current child in the displayed network it returns the
 708         network itself. Measured in a live session: with nothing current it
 709         still named the node that used to be, and at /obj it named /obj.
 710 
 711         Callers guarded the second case with `current.parent() == pwd()` --
 712         which is `pwd().parent() == pwd()`, so when it fired it discarded the
 713         answer entirely, and the off-screen arrow never drew at /obj at all.
 714         Nothing guarded the first, so Rename Node and the flag toggles would
 715         act on whatever had been current a moment ago.
 716 
 717         hou.Node.isCurrent() is authoritative and immediate, but it is a
 718         per-child flag, so the node has to be searched for. Being current
 719         implies being selected -- selecting another node moves current with it,
 720         deselecting clears it -- so selectedChildren(), one call returning a
 721         short list, finds it every time in practice. tools/check.py asserts
 722         that invariant; the children() scan below is the safety net for if it
 723         stops holding, not the normal path. It matters because this runs from
 724         the overlay on every network editor UI event, and scanning a large
 725         network on every mouse move would not be free.
 726         """
 727         network = self.hou_tab.pwd()
 728         if network is None:
 729             return None
 730         for node in network.selectedChildren():
 731             if node.isCurrent():
 732                 return node
 733         for node in network.children():
 734             if node.isCurrent():
 735                 return node
 736         return None
 737 
 738     @command("Deselect All")
 739     def deselectAllNodes(self):
 740         self.hou_tab.clearAllSelected()
 741 
 742     def selectAllNodes(self):
 743         nodes = self.hou_tab.pwd().children()
 744         for node in nodes:
 745             node.setSelected(True)
 746 
 747     @command("Recook Selection")
 748     def recookSelection(self):
 749         nodes = list(self.hou_tab.pwd().selectedChildren())
 750         if not nodes:
 751             hou.ui.setStatusMessage("No selected nodes to recook",
 752                                     hou.severityType.Warning)
 753             return
 754 
 755         cooked = 0
 756         for node in nodes:
 757             try:
 758                 node.cook(force=True)
 759                 cooked += 1
 760             except hou.Error as e:
 761                 hou.ui.setStatusMessage(f"Failed to recook {node.path()}: {e}",
 762                                         hou.severityType.Error)
 763                 return
 764 
 765         hou.ui.setStatusMessage(f"Recooked {cooked} selected node(s)",
 766                                 hou.severityType.Message)
 767 
 768 
 769     """ Connectivity """
 770 
 771 
 772     def connectedComponents(self):
 773         """Returns a list of lists of hou.Node, where each inner list is a
 774         connected component (nodes reachable via input/output wires in any
 775         direction)."""
 776         parent = self.hou_tab.pwd()
 777         nodes = list(parent.children())
 778         node_set = set(nodes)
 779         visited = set()
 780         components = []
 781         for start in nodes:
 782             if start in visited:
 783                 continue
 784             component = []
 785             stack = [start]
 786             while stack:
 787                 node = stack.pop()
 788                 if node in visited:
 789                     continue
 790                 visited.add(node)
 791                 component.append(node)
 792                 neighbors = list(node.inputs()) + list(node.outputs())
 793                 for neighbor in neighbors:
 794                     if neighbor is None:
 795                         continue
 796                     if neighbor not in node_set:
 797                         continue
 798                     if neighbor in visited:
 799                         continue
 800                     stack.append(neighbor)
 801             components.append(component)
 802         return components
 803 
 804     def selectComponent(self, direction):
 805         """Select the connected component closest to the current selection in
 806         the given spatial direction. direction is 'next' (to the right) or
 807         'prev' (to the left). If no selection exists, defaults to the 
 808         top-left most component."""
 809         components = self.connectedComponents()
 810         if not components:
 811             return
 812 
 813         def sort_key(comp):
 814             xs = [n.position()[0] for n in comp]
 815             ys = [n.position()[1] for n in comp]
 816             cx = (min(xs) + max(xs)) / 2.0
 817             cy = (min(ys) + max(ys)) / 2.0
 818             # Negate cy so higher y (top) sorts earlier in ascending order.
 819             # Ascending cx (left) sorts earlier.
 820             return (cx, -cy)
 821 
 822         selected_set = set(self.hou_tab.pwd().selectedChildren())
 823         current = self.currentNode()
 824         ref_comp = None
 825         
 826         # 1. Determine if we are currently "inside" a component
 827         if current is not None:
 828             for comp in components:
 829                 if current in comp:
 830                     # Check if this component is already fully selected
 831                     comp_set = set(comp)
 832                     if not comp_set.issubset(selected_set):
 833                         # Not fully selected! Our target is to complete this component.
 834                         target = comp
 835                         for node in self.hou_tab.pwd().children():
 836                             node.setSelected(node in target)
 837                         self._frameSelectionInView()
 838                         self.updateCurrentNodeOverlay()
 839                         return
 840                     
 841                     # Component is already fully selected, use it as reference for navigation
 842                     ref_comp = comp
 843                     break
 844         
 845         # 2. If no current node, or current node's component was already fully selected,
 846         # but there is other selection, find the reference component from that.
 847         if ref_comp is None and selected_set:
 848             for comp in components:
 849                 if any(n in selected_set for n in comp):
 850                     ref_comp = comp
 851                     break
 852 
 853         # 3. Handle Navigation
 854         if ref_comp is None:
 855             # Fallback: Sort all components and pick the top-left one
 856             components.sort(key=sort_key)
 857             target = components[0]
 858         else:
 859             ref_key = sort_key(ref_comp)
 860             if direction == 'next':
 861                 candidates = [(sort_key(c), c) for c in components if sort_key(c) > ref_key]
 862                 if not candidates:
 863                     return
 864                 candidates.sort(key=lambda t: t[0])
 865                 target = candidates[0][1]
 866             else:
 867                 candidates = [(sort_key(c), c) for c in components if sort_key(c) < ref_key]
 868                 if not candidates:
 869                     return
 870                 candidates.sort(key=lambda t: t[0], reverse=True)
 871                 target = candidates[0][1]
 872 
 873         for node in self.hou_tab.pwd().children():
 874             node.setSelected(node in target)
 875         if target:
 876             self.hou_tab.setCurrentNode(target[0], pick_node=False)
 877         self._frameSelectionInView()
 878         self.updateCurrentNodeOverlay()
 879 
 880 
 881     """ Flags """
 882 
 883 
 884     def setDisplayFlag(self):
 885         node = self.currentNode()
 886         if node is None:
 887             return
 888         node.setDisplayFlag(True)
 889         node.setRenderFlag(True)
 890 
 891     def toggleBypassFlag(self):
 892         selected = list(self.hou_tab.pwd().selectedChildren())
 893         if not selected:
 894             current = self.currentNode()
 895             if current is None:
 896                 return
 897             selected = [current]
 898 
 899         bypass_state = not any(node.isBypassed() for node in selected)
 900         for node in selected:
 901             node.bypass(bypass_state)
 902 
 903     def toggleTemplateFlag(self):
 904         node = self.currentNode()
 905         if node is None:
 906             return
 907         node.setTemplateFlag(not node.isTemplateFlagSet())
 908 
 909 
 910     """ Objects and node control """
 911 
 912 
 913     def _snapToGrid(self, position):
 914         step = self._gridStep()
 915         return hou.Vector2(
 916             round(position[0] / step[0]) * step[0],
 917             round(position[1] / step[1]) * step[1],
 918         )
 919 
 920     def snapToGrid(self, node):
 921         # In this Houdini version, node.position() is the bottom-left corner.
 922         # Standard node size discovered via RPC is 1.0 x 0.3.
 923         # Center offset is configurable via node_graph.node_center_offset_x/y.
 924         offset = self._nodeOffset()
 925         pos = node.position()
 926         center = pos + offset
 927         G = self._snapToGrid(center)
 928         # To put center at G, set pos to G - offset
 929         node.setPosition(G - offset)
 930 
 931     @command("Snap to Grid")
 932     def snapNodesToGrid(self):
 933         """Snap the selected nodes, or every node when none is selected.
 934 
 935         Cleans up networks laid out before hard snapping, and anything that
 936         still sets positions behind Houdini's snap code: layoutChildren,
 937         paste, the shove-aside on wire insert.
 938         """
 939         pwd = self.hou_tab.pwd()
 940         nodes = list(pwd.selectedChildren()) or list(pwd.children())
 941         with hou.undos.group("Snap to Grid"):
 942             moved = self._snapNodes(nodes)
 943         self.updateCurrentNodeOverlay()
 944         self.hou_tab.flashMessage(
 945             None, f"Snapped {moved} of {len(nodes)} nodes", 1.0)
 946 
 947     def swapDroppedNode(self, node_path, start_pos):
 948         """Finish a move that put one node onto another: swap positions.
 949 
 950         nodegraphhooks records the node under the mouse and its position at
 951         mousedown and calls this on the mouseup that ends the drag, after
 952         Houdini's move handler has written the new position;
 953         translateSelectedNodes calls it after a keyboard step. If the moved
 954         node's centre now sits in another node's grid cell, that node moves
 955         into the cell the dragged one vacated. When the two are wired directly
 956         to each other they also trade places in the chain (see
 957         _swapChainPlaces); otherwise wiring is left alone. Returns the
 958         displaced node, or None when nothing was swapped.
 959         """
 960         if not HCSettings().dropSwapEnabled():
 961             return None
 962         node = hou.node(node_path)
 963         pwd = self.hou_tab.pwd()
 964         if node is None or node.parent() != pwd:
 965             return None
 966         start = hou.Vector2(start_pos[0], start_pos[1])
 967         end = node.position()
 968         if (end - start).length() < 1e-6:
 969             return None
 970 
 971         # Same cell: centres within half a grid step on both axes. Under
 972         # hard snapping they coincide exactly; the tolerance covers a drop
 973         # with snapping off, where "onto" means mostly overlapping.
 974         step = self._gridStep()
 975         offset = self._nodeOffset()
 976         center = end + offset
 977         target = None
 978         best = None
 979         for other in pwd.children():
 980             if other == node:
 981                 continue
 982             other_center = other.position() + offset
 983             dx = abs(other_center[0] - center[0])
 984             dy = abs(other_center[1] - center[1])
 985             if dx < step[0] * 0.5 and dy < step[1] * 0.5:
 986                 dist = dx + dy
 987                 if best is None or dist < best:
 988                     target, best = other, dist
 989         if target is None:
 990             return None
 991 
 992         with hou.undos.group("Swap Nodes"):
 993             target.setPosition(start)
 994             pair = self._linkedPair(node, target)
 995             if pair is not None:
 996                 problem = self._swapChainPlaces(*pair)
 997                 if problem:
 998                     self.hou_tab.flashMessage(None, problem, 2.0)
 999         return target
1000 
1001     def _linkedPair(self, a, b):
1002         """(upstream, downstream) when one feeds the other directly, else None."""
1003         for conn in b.inputConnections():
1004             if conn.inputItem() == a:
1005                 return (a, b)
1006         for conn in a.inputConnections():
1007             if conn.inputItem() == b:
1008                 return (b, a)
1009         return None
1010 
1011     @staticmethod
1012     def _connect(consumer, index, item, output_index):
1013         # A network dot has a single input and no index for it.
1014         if isinstance(consumer, hou.NetworkDot):
1015             consumer.setInput(item, output_index)
1016         else:
1017             consumer.setInput(index, item, output_index)
1018 
1019     def _swapChainPlaces(self, up, down):
1020         """Make `down` take `up`'s place in the graph and vice versa.
1021 
1022         P -> up -> down -> Q becomes P -> down -> up -> Q: down inherits up's
1023         inputs and up's other outputs, up inherits down's other inputs and
1024         down's outputs, and the link between them reverses. Returns a message
1025         when the swap cannot be made (a node has too few inputs for the
1026         connections it would inherit) and leaves the wiring untouched then.
1027         """
1028         def source(conn):
1029             return (conn.inputIndex(), conn.inputItem(), conn.inputItemOutputIndex())
1030 
1031         def sink(conn):
1032             return (conn.outputItem(), conn.inputIndex(), conn.inputItemOutputIndex())
1033 
1034         up_in = [source(c) for c in up.inputConnections()]
1035         down_in = [source(c) for c in down.inputConnections()]
1036         up_out = [sink(c) for c in up.outputConnections() if c.outputItem() != down]
1037         down_out = [sink(c) for c in down.outputConnections()]
1038 
1039         for node, inherited in ((down, up_in), (up, down_in)):
1040             need = max((idx for idx, _, _ in inherited), default=-1) + 1
1041             if need > node.type().maxNumInputs():
1042                 return f"Cannot swap chain: {node.name()} has too few inputs"
1043 
1044         def clamp_output(item, index):
1045             names = item.outputNames() if isinstance(item, hou.Node) else ()
1046             return index if index < len(names) else 0
1047 
1048         for idx, _, _ in up_in:
1049             up.setInput(idx, None)
1050         for idx, _, _ in down_in:
1051             down.setInput(idx, None)
1052         for consumer, idx, _ in up_out + down_out:
1053             self._connect(consumer, idx, None, 0)
1054 
1055         for idx, item, out in up_in:
1056             down.setInput(idx, item, out)
1057         for idx, item, out in down_in:
1058             if item == up:
1059                 up.setInput(idx, down, clamp_output(down, out))
1060             else:
1061                 up.setInput(idx, item, out)
1062         for consumer, idx, out in up_out:
1063             self._connect(consumer, idx, down, clamp_output(down, out))
1064         for consumer, idx, out in down_out:
1065             self._connect(consumer, idx, up, clamp_output(up, out))
1066         return None
1067 
1068     def sweepToGrid(self):
1069         """Snap every off-grid node in the displayed network; return the count.
1070 
1071         nodegraphhooks queues this after each mouse action and key hit, for
1072         the paths Houdini's snap code never sees: layoutChildren, paste, the
1073         shove-aside on wire insert. Undo is off for it, so Ctrl+Z undoes the
1074         layout or move itself rather than first unsnapping its result -- the
1075         positions it restores were swept already.
1076         """
1077         if not HCSettings().gridSnapEnabled():
1078             return 0
1079         with hou.undos.disabler():
1080             return self._snapNodes(self.hou_tab.pwd().children())
1081 
1082     def _snapNodes(self, nodes):
1083         moved = 0
1084         for node in nodes:
1085             if self._isOnGrid(node.position()):
1086                 continue
1087             self.snapToGrid(node)
1088             moved += 1
1089         return moved
1090 
1091     def _isOnGrid(self, pos, tol=1e-6):
1092         # Check if the center (pos + offset) is on the grid
1093         center = pos + self._nodeOffset()
1094         snapped = self._snapToGrid(center)
1095         return abs(center[0] - snapped[0]) < tol and abs(center[1] - snapped[1]) < tol
1096 
1097     def _outputDescendants(self, node, visited=None):
1098         if visited is None:
1099             visited = set()
1100         visited.add(node)
1101         for output in node.outputs():
1102             if output not in visited:
1103                 self._outputDescendants(output, visited)
1104         return visited
1105 
1106     def _cellKey(self, center):
1107         """The grid cell holding a node centre, as integer coordinates."""
1108         step = self._gridStep()
1109         return (int(round(center[0] / step[0])), int(round(center[1] / step[1])))
1110 
1111     def _displaceOverrun(self, moving, origins, delta):
1112         """Nodes the stepping group landed on trade places with it.
1113 
1114         `origins` maps the cell each moving node left to the node. Along the
1115         line of the step, the moving nodes form runs; a run whose head lands
1116         on a bystander pushes it to the cell freed at the run's tail, and
1117         when the bystander is wired to the run it trades chain places with
1118         each node of the run in turn, so P -> a -> b -> c -> Q with {a, b}
1119         stepped down onto c becomes P -> c -> a -> b -> Q. A run of one is
1120         the swap a mouse drop makes. Before this only a lone node swapped;
1121         a selection stepped onto its neighbour just piled onto it.
1122         """
1123         if not origins or not HCSettings().dropSwapEnabled():
1124             return
1125         step = self._gridStep()
1126         offset = self._nodeOffset()
1127         dkey = (int(round(delta[0] / step[0])), int(round(delta[1] / step[1])))
1128         landed = {(x + dkey[0], y + dkey[1]): node for (x, y), node in origins.items()}
1129         for other in self.hou_tab.pwd().children():
1130             if other in moving:
1131                 continue
1132             head = landed.get(self._cellKey(other.position() + offset))
1133             if head is None:
1134                 continue
1135             run = [head]
1136             key = self._cellKey(other.position() + offset)
1137             cell = (key[0] - dkey[0], key[1] - dkey[1])
1138             while (cell[0] - dkey[0], cell[1] - dkey[1]) in origins:
1139                 cell = (cell[0] - dkey[0], cell[1] - dkey[1])
1140                 run.append(origins[cell])
1141             other.setPosition(hou.Vector2(cell[0] * step[0], cell[1] * step[1]) - offset)
1142             for node in run:
1143                 pair = self._linkedPair(node, other)
1144                 if pair is None:
1145                     break
1146                 problem = self._swapChainPlaces(*pair)
1147                 if problem:
1148                     self.hou_tab.flashMessage(None, problem, 2.0)
1149                     break
1150 
1151     def translateSelectedNodes(self, direction, hierarchy=None):
1152         step = self._gridStep()
1153         delta = {
1154             'up': hou.Vector2(0, step[1]),
1155             'down': hou.Vector2(0, -step[1]),
1156             'left': hou.Vector2(-step[0], 0),
1157             'right': hou.Vector2(step[0], 0),
1158         }[direction]
1159         
1160         # Determine nodes to move: Houdini selection, or nodes under the HC cursor
1161         hou_selection = list(self.hou_tab.pwd().selectedChildren())
1162         if not hou_selection:
1163             rect = self.hcnetcursorRect()
1164             nodes_to_move = set()
1165             for node in self.hou_tab.pwd().children():
1166                 if rect.contains(self.hou_tab.itemRect(node).center()):
1167                     nodes_to_move.add(node)
1168         else:
1169             nodes_to_move = set(hou_selection)
1170 
1171         if not nodes_to_move:
1172             return
1173 
1174         final_nodes = set(nodes_to_move)
1175         if hierarchy == 'ancestors':
1176             for node in nodes_to_move:
1177                 final_nodes.update(node.inputAncestors())
1178         elif hierarchy == 'descendants':
1179             for node in nodes_to_move:
1180                 final_nodes.update(self._outputDescendants(node))
1181 
1182         before_rect = self._selectedNodesRect()
1183         moved_any = False
1184         offset = self._nodeOffset()
1185         origins = {}
1186         with hou.undos.group("Move Nodes"):
1187             for hou_node in final_nodes:
1188                 pos = hou_node.position()
1189                 center = pos + offset
1190                 G = self._snapToGrid(center)
1191 
1192                 if not self._isOnGrid(pos):
1193                     hou_node.setPosition(G - offset)
1194                 else:
1195                     origins[self._cellKey(G)] = hou_node
1196                     hou_node.setPosition(G + delta - offset)
1197                 moved_any = True
1198             self._displaceOverrun(final_nodes, origins, delta)
1199         if moved_any:
1200             after_rect = self._selectedNodesRect()
1201             if before_rect is None or after_rect is None:
1202                 self.updateCurrentNodeOverlay()
1203                 return
1204 
1205             before_min = before_rect.min()
1206             after_min = after_rect.min()
1207             actual_delta = hou.Vector2(
1208                 after_min[0] - before_min[0],
1209                 after_min[1] - before_min[1],
1210             )
1211 
1212             before_size = before_rect.size()
1213             after_size = after_rect.size()
1214             size_changed = (
1215                 abs(after_size[0] - before_size[0]) > 1e-6 or
1216                 abs(after_size[1] - before_size[1]) > 1e-6
1217             )
1218 
1219             # Preserve expanded cursor size when only translating; refit only if
1220             # the selection envelope actually changed size.
1221             if size_changed:
1222                 self.fitHcnetcursorToSelectedNodes()
1223             elif abs(actual_delta[0]) > 1e-6 or abs(actual_delta[1]) > 1e-6:
1224                 self.translateHcnetcursorByDelta(actual_delta)
1225             else:
1226                 self.updateCurrentNodeOverlay()
1227 
1228 
1229     """ Viewport """
1230 
1231 
1232     def bounds(self):
1233         return self.hou_tab.visibleBounds()
1234 
1235     def cursorPosition(self):
1236         return self.hou_tab.cursorPosition()
1237 
1238     def hcnetcursorCenter(self):
1239         return self.hcnetcursorRect().center()
1240 
1241     def _nodesInRect(self, rect):
1242         """Children whose centre lies in `rect` (network units)."""
1243         offset = self._nodeOffset()
1244         found = []
1245         for node in self.hou_tab.pwd().children():
1246             if rect.contains(node.position() + offset):
1247                 found.append(node)
1248         return found
1249 
1250     def nodesInHcnetcursor(self):
1251         """Nodes whose centre is in the hc cursor cell."""
1252         return self._nodesInRect(self.hcnetcursorRect())
1253 
1254     def wiresInHcnetcursor(self, nodes=None):
1255         """Wires crossing the hc cursor cell, as hou.NodeConnection objects.
1256 
1257         Every connection into a child of the network is tested against the
1258         cell by sampling its curve between the connector positions the
1259         editor reports (itemOutputPos / itemInputPos, leaving along
1260         itemOutputDir and arriving along itemInputDir, as Houdini's own
1261         preview wires are built). A wire that enters the cell only to reach
1262         a node sitting in it is not "crossing" it: those are dropped, so a
1263         cell on a node reports no wires and an empty cell a wire passes
1264         through reports that wire. `nodes` is nodesInHcnetcursor(), passed
1265         in when the caller already has it.
1266 
1267         This deliberately does not use networkItemsInBox. That reads the
1268         editor's pick records, which are only rebuilt on redraw, and it
1269         crashed Houdini when called in the same tick that had destroyed
1270         nodes in the network (OPUIgetWireInput on a stale record).
1271         """
1272         if nodes is None:
1273             nodes = self.nodesInHcnetcursor()
1274         rect = self.hcnetcursorRect()
1275         ed = self.hou_tab
1276         wires = []
1277         for node in ed.pwd().children():
1278             for conn in node.inputConnections():
1279                 if conn.inputItem() in nodes or conn.outputItem() in nodes:
1280                     continue
1281                 try:
1282                     start = ed.itemOutputPos(conn.inputItem(), conn.inputItemOutputIndex())
1283                     start_dir = ed.itemOutputDir(conn.inputItem(), conn.inputItemOutputIndex())
1284                     end = ed.itemInputPos(conn.outputItem(), conn.inputIndex())
1285                     end_dir = ed.itemInputDir(conn.outputItem(), conn.inputIndex())
1286                 except hou.Error:
1287                     continue
1288                 if _wireCrossesRect(start, start_dir, end, end_dir, rect):
1289                     wires.append(conn)
1290         return wires
1291 
1292     @command("Add Node at Cursor")
1293     def addNodeAtCursor(self, key="Tab"):
1294         """Open the Tab menu with the new node bound for the hc cursor cell.
1295 
1296         What the cell holds decides the wiring. An empty cell: the node is
1297         placed there and left unwired, whatever is selected. One wire
1298         crossing an otherwise empty cell: the node splices into it. One
1299         node: the new node takes the cell, wired inline above that node on
1300         its first input, and the row from the cell down shifts one step to
1301         make room (that shift waits until the node exists, see
1302         finishPendingInsert, so cancelling the menu changes nothing). More
1303         than one node or wire, or a node and a wire together: placed and
1304         left unwired, since guessing would be worse.
1305 
1306         `key` is what opened the menu; Houdini closes the menu when it is
1307         pressed again. The menu tool puts the node's bottom-left corner at
1308         node_position (OnCreated's grid snap runs before that), so the
1309         centre offset is taken off the cell centre here.
1310         """
1311         rect = self.hcnetcursorRect()
1312         nodes = self.nodesInHcnetcursor()
1313         wires = self.wiresInHcnetcursor(nodes)
1314         kwargs = {"node_position": rect.center() - self._nodeOffset()}
1315         _pending_inserts.pop(self.hou_tab)
1316         if len(nodes) == 1 and not wires:
1317             target = nodes[0]
1318             kwargs["dest_item"] = target
1319             kwargs["dest_connector_index"] = 0
1320             for conn in target.inputConnections():
1321                 if conn.inputIndex() == 0:
1322                     kwargs["src_item"] = conn.inputItem()
1323                     kwargs["src_connector_index"] = conn.inputItemOutputIndex()
1324                     break
1325             _pending_inserts.set(self.hou_tab, {
1326                 "rect": (rect.min()[0], rect.min()[1], rect.max()[0], rect.max()[1]),
1327                 "target": target.path(),
1328                 "children": {n.path() for n in self.hou_tab.pwd().children()},
1329                 "time": time.time(),
1330             })
1331         elif not nodes and len(wires) == 1:
1332             wire = wires[0]
1333             kwargs["src_item"] = wire.inputItem()
1334             kwargs["src_connector_index"] = wire.inputItemOutputIndex()
1335             kwargs["dest_item"] = wire.outputItem()
1336             kwargs["dest_connector_index"] = wire.inputIndex()
1337         self.hou_tab.openTabMenu(key=key, **kwargs)
1338 
1339     #: Seconds a pending insert may wait for its node before it is dropped.
1340     PENDING_INSERT_TIMEOUT = 60.0
1341 
1342     def finishPendingInsert(self, uievent):
1343         """Make room for a node the Tab menu just put on top of another.
1344 
1345         nodegraphhooks calls this on every network editor event. The menu
1346         is a popup, so the editor sees nothing until it has closed; the
1347         first event after that finds either a child that was not there
1348         when the menu opened -- the insert happened -- or none, meaning
1349         the menu was cancelled. On an insert, every node from the cell's
1350         row down moves one grid step down, the new nodes excepted, which
1351         opens exactly the row the cell is in and keeps every other row as
1352         it was; then the new node goes into the cell. That last move is
1353         needed because the menu tool, finding the cell occupied, nudges
1354         the node it creates half a step sideways; that is also why the
1355         new node is found by not being in the recorded set of children
1356         rather than by where it is. A cancel is forgotten on the next
1357         click or key, or after PENDING_INSERT_TIMEOUT, so a node made in
1358         that cell later by other means does not trigger a shift.
1359         """
1360         pending = _pending_inserts.get(self.hou_tab)
1361         if pending is None:
1362             return
1363         pwd = self.hou_tab.pwd()
1364         new_nodes = [n for n in pwd.children() if n.path() not in pending["children"]]
1365         if new_nodes:
1366             _pending_inserts.pop(self.hou_tab)
1367             rect = hou.BoundingRect(*pending["rect"])
1368             step = self._gridStep()
1369             offset = self._nodeOffset()
1370             # The one wired into the target is the node to seat in the cell;
1371             # a tool that makes several nodes seats the first otherwise.
1372             target = hou.node(pending["target"])
1373             seated = new_nodes[0]
1374             for node in new_nodes:
1375                 if target is not None and target in node.outputs():
1376                     seated = node
1377                     break
1378             with hou.undos.group("Make room for inserted node"):
1379                 for node in pwd.children():
1380                     if node in new_nodes:
1381                         continue
1382                     if (node.position() + offset)[1] < rect.max()[1]:
1383                         node.setPosition(node.position() - hou.Vector2(0, step[1]))
1384                 seated.setPosition(rect.center() - offset)
1385             return
1386         eventtype = str(getattr(uievent, "eventtype", "")).lower()
1387         if (eventtype in ("mousedown", "keyhit")
1388                 or time.time() - pending["time"] > self.PENDING_INSERT_TIMEOUT):
1389             _pending_inserts.pop(self.hou_tab)
1390 
1391     def pasteAtCursor(self):
1392         """Paste nodes from clipboard centered on the hc cursor position."""
1393         cursor = self.hcnetcursorCenter()
1394         self.hou_tab.pwd().pasteItemsFromClipboard(cursor)
1395 
1396     def zoomPivot(self):
1397         node_graph = HCSettings().prefs().get("node_graph", {})
1398         behavior = node_graph.get("zoom_center", "mouse_cursor")
1399         if behavior == "hc_cursor":
1400             return self.hcnetcursorCenter()
1401         return self.cursorPosition()
1402 
1403     @command("Frame All")
1404     def frameAll(self):
1405         self.hou_tab.requestZoomReset()
1406 
1407     def frameCurrentComponent(self):
1408         """Frame the connected component containing selected nodes, but only
1409         if all selected nodes belong to a single component and no nodes from
1410         other components are selected."""
1411         components = self.connectedComponents()
1412         if not components:
1413             return
1414         selected_set = set(self.hou_tab.pwd().selectedChildren())
1415         if not selected_set:
1416             return
1417         # Find the component(s) that intersect the selection.
1418         touched = [c for c in components if set(c) & selected_set]
1419         if len(touched) != 1:
1420             return
1421         # Verify the selection is a subset of that component
1422         # (no nodes from other components selected).
1423         if not selected_set.issubset(set(touched[0])):
1424             return
1425         # Select the full component.
1426         component = touched[0]
1427         for c in components:
1428             for n in c:
1429                 n.setSelected(n in component)
1430         # Frame using Houdini's built-in fit, which centers properly.
1431         self.hou_tab.homeToSelection()
1432 
1433     def centerOnCursor(self):
1434         """Pan the viewport so the hc cursor is centered, preserving zoom."""
1435         cursor = self.hcnetcursorCenter()
1436         view = self.hou_tab.visibleBounds()
1437         view_center = view.center()
1438         delta = cursor - view_center
1439         view.translate(delta)
1440         self.setBounds(view)
1441 
1442     @command("Frame HC Cursor")
1443     def frameHcnetcursor(self, margin_cells=1.0, min_view_cells=(6.0, 4.0)):
1444         """Zoom/pan the viewport to the hcnetcursor, ignoring node selection."""
1445         rect = self.hcnetcursorRect()
1446         step = self._gridStep()
1447         size = rect.size()
1448         view = self.hou_tab.visibleBounds()
1449         view_size = view.size()
1450         aspect = view_size[0] / view_size[1] if view_size[1] else 1.0
1451 
1452         width = max(
1453             size[0] + (step[0] * margin_cells * 2.0),
1454             step[0] * min_view_cells[0],
1455         )
1456         height = max(
1457             size[1] + (step[1] * margin_cells * 2.0),
1458             step[1] * min_view_cells[1],
1459         )
1460 
1461         if width / height > aspect:
1462             height = width / aspect
1463         else:
1464             width = height * aspect
1465 
1466         center = rect.center()
1467         self.setBounds(hou.BoundingRect(
1468             center[0] - width / 2.0,
1469             center[1] - height / 2.0,
1470             center[0] + width / 2.0,
1471             center[1] + height / 2.0,
1472         ))
1473 
1474     def screenSize(self):
1475         return self.hou_tab.screenBounds().size()
1476 
1477     def size(self):
1478         return self.bounds().size()
1479 
1480     def setBounds(self, bounds):
1481         # The last argument is misdocumented. Measured in a live session:
1482         # without it a bounds change that keeps the zoom is dropped entirely,
1483         # so every pure pan here -- the cursor follow in _frameRectInView,
1484         # centerOnCursor, translateView -- silently did nothing, while frames
1485         # that changed the zoom worked. Houdini's own framing code passes it.
1486         self.hou_tab.setVisibleBounds(bounds, 0.0, 0.0, True)
1487         self.updateCurrentNodeOverlay()
1488 
1489     def translateView(self, direction):
1490         xform_map = {
1491             'up':    hou.Vector2(0, self.delta_t * self.zoomLevel()),
1492             'down':  hou.Vector2(0, self.delta_t * self.zoomLevel() * -1),
1493             'left':  hou.Vector2(self.delta_t * self.zoomLevel() * -1, 0),
1494             'right': hou.Vector2(self.delta_t * self.zoomLevel(), 0)
1495         }
1496         bounds = self.bounds()
1497         bounds.translate(xform_map[direction])
1498         self.setBounds(bounds)
1499 
1500     def zoom(self, direction, amount=0.25, pivot=None):
1501         factor = 1.0 - amount if direction == 'in' else 1.0 + amount
1502         bounds = self.bounds()
1503         if pivot is None:
1504             pivot = self.zoomPivot()
1505         bounds.translate(pivot * -1)
1506         bounds.scale((factor, factor))
1507         bounds.translate(pivot)
1508         self.setBounds(bounds)
1509 
1510     def zoomLevel(self):
1511         """Network units per screen pixel horizontally. Larger when zoomed out."""
1512         screen_width = self.screenSize()[0]
1513         if not screen_width:
1514             return 1.0
1515         return self.size()[0] / screen_width
1516 
1517 
1518     """ Interface"""
1519 
1520 
1521     def isMenuOpen(self):
1522         value = self.hou_tab.getPref('showmenu')
1523         return int(value)
1524 
1525     def setMenuOpen(self, value):
1526         self.hou_tab.setPref('showmenu', str(value))
1527 
1528     @command("Show Path Message")
1529     def showPathMessage(self):
1530         self.hou_tab.flashMessage(image=None, message=self.path(), duration=1)
1531 
1532     def isDimmingUnusedNodes(self):
1533         return self.hou_tab.getPref('dimunusednodes')
1534 
1535     @command("Dim Unused Nodes", state="isDimmingUnusedNodes")
1536     def toggleDimUnusedNodes(self):
1537         map = {
1538             '0': '1',
1539             '1': '0'
1540         }
1541         self.hou_tab.setPref('dimunusednodes', map[self.isDimmingUnusedNodes()])
1542 
1543     def gridMode(self):
1544         """The `gridmode` pref as Houdini stores it: '0', '1' or '2'."""
1545         return self.hou_tab.getPref('gridmode')
1546 
1547     @command("Grid Mode", state="gridMode", choices=GRID_MODES)
1548     def setGridMode(self, mode):
1549         self.hou_tab.setPref('gridmode', str(mode))
1550 
1551     # Cycles through the three modes; NetworkViewMenu.xml's Toggle Grid entry
1552     # still calls it. The panel lists the dropdown instead.
1553     def toggleGridMode(self):
1554         map = {
1555             '0': '1',
1556             '1': '2',
1557             '2': '0'
1558         }
1559         self.setGridMode(map[self.gridMode()])
1560 
1561     def isChromeVisible(self):
1562         return bool(super().isChromeVisible() or self.isMenuOpen())
1563 
1564     def showChrome(self, visible):
1565         super().showChrome(visible)
1566         # The node graph menu is always collapsed by a chrome toggle, never
1567         # restored -- matching the original toggleMenus behaviour.
1568         self.setMenuOpen(0)
1569 
1570     @command("Network Menu", state="isMenuOpen")
1571     def toggleMenu(self):
1572         map = {
1573             '0': '1',
1574             '1': '0'
1575         }
1576         mode = self.hou_tab.getPref('showmenu')
1577         self.hou_tab.setPref('showmenu', map[mode])
1578 
1579 
1580     """ Node replacement """
1581 
1582 
1583     @command("Excise Node")
1584     def exciseNode(self):
1585         """Take the current node out of its chain and leave the chain whole.
1586 
1587         Every consumer of the node is rewired to what fed the node's lowest
1588         connected input, so P -> N -> Q becomes P -> Q (with nothing feeding
1589         N, the consumers are simply unplugged). N keeps its parameters,
1590         loses its wires and steps aside to the nearest free cell on its
1591         right, still current, ready to be walked elsewhere with alt+hjkl or
1592         deleted. Unlike Delete it keeps the node.
1593         """
1594         node = self.currentNode()
1595         if node is None:
1596             hou.ui.setStatusMessage("No current node to excise",
1597                                     hou.severityType.Warning)
1598             return
1599         feeds = sorted(node.inputConnections(), key=lambda c: c.inputIndex())
1600         feed = (feeds[0].inputItem(), feeds[0].inputItemOutputIndex()) if feeds else (None, 0)
1601         with hou.undos.group("Excise Node"):
1602             for conn in list(node.outputConnections()):
1603                 self._connect(conn.outputItem(), conn.inputIndex(), feed[0], feed[1])
1604             for conn in feeds:
1605                 node.setInput(conn.inputIndex(), None)
1606             node.setPosition(self._freeCellRightOf(node))
1607             node.setCurrent(True, clear_all_selected=True)
1608 
1609     def _freeCellRightOf(self, node):
1610         """Position for `node` in the nearest empty cell to its right."""
1611         step = self._gridStep()
1612         offset = self._nodeOffset()
1613         taken = {self._cellKey(other.position() + offset)
1614                  for other in node.parent().children() if other != node}
1615         x, y = self._cellKey(node.position() + offset)
1616         x += 1
1617         while (x, y) in taken:
1618             x += 1
1619         return hou.Vector2(x * step[0], y * step[1]) - offset
1620 
1621     @command("Replace Node")
1622     def replaceNode(self):
1623         """Open a fuzzy-search node type picker and replace the single
1624         selected node with the chosen type, preserving position and wiring."""
1625         parent = self.hou_tab.pwd()
1626         selected = list(parent.selectedChildren())
1627 
1628         if len(selected) != 1:
1629             hou.ui.setStatusMessage("Select exactly one node to replace",
1630                                     hou.severityType.Warning)
1631             return
1632 
1633         old_node = selected[0]
1634         context = old_node.type().category().name()
1635 
1636         # Collect all node types in the same category context
1637         node_types = []
1638         try:
1639             categories = hou.nodeTypeCategories()
1640             if context in categories:
1641                 node_types = list(categories[context].nodeTypes().values())
1642         except hou.Error as e:
1643             hou.ui.setStatusMessage(f"Cannot list node types: {e}",
1644                                     hou.severityType.Error)
1645             return
1646 
1647         if not node_types:
1648             hou.ui.setStatusMessage("No node types found",
1649                                     hou.severityType.Error)
1650             return
1651 
1652         def do_replace(name):
1653             if not name or name == old_node.type().name():
1654                 return
1655             try:
1656                 new_node = parent.createNode(name)
1657             except Exception as e:
1658                 hou.ui.setStatusMessage(f"Cannot create {name}: {e}",
1659                                         hou.severityType.Error)
1660                 return
1661 
1662             # Copy position
1663             new_node.setPosition(old_node.position())
1664 
1665             # Rewire: connect old_node's inputs to new_node
1666             for i, input_node in enumerate(old_node.inputs()):
1667                 if input_node is not None:
1668                     new_node.setInput(i, input_node)
1669 
1670             # Rewire: connect old_node's outputs to new_node
1671             for output_node in list(old_node.outputs()):
1672                 for conn in output_node.inputConnections():
1673                     if conn.inputNode() == old_node:
1674                         output_node.setInput(conn.inputIndex(), new_node)
1675 
1676             # Copy display/render flags
1677             new_node.setDisplayFlag(old_node.isDisplayFlagSet())
1678             new_node.setRenderFlag(old_node.isRenderFlagSet())
1679             new_node.bypass(old_node.isBypassed())
1680             new_node.setTemplateFlag(old_node.isTemplateFlagSet())
1681             new_node.setColor(old_node.color())
1682             # OnCreated has just tagged new_node with the default color, but it
1683             # is wearing old_node's color now. Carry old_node's tag across too,
1684             # or updateNodeColors() reverts the copied color on the next load.
1685             old_tag = old_node.userData("hc_custom_color")
1686             if old_tag is None:
1687                 new_node.destroyUserData("hc_custom_color", must_exist=False)
1688             else:
1689                 new_node.setUserData("hc_custom_color", old_tag)
1690 
1691             old_node.destroy()
1692             new_node.setCurrent(True, clear_all_selected=True)
1693             hou.ui.setStatusMessage(f"Replaced with {name}",
1694                                     hou.severityType.Message)
1695 
1696         # Use description (English label) for display, name for creation
1697         labeled = [(nt.description(), nt.name()) for nt in node_types]
1698         labeled.sort(key=lambda x: x[0])
1699         list_dict = {label: (lambda n=name: do_replace(n))
1700                      for label, name in labeled}
1701 
1702         from .hcwidgets import HCWidgets
1703         anchor_geometry = self.hou_tab.pane().qtScreenGeometry()
1704         dialog = HCWidgets.SelectionDialog('Replace Node', list_dict,
1705                                            anchor_geometry=anchor_geometry)
1706         dialog.show()
1707         dialog.raise_()
1708         dialog.activateWindow()
1709 
1710 
1711     """ Utility """
1712 
1713     def type(self):
1714         return "HCNetworkEditor"
1715 
1716     @command("Reload Node Shapes")
1717     def reloadNodeShapes(self):
1718         self.hou_tab.reloadNodeShapes()
1719 
1720     @command("Rename Node")
1721     def renameNode(self):
1722         node = self.currentNode()
1723         if node is None:
1724             hou.ui.setStatusMessage("No current node to rename",
1725                                     hou.severityType.Warning)
1726             return
1727         button, name = hou.ui.readInput(
1728             "Rename node", buttons=("Rename", "Cancel"), initial_contents=node.name()
1729         )
1730         if button != 0 or not name.strip():
1731             return
1732         try:
1733             node.setName(name.strip(), unique_name=True)
1734         except hou.OperationFailed as e:
1735             hou.ui.setStatusMessage(f"Cannot rename: {e}", hou.severityType.Error)
1736 
1737     @command("Set Node Colors")
1738     def setNodeColors(self):
1739         nodes = list(self.hou_tab.pwd().selectedChildren())
1740         if not nodes:
1741             hou.ui.setStatusMessage("No selected nodes to color",
1742                                     hou.severityType.Warning)
1743             return
1744         color = hou.ui.selectColor(HCSettings().nodeColor())
1745         if color is None:  # dialog cancelled
1746             return
1747         # One undo for the whole selection, not one per node.
1748         with hou.undos.group("Set Node Colors"):
1749             for node in nodes:
1750                 node.setColor(color)
1751                 # An explicit choice opts the node out of updateNodeColors(),
1752                 # which would otherwise revert it to the default on the next
1753                 # hip load.
1754                 node.destroyUserData("hc_custom_color", must_exist=False)
1755 
1756     @command("Reset Node Colors")
1757     def resetNodeColors(self):
1758         """Put selected nodes back under the configured default color.
1759 
1760         Set Node Colors deliberately drops the `hc_custom_color` tag, so a node
1761         colored by hand is never touched by updateNodeColors() again. This is
1762         the way back: it restores the default color and re-records the tag, so
1763         the node follows the setting once more. Without it, opting a node out
1764         was a one-way door.
1765         """
1766         nodes = list(self.hou_tab.pwd().selectedChildren())
1767         if not nodes:
1768             hou.ui.setStatusMessage("No selected nodes to reset",
1769                                     hou.severityType.Warning)
1770             return
1771         settings = HCSettings()
1772         color = settings.nodeColor()
1773         color_hex = settings.nodeColorHex()
1774         # One undo for the whole selection, not one per node.
1775         with hou.undos.group("Reset Node Colors"):
1776             for node in nodes:
1777                 node.setColor(color)
1778                 node.setUserData("hc_custom_color", color_hex)
1779 
1780     @command("Set Node Shapes")
1781     def setNodeShapes(self):
1782         """Apply the configured node shape to the selected nodes.
1783 
1784         This used to take pwd().children() unconditionally: there was no way to
1785         reshape a few nodes, and invoking it rewrote every node in the network,
1786         including the ones you had deliberately left alone. It now behaves
1787         exactly like its sibling Set Node Colors -- the selection, or nothing.
1788         """
1789         nodes = list(self.hou_tab.pwd().selectedChildren())
1790         if not nodes:
1791             hou.ui.setStatusMessage("No selected nodes to shape",
1792                                     hou.severityType.Warning)
1793             return
1794         shape = HCSettings().nodeShape()
1795         # One undo for the whole selection, not one per node.
1796         with hou.undos.group("Set Node Shapes"):
1797             for node in nodes:
1798                 node.setUserData("nodeshape", shape)