git.lucas.co / hou-control
git clone https://git.lucas.co/hou-control.git

python3.11libs/hc/hcnetworkeditor.py (19.5K)

  1 import hou, math, types
  2 from .hcpathtab import HCPathTab
  3 from .hcsettings import HCSettings
  4 
  5 
  6 # Per-network selection-extension paths, keyed by pwd path.
  7 # Each entry is a list of (node_path, arrived_via_direction) tuples from
  8 # the anchor (index 0, direction=None) to the head (index -1).
  9 _selection_paths = {}
 10 _OPPOSITE = {'up': 'down', 'down': 'up', 'left': 'right', 'right': 'left'}
 11 
 12 
 13 class HCNetworkEditor(HCPathTab):
 14     def __init__(self, hou_tab):
 15         self.hou_tab = hou_tab
 16         self.delta_t = 2
 17         self.network_node = self.pwd()
 18 
 19     def initialize(self):
 20         self.setMenuOpen(0)
 21         # Show the grid by default in hc-spawned network editors.
 22         self.hou_tab.setPref('gridmode', '2')
 23 
 24 
 25     """ Navigation """
 26 
 27 
 28     def _traversalTarget(self, direction, from_node=None):
 29         """Find the next node via wire traversal from from_node (default: the
 30         editor's current node). If no node is current or visible, falls back 
 31         to the node nearest to the viewport center.
 32         up = first input; down = first output;
 33         left/right = cycle through nodes sharing a downstream child (ordered
 34         by x-position)."""
 35         if from_node is None:
 36             from_node = self.hou_tab.currentNode()
 37         
 38         # Check if from_node is visible in the current viewport
 39         is_visible = False
 40         if from_node is not None:
 41             # itemRect returns coordinates in network space
 42             # visibleBounds returns the viewport in network space
 43             if self.hou_tab.visibleBounds().intersects(self.hou_tab.itemRect(from_node)):
 44                 is_visible = True
 45 
 46         # Fallback to viewport center if no node is current OR current node is off-screen
 47         if from_node is None or not is_visible:
 48             nearest = self._nearestNodeToViewportCenter()
 49             if nearest:
 50                 # If the current node was off-screen, the first press brings us to the center
 51                 return nearest
 52 
 53         if from_node is None:
 54             return None
 55 
 56         if direction == 'up':
 57             inputs = [n for n in from_node.inputs() if n is not None]
 58             return inputs[0] if inputs else None
 59         if direction == 'down':
 60             outputs = list(from_node.outputs())
 61             return outputs[0] if outputs else None
 62         if direction in ('left', 'right'):
 63             neighbors = set()
 64             for child in from_node.outputs():
 65                 for sibling in child.inputs():
 66                     if sibling is not None and sibling != from_node:
 67                         neighbors.add(sibling)
 68             if not neighbors:
 69                 return None
 70             ordered = sorted(
 71                 neighbors | {from_node},
 72                 key=lambda n: (n.position()[0], n.position()[1]),
 73             )
 74             idx = ordered.index(from_node)
 75             step = -1 if direction == 'left' else 1
 76             return ordered[(idx + step) % len(ordered)]
 77         return None
 78 
 79     def navigateDirection(self, direction):
 80         target = self._traversalTarget(direction)
 81         if target is not None:
 82             target.setSelected(True)
 83             self.hou_tab.setCurrentNode(target)
 84             self._frameSelectionInView()
 85             self.updateCurrentNodeOverlay()
 86 
 87     def _frameSelectionInView(self, margin_frac=0.1):
 88         """Pan or zoom the view so every selected node is visible with a
 89         margin proportional to the current viewport size on each side. Pans
 90         when the selection fits the current viewport; zooms out otherwise."""
 91         parent = self.hou_tab.pwd()
 92         selected = parent.selectedChildren()
 93         if not selected:
 94             return
 95 
 96         sel_rect = self.hou_tab.itemRect(selected[0])
 97         for n in selected[1:]:
 98             sel_rect.enlargeToContain(self.hou_tab.itemRect(n))
 99 
100         view = self.hou_tab.visibleBounds()
101         view_size = view.size()
102         margin = hou.Vector2(view_size[0] * margin_frac,
103                              view_size[1] * margin_frac)
104         sel_rect.expand(margin)
105 
106         needed = sel_rect.size()
107         if needed[0] > view_size[0] or needed[1] > view_size[1]:
108             aspect = view_size[0] / view_size[1]
109             nw, nh = needed[0], needed[1]
110             if nw / nh > aspect:
111                 nh = nw / aspect
112             else:
113                 nw = nh * aspect
114             center = sel_rect.center()
115             self.setBounds(hou.BoundingRect(
116                 center[0] - nw / 2, center[1] - nh / 2,
117                 center[0] + nw / 2, center[1] + nh / 2,
118             ))
119             return
120 
121         vmin, vmax = view.min(), view.max()
122         smin, smax = sel_rect.min(), sel_rect.max()
123         dx = 0.0
124         if smin[0] < vmin[0]:
125             dx = smin[0] - vmin[0]
126         elif smax[0] > vmax[0]:
127             dx = smax[0] - vmax[0]
128         dy = 0.0
129         if smin[1] < vmin[1]:
130             dy = smin[1] - vmin[1]
131         elif smax[1] > vmax[1]:
132             dy = smax[1] - vmax[1]
133         if dx or dy:
134             view.translate(hou.Vector2(dx, dy))
135             self.setBounds(view)
136 
137     def updateCurrentNodeOverlay(self):
138         """Draw an arrow at the viewport edge pointing at the current node if
139         it is off-screen. Clears any overlay when the current node is in
140         view or there is no current node. Uses hou.NetworkShape primitives
141         so the arrow lives on the native overlay layer."""
142         ed = self.hou_tab
143         current = ed.currentNode()
144         if current is None or current.parent() != ed.pwd():
145             ed.setOverlayShapes([])
146             return
147 
148         rect = ed.itemRect(current)
149         node_center = ed.posToScreen(hou.Vector2(rect.center()[0],
150                                                  rect.center()[1]))
151         sb = ed.screenBounds()
152         w, h = sb.size()[0], sb.size()[1]
153         margin = 30
154 
155         if (margin <= node_center[0] <= w - margin
156                 and margin <= node_center[1] <= h - margin):
157             ed.setOverlayShapes([])
158             return
159 
160         cx, cy = w / 2.0, h / 2.0
161         dx, dy = node_center[0] - cx, node_center[1] - cy
162         if dx == 0.0 and dy == 0.0:
163             ed.setOverlayShapes([])
164             return
165 
166         tvals = []
167         if dx != 0.0:
168             for tx in (margin, w - margin):
169                 tt = (tx - cx) / dx
170                 if tt > 0:
171                     tvals.append(tt)
172         if dy != 0.0:
173             for ty in (margin, h - margin):
174                 tt = (ty - cy) / dy
175                 if tt > 0:
176                     tvals.append(tt)
177         if not tvals:
178             ed.setOverlayShapes([])
179             return
180         t = min(min(tvals), 1.0)
181         tip_x, tip_y = cx + t * dx, cy + t * dy
182 
183         angle = math.atan2(dy, dx)
184         # Dimensions for the triangle
185         length = 18.0
186         width_ratio = 0.6  # Half-width relative to length
187         
188         # Calculate triangle vertices
189         # tip is at (tip_x, tip_y)
190         # back corners are at a distance 'length' away along the angle
191         p1 = hou.Vector2(tip_x, tip_y)
192         p2 = hou.Vector2(
193             tip_x - length * math.cos(angle - math.atan(width_ratio)),
194             tip_y - length * math.sin(angle - math.atan(width_ratio))
195         )
196         p3 = hou.Vector2(
197             tip_x - length * math.cos(angle + math.atan(width_ratio)),
198             tip_y - length * math.sin(angle + math.atan(width_ratio))
199         )
200 
201         color = hou.Color((0.38, 0.56, 0.56))
202         width = 2.0
203         shapes = [
204             hou.NetworkShapeLine(p1, p2, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
205             hou.NetworkShapeLine(p2, p3, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
206             hou.NetworkShapeLine(p3, p1, color=color, alpha=1.0, width=width, screen_space=True, smooth=True),
207         ]
208         ed.setOverlayShapes(shapes)
209 
210     def _nearestNodeToViewportCenter(self):
211         bounds = self.bounds()
212         center = bounds.center()
213         best = None
214         best_dist = float("inf")
215         for node in self.hou_tab.pwd().children():
216             npos = node.position()
217             dx = npos[0] - center[0]
218             dy = npos[1] - center[1]
219             dist = (dx * dx + dy * dy) ** 0.5
220             if dist < best_dist:
221                 best = node
222                 best_dist = dist
223         return best
224 
225 
226     """ Selection """
227 
228 
229     def addToSelection(self, direction):
230         """Extend or contract a selection chain anchored at the current node.
231         Mirrors text-editor shift+arrow behavior: each press walks the head
232         by one hop via wires; pressing the opposite direction contracts
233         (deselects) the tail node. The path auto-resets whenever the
234         current node no longer matches the anchor."""
235         parent = self.hou_tab.pwd()
236         current = self.hou_tab.currentNode()
237         
238         # If no current node, find the nearest one to start the selection
239         if current is None:
240             current = self._nearestNodeToViewportCenter()
241             if current:
242                 current.setSelected(True)
243                 self.hou_tab.setCurrentNode(current)
244             return
245 
246         key = parent.path()
247         path = _selection_paths.get(key, [])
248 
249         # Reset if stale: anchor must still be the current node, and every
250         # node referenced in the path must still exist.
251         if (not path
252                 or path[0][0] != current.path()
253                 or any(hou.node(p) is None for p, _ in path)):
254             path = [(current.path(), None)]
255 
256         if len(path) >= 2 and _OPPOSITE.get(path[-1][1]) == direction:
257             head = hou.node(path[-1][0])
258             if head is not None:
259                 head.setSelected(False)
260             path.pop()
261             _selection_paths[key] = path
262             return
263 
264         head = hou.node(path[-1][0])
265         target = self._traversalTarget(direction, from_node=head)
266         if target is None:
267             return
268         if any(p == target.path() for p, _ in path):
269             return
270 
271         current.setSelected(True)
272         target.setSelected(True)
273         path.append((target.path(), direction))
274         _selection_paths[key] = path
275         self._frameSelectionInView()
276 
277     def currentNode(self):
278         return self.hou_tab.currentNode()
279 
280     def deselectAllNodes(self):
281         self.hou_tab.clearAllSelected()
282 
283     def selectAllNodes(self):
284         nodes = self.network_node.children()
285         for node in nodes:
286             node.setSelected(True)
287 
288 
289     """ Connectivity """
290 
291 
292     def connectedComponents(self):
293         """Returns a list of lists of hou.Node, where each inner list is a
294         connected component (nodes reachable via input/output wires in any
295         direction)."""
296         parent = self.hou_tab.pwd()
297         nodes = list(parent.children())
298         node_set = set(nodes)
299         visited = set()
300         components = []
301         for start in nodes:
302             if start in visited:
303                 continue
304             component = []
305             stack = [start]
306             while stack:
307                 node = stack.pop()
308                 if node in visited:
309                     continue
310                 visited.add(node)
311                 component.append(node)
312                 neighbors = list(node.inputs()) + list(node.outputs())
313                 for neighbor in neighbors:
314                     if neighbor is None:
315                         continue
316                     if neighbor not in node_set:
317                         continue
318                     if neighbor in visited:
319                         continue
320                     stack.append(neighbor)
321             components.append(component)
322         return components
323 
324     def selectComponent(self, direction):
325         """Select the connected component closest to the current selection in
326         the given spatial direction. direction is 'next' (to the right) or
327         'prev' (to the left). If no selection exists, defaults to the 
328         top-left most component."""
329         components = self.connectedComponents()
330         if not components:
331             return
332 
333         def sort_key(comp):
334             xs = [n.position()[0] for n in comp]
335             ys = [n.position()[1] for n in comp]
336             cx = (min(xs) + max(xs)) / 2.0
337             cy = (min(ys) + max(ys)) / 2.0
338             # Negate cy so higher y (top) sorts earlier in ascending order.
339             # Ascending cx (left) sorts earlier.
340             return (cx, -cy)
341 
342         selected_set = set(self.hou_tab.pwd().selectedChildren())
343         current = self.hou_tab.currentNode()
344         ref_comp = None
345         
346         # 1. Determine if we are currently "inside" a component
347         if current is not None:
348             for comp in components:
349                 if current in comp:
350                     # Check if this component is already fully selected
351                     comp_set = set(comp)
352                     if not comp_set.issubset(selected_set):
353                         # Not fully selected! Our target is to complete this component.
354                         target = comp
355                         for node in self.hou_tab.pwd().children():
356                             node.setSelected(node in target)
357                         self._frameSelectionInView()
358                         self.updateCurrentNodeOverlay()
359                         return
360                     
361                     # Component is already fully selected, use it as reference for navigation
362                     ref_comp = comp
363                     break
364         
365         # 2. If no current node, or current node's component was already fully selected,
366         # but there is other selection, find the reference component from that.
367         if ref_comp is None and selected_set:
368             for comp in components:
369                 if any(n in selected_set for n in comp):
370                     ref_comp = comp
371                     break
372 
373         # 3. Handle Navigation
374         if ref_comp is None:
375             # Fallback: Sort all components and pick the top-left one
376             components.sort(key=sort_key)
377             target = components[0]
378         else:
379             ref_key = sort_key(ref_comp)
380             if direction == 'next':
381                 candidates = [(sort_key(c), c) for c in components if sort_key(c) > ref_key]
382                 if not candidates:
383                     return
384                 candidates.sort(key=lambda t: t[0])
385                 target = candidates[0][1]
386             else:
387                 candidates = [(sort_key(c), c) for c in components if sort_key(c) < ref_key]
388                 if not candidates:
389                     return
390                 candidates.sort(key=lambda t: t[0], reverse=True)
391                 target = candidates[0][1]
392 
393         for node in self.hou_tab.pwd().children():
394             node.setSelected(node in target)
395         if target:
396             self.hou_tab.setCurrentNode(target[0], pick_node=False)
397         self._frameSelectionInView()
398         self.updateCurrentNodeOverlay()
399 
400 
401     """ Legacy / broken connectivity stubs """
402 
403 
404     def chunk(self):
405         search_nodes = [self.currentNode()]
406         tree = []
407         search = True
408         while search:
409             if len(search_nodes) == 0:
410                 break
411             for node in search_nodes:
412                 tree.append(node)
413                 search_nodes.remove(node)
414                 input_nodes = node.inputs()
415                 output_nodes = node.outputs()
416                 search_nodes.append(input_nodes)
417                 search_nodes.append(output_nodes)
418             continue
419         return tree
420 
421 
422 
423     def chunks(self):
424         nodes = self.network_node.children()
425         # for node in nodes:
426 
427 
428 
429     """ Flags """
430 
431 
432     def setDisplayFlag(self):
433         self.currentNode().setDisplayFlag(True)
434         self.currentNode().setRenderFlag(True)
435 
436     def toggleBypassFlag(self):
437         self.currentNode().bypass(not self.currentNode().isBypassed())
438 
439     def toggleTemplateFlag(self):
440         self.currentNode().setTemplateFlag(not self.currentNode().isTemplateFlagSet())
441 
442 
443     """ Objects and node control """
444 
445 
446     def snapToGrid(self, node):
447         pos = node.position()
448         pos[0] = round(pos[0] - 0.5) + 0.5
449         pos[1] = round(pos[1] - 0.85) + 0.85
450         node.setPosition(pos)
451 
452     def translateSelectedNodes(self, direction):
453         for node in self.selectedNodes():
454             self.snapToGrid(node)
455             pos = node.position()
456             # do the move
457             if direction == 'up':
458                 pos[1] += 1
459             elif direction == 'down':
460                 pos[1] -= 1
461             elif direction == 'left':
462                 pos[0] -= 1
463             elif direction == 'right':
464                 pos[0] += 1
465             node.setPosition(pos)
466 
467 
468     """ Viewport """
469 
470 
471     def bounds(self):
472         return self.hou_tab.visibleBounds()
473 
474     def cursorPosition(self):
475         return self.hou_tab.cursorPosition()
476 
477     def frameAll(self):
478         self.hou_tab.requestZoomReset()
479 
480     def screenSize(self):
481         return self.hou_tab.screenBounds().size()
482 
483     def size(self):
484         return self.bounds().size()
485 
486     def setBounds(self, bounds):
487         self.hou_tab.setVisibleBounds(bounds)
488         self.updateCurrentNodeOverlay()
489 
490     def translateView(self, direction):
491         xform_map = {
492             'up':    hou.Vector2(0, self.delta_t * self.zoomLevel()),
493             'down':  hou.Vector2(0, self.delta_t * self.zoomLevel() * -1),
494             'left':  hou.Vector2(self.delta_t * self.zoomLevel() * -1, 0),
495             'right': hou.Vector2(self.delta_t * self.zoomLevel(), 0)
496         }
497         bounds = self.bounds()
498         bounds.translate(xform_map[direction])
499         self.setBounds(bounds)
500 
501     def zoom(self, direction, amount=0.25, pivot=None):
502         factor = 1.0 - amount if direction == 'in' else 1.0 + amount
503         bounds = self.bounds()
504         if pivot is None:
505             pivot = bounds.center()
506         bounds.translate(pivot * -1)
507         bounds.scale((factor, factor))
508         bounds.translate(pivot)
509         self.setBounds(bounds)
510 
511     def zoomLevel(self):
512         zoomlevel = self.size()[0] / self.size()[0]
513         return zoomlevel
514 
515 
516     """ Interface"""
517 
518 
519     def isMenuOpen(self):
520         value = self.hou_tab.getPref('showmenu')
521         return int(value)
522 
523     def setMenuOpen(self, value):
524         self.hou_tab.setPref('showmenu', str(value))
525 
526     def showPathMessage(self):
527         self.hou_tab.flashMessage(image=None, message=self.path(), duration=1)
528 
529     # def showRadialMenu(self):
530         # from .hcradialutils import editorRadialMain
531         # menu = editorRadialMain()
532         # self.hou_tab.displayRadialMenu("hc_editor_radial_menu")
533 
534     def toggleDimUnusedNodes(self):
535         map = {
536             '0': '1',
537             '1': '0'
538         }
539         mode = self.hou_tab.getPref('dimunusednodes')
540         self.hou_tab.setPref('dimunusednodes', map[mode])
541 
542     def toggleGridMode(self):
543         map = {
544             '0': '1',
545             '1': '2',
546             '2': '0'
547         }
548         mode = self.hou_tab.getPref('gridmode')
549         self.hou_tab.setPref('gridmode', map[mode])
550 
551     def toggleMenu(self):
552         map = {
553             '0': '1',
554             '1': '0'
555         }
556         mode = self.hou_tab.getPref('showmenu')
557         self.hou_tab.setPref('showmenu', map[mode])
558 
559 
560     """ Utility """
561 
562     def type(self):
563         return "HCNetworkEditor"
564 
565     def reloadNodeShapes(self):
566         self.hou_tab.reloadNodeShapes()
567 
568     def renameNode(self):
569         node = self.currentNode()
570         name = hou.ui.readInput("Rename_node", buttons=("Yes", "No"))
571         if name[0] == 0:
572             node.setName(name[1])
573 
574     def setNodeColors(self):
575         color = hou.ui.selectColor(hou.Color(HCSettings().node_color))
576         nodes = self.pwd().selectedChildren()
577         for node in nodes:
578             node.setColor(color)
579 
580     def setNodeShapes(self):
581         nodes = self.pwd().children()
582         for node in nodes:
583             node.setUserData("nodeshape", "rect")