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")