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)