SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcsession.py (41.5K)
1 import hou
2 from PySide6.QtWidgets import QApplication
3 from PySide6.QtCore import QTimer
4 from .hcbindings import HCBindings
5 from .hcnetworkeditor import HCNetworkEditor
6 from .hcsceneviewer import HCSceneViewer
7 from .hcpane import HCPane
8 from .hcpathtab import HCPathTab
9 from .hctab import HCTab
10 from .hcwidgets import HCWidgets
11 from .hccommands import command
12
13
14 SETTINGS_PANEL_SIZE = (720, 600)
15 SPREADSHEET_PANEL_SIZE = (1400, 900)
16
17 # (panel label, hou.updateMode attribute name) for the Update Mode dropdown.
18 UPDATE_MODES = (
19 ("Auto", "AutoUpdate"),
20 ("Manual", "Manual"),
21 ("On Mouse Up", "OnMouseUp"),
22 )
23
24
25 def floatWindow(window, size=None):
26 """Make a Houdini floating panel's toplevel read as a dialog to the WM.
27
28 Houdini creates every floating panel as a plain _NET_WM_WINDOW_TYPE_NORMAL
29 toplevel with no transient parent, so a tiling compositor (wlroots/niri
30 here) tiles it like any other window -- which is why HC Settings came up
31 the same size as the main Houdini window no matter what `size` was passed
32 to createFloatingPanel(). Qt.Dialog plus a transient parent makes the
33 compositor float it instead and honour the requested size.
34
35 Changing the flag re-creates the native window, so it is only done once;
36 on an already-converted window this is a no-op and the panel does not
37 flicker.
38 """
39 from PySide6 import QtCore
40
41 # Qt.Dialog is Qt.Window|0x2, so a plain `flags & Qt.Dialog` test is true
42 # for every toplevel. Mask off the hint bits and compare the type.
43 if window.windowFlags() & QtCore.Qt.WindowType_Mask == QtCore.Qt.Dialog:
44 return
45
46 window.hide()
47 window.setWindowFlag(QtCore.Qt.Dialog, True)
48 handle = window.windowHandle()
49 main = hou.qt.mainWindow()
50 main_handle = main.windowHandle() if main is not None else None
51 if handle is not None and main_handle is not None:
52 handle.setTransientParent(main_handle)
53 if size is not None:
54 window.resize(*size)
55
56
57 def parseFileHistory(text):
58 """Paths of the HIP block of a file.history, most recent first.
59
60 Houdini writes blocks as ``HIP\n{\n<path>\n...\n}`` and appends the
61 newest path at the end; a path opened again is listed once.
62 """
63 paths = []
64 block = None
65 for line in text.splitlines():
66 line = line.strip()
67 if block is None:
68 if line and line not in ("{", "}"):
69 block = line # a block name: HIP, OTL, ...
70 continue
71 if line == "{":
72 continue
73 if line == "}":
74 block = None
75 continue
76 if block == "HIP" and line:
77 paths.append(line)
78 seen = set()
79 ordered = []
80 for path in reversed(paths):
81 if path not in seen:
82 seen.add(path)
83 ordered.append(path)
84 return ordered
85
86
87 class HCSession:
88 def __init__(self):
89 return
90
91
92 """ Return HC Objects """
93
94 def allNetworkEditors(self):
95 network_editors = []
96 for tab in self.allTabs():
97 if tab.type() == 'HCNetworkEditor':
98 network_editors.append(tab)
99 return network_editors
100
101 def allPanes(self):
102 panes = []
103 seen = set()
104 for pane in self.desktop().panes():
105 if pane.id() not in seen:
106 panes.append(HCPane(pane))
107 seen.add(pane.id())
108 for fp in hou.ui.floatingPanels():
109 if fp.qtParentWindow() is None:
110 continue
111 for pane in fp.panes():
112 if pane.id() not in seen:
113 panes.append(HCPane(pane))
114 seen.add(pane.id())
115 return panes
116
117 def allSceneViewers(self):
118 scene_viewers = []
119 for tab in self.allTabs():
120 if tab.type() == 'HCSceneViewer':
121 scene_viewers.append(tab)
122 return scene_viewers
123
124 def allTabs(self):
125 tabs = []
126 for pane in self.allPanes():
127 for hou_tab in pane.hou_pane.tabs():
128 tabs.append(pane.convertTab(hou_tab))
129 return tabs
130
131 def allViewports(self):
132 viewports = []
133 scene_viewers = self.allSceneViewers()
134 for scene_viewer in scene_viewers:
135 for viewport in scene_viewer.allViewports():
136 viewports.append(viewport)
137 return viewports
138
139 def currentPane(self):
140 hou_pane = self._paneFromQtFocus()
141 if hou_pane is None:
142 hou_pane = hou.ui.paneUnderCursor()
143 if hou_pane is None:
144 hou_pane = self._floatingPaneUnderCursor()
145 return HCPane(hou_pane) if hou_pane is not None else None
146
147 def _paneFromQtFocus(self):
148 """The pane whose area encloses the focused Qt widget, or None.
149
150 Only a widget that sits entirely inside one pane counts -- a Python
151 panel such as HC Settings, say. Houdini draws its native panes into a
152 single RE_Window widget spanning the whole desktop, and that is what
153 has focus while you work in a network editor or scene viewer. Testing
154 that widget's centre used to hand back whichever pane happened to lie
155 under the middle of the window, so the HC Panel listed the scene
156 viewer's commands no matter which pane it was opened over. A widget
157 that no single pane encloses says nothing about where the user is,
158 and currentPane() falls through to the pane under the cursor.
159 """
160 app = QApplication.instance()
161 if app is None:
162 return None
163
164 focus_widget = app.focusWidget()
165 if focus_widget is None:
166 return None
167
168 top_left = focus_widget.mapToGlobal(focus_widget.rect().topLeft())
169 bottom_right = focus_widget.mapToGlobal(focus_widget.rect().bottomRight())
170
171 for pane in self.allPanes():
172 try:
173 geometry = pane.qtScreenGeometry()
174 if geometry.contains(top_left) and geometry.contains(bottom_right):
175 return pane.hou_pane
176 except Exception:
177 continue
178 return None
179
180 def _floatingPaneUnderCursor(self):
181 from PySide6.QtGui import QCursor
182 pos = QCursor.pos()
183 for fp in hou.ui.floatingPanels():
184 qw = fp.qtParentWindow()
185 if qw is None or not qw.geometry().contains(pos):
186 continue
187 panes = fp.panes()
188 if panes:
189 return panes[0]
190 return None
191
192 def currentTab(self):
193 pane = self.currentPane()
194 return pane.currentTab() if pane is not None else None
195
196 def mainWindowGeometry(self):
197 """The main Houdini window's frame, in the same screen space as
198 hou.Pane.qtScreenGeometry(), or None when there is no main window."""
199 main = hou.qt.mainWindow()
200 return main.frameGeometry() if main is not None else None
201
202
203 """ Windows """
204
205 @command("Color Editor")
206 def colorEditor(self):
207 hou.ui.selectColor()
208
209 @command("Floating Parameter Editor")
210 def floatingParameterEditor(self):
211 tab = self.currentTab()
212 if tab is None or tab.type() != 'HCNetworkEditor':
213 hou.ui.setStatusMessage('Not a network editor', hou.severityType.Error)
214 return
215 node = tab.currentNode()
216 if node is None:
217 hou.ui.setStatusMessage('No current node', hou.severityType.Warning)
218 return
219 hou.ui.showFloatingParameterEditor(node)
220
221 def hcPanel(self):
222 from .hcmaps import HCMaps
223
224 pane = self.currentPane()
225 tab = self.currentTab()
226 list_dict = HCMaps().commands(self, pane, tab)
227
228 # Centred on the Houdini window, not the pane the panel was opened
229 # over: the pane still decides which commands are listed, but a
230 # 900x600 dialog centred on a narrow pane hung off the window edge.
231 panel = HCWidgets.SelectionDialog('hcpanel', list_dict, anchor_geometry=self.mainWindowGeometry())
232 panel.show()
233 panel.raise_()
234 panel.activateWindow()
235
236 @command("New File")
237 def newFile(self):
238 file_path = hou.ui.selectFile(
239 title="New File",
240 file_type=hou.fileType.Hip,
241 chooser_mode=hou.fileChooserMode.Write
242 )
243 if file_path:
244 QTimer.singleShot(0, lambda: self._executeNewFile(file_path))
245
246 def _executeNewFile(self, file_path):
247 file_path = self._normalizedHipPath(file_path)
248
249 try:
250 hou.hipFile.clear()
251 hou.hipFile.save(file_path)
252 self._verifyHipFileSaved(file_path, "Created new file")
253 except (hou.Error, OSError) as e:
254 hou.ui.displayMessage(f"Error creating file: {e}", severity=hou.severityType.Error)
255
256 @command("Open File")
257 def openFile(self):
258 file_path = hou.ui.selectFile(title="Open File", file_type=hou.fileType.Hip)
259 if file_path:
260 QTimer.singleShot(0, lambda: self._executeOpenFile(file_path))
261
262 def _executeOpenFile(self, file_path):
263 try:
264 hou.hipFile.load(file_path)
265 hou.ui.setStatusMessage(f"Opened file: {file_path}")
266 except hou.Error as e:
267 hou.ui.displayMessage(f"Error opening file: {e}", severity=hou.severityType.Error)
268
269 @command("Open Preferences")
270 def openPreferences(self):
271 hou.ui.openPreferences('ui', '')
272
273 @command("Open Hotkey Editor")
274 def openHotkeyEditor(self):
275 # Edit > Hotkeys... is a compiled-in actionItem (h.hotkey_mgr) with
276 # no HOM call behind it; the window it opens is this Python module.
277 from hotkeys import mainwidget
278 mainwidget.showHotkeyManagerWindow()
279
280 def statusDialog(self):
281 """The HC Status dialog if one has been created, else None."""
282 from .hcstatus import HCStatus
283 return hou.qt.mainWindow().findChild(HCStatus, HCStatus.OBJECT_NAME)
284
285 def isStatusVisible(self):
286 dialog = self.statusDialog()
287 return dialog is not None and dialog.isVisible()
288
289 @command("HC Status", state="isStatusVisible")
290 def toggleStatus(self):
291 from .hcstatus import HCStatus
292 existing = self.statusDialog()
293 if existing is None:
294 existing = HCStatus()
295 if existing.isVisible():
296 existing.hide()
297 else:
298 existing.show()
299 existing.raise_()
300
301 def toggleStatusBar(self):
302 from .hcstatusbar import HCStatusBar
303 bar = HCStatusBar()
304 if bar.isVisible():
305 bar.hide()
306 else:
307 bar._ensureOverlay()
308 bar.overlay.show()
309 bar.overlay.raise_()
310
311 def settingsTab(self):
312 """The open HC Settings pane tab, or None.
313
314 A PythonPanel tab is identified by its active interface, not by its
315 type -- every Python Panel is a hou.paneTabType.PythonPanel, so the
316 interface name is the only thing that distinguishes ours from any
317 other. allPanes() already spans the desktop and the floating panels,
318 so a settings tab the user has docked somewhere is found too.
319 """
320 from .hcsettings import HCSettingsPanel
321 for pane in self.allPanes():
322 for hou_tab in pane.hou_pane.tabs():
323 if hou_tab.type() != hou.paneTabType.PythonPanel:
324 continue
325 interface = hou_tab.activeInterface()
326 if interface is not None and interface.name() == HCSettingsPanel.INTERFACE_NAME:
327 return hou_tab
328 return None
329
330 @command("Open HC Settings")
331 def openSettings(self):
332 """Show the HC Settings panel, creating a floating one if none is open.
333
334 The panel is a registered Python Panel interface
335 (python_panels/hc_settings.pypanel), so the user can also just add an
336 "HC Settings" tab to any pane. This command keeps the old one-keypress
337 behaviour: focus the existing tab, or float a new one.
338 """
339 from .hcsettings import HCSettingsPanel
340
341 created = False
342 hou_tab = self.settingsTab()
343 if hou_tab is None:
344 if hou.pypanel.interfaceByName(HCSettingsPanel.INTERFACE_NAME) is None:
345 hou.ui.setStatusMessage(
346 "HC Settings panel is not registered -- is "
347 "python_panels/hc_settings.pypanel on HOUDINI_PATH?",
348 hou.severityType.Error)
349 return None
350 # Passing the interface name (rather than setting it afterwards)
351 # also suppresses the Python Panel interface-picker toolbar.
352 panel = self.desktop().createFloatingPanel(
353 hou.paneTabType.PythonPanel,
354 size=SETTINGS_PANEL_SIZE,
355 python_panel_interface=HCSettingsPanel.INTERFACE_NAME,
356 immediate=True)
357 hou_tab = panel.paneTabs()[0]
358 created = True
359
360 hou_tab.setIsCurrentTab()
361 window = hou_tab.qtParentWindow()
362 if window is not None:
363 # Only size it on the way up: re-invoking the command on an open
364 # panel should focus it, not undo a resize the user has made.
365 floatWindow(window, size=SETTINGS_PANEL_SIZE if created else None)
366 window.show()
367 window.raise_()
368 window.activateWindow()
369 return hou_tab
370
371 def splitHandles(self):
372 from .hcsplithandles import HCSplitHandles
373 if not hasattr(hou.session, "_hc_split_handles"):
374 hou.session._hc_split_handles = HCSplitHandles()
375 return hou.session._hc_split_handles
376
377 def isSplitHandlesShowing(self):
378 # Read without creating: the panel asks on every open, and a state
379 # query should not be what first builds the handle manager.
380 handles = getattr(hou.session, "_hc_split_handles", None)
381 return handles is not None and handles.isShowing()
382
383 @command("Split Handles", state="isSplitHandlesShowing")
384 def toggleSplitHandles(self):
385 self.splitHandles().toggle()
386
387 def spreadsheetPanel(self):
388 """The floating panel holding a DetailsView tab, or None."""
389 for fp in hou.ui.floatingPanels():
390 for pane in fp.panes():
391 for tab in pane.tabs():
392 if tab.type() == hou.paneTabType.DetailsView:
393 return fp
394 return None
395
396 def isSpreadsheetOpen(self):
397 return self.spreadsheetPanel() is not None
398
399 @command("Spreadsheet", state="isSpreadsheetOpen")
400 def toggleSpreadsheet(self):
401 panel = self.spreadsheetPanel()
402 if panel is not None:
403 panel.close()
404 hou.ui.setStatusMessage("Closed Spreadsheet")
405 return
406
407 # If not found, open a new floating one with DetailsView. It asks to
408 # be floated: like HC Settings, a tiling WM would otherwise give the
409 # spreadsheet the same tile -- and so the same size -- as the main
410 # Houdini window.
411 self.newFloatingPane(hou.paneTabType.DetailsView,
412 size=SPREADSHEET_PANEL_SIZE)
413 hou.ui.setStatusMessage("Opened Spreadsheet")
414
415 @command("Refresh Split Handles")
416 def refreshSplitHandles(self):
417 self.splitHandles().refresh()
418
419 def rotateSplit(self):
420 pane = hou.ui.paneUnderCursor()
421 if pane is None:
422 return
423 parent = pane.getSplitParent()
424 if parent is not None:
425 parent.splitRotate()
426
427 def windowWatcher(self):
428 from .hcwindowwatcher import HCWindowWatcher
429 if not hasattr(hou.session, "_hc_window_watcher"):
430 hou.session._hc_window_watcher = HCWindowWatcher()
431 return hou.session._hc_window_watcher
432
433 def isWindowWatcherRunning(self):
434 watcher = getattr(hou.session, "_hc_window_watcher", None)
435 return watcher is not None and watcher.isRunning()
436
437 @command("Window Watcher", state="isWindowWatcherRunning")
438 def toggleWindowWatcher(self):
439 self.windowWatcher().toggle()
440
441
442 """ Settings """
443
444 def isAutoSave(self):
445 return hou.getPreference('autoSave')
446
447 @command("Reload Colors")
448 def reloadColorSchemes(self):
449 """Reload the UI and viewport color schemes from config/.
450
451 This used to call updateNodeColors() as well, so rereading a .hcs file
452 also rewrote node colors throughout the open scene. They share a word
453 and nothing else: one reloads config off disk, the other edits the hip.
454 Update Node Colors is its own command and its own menu item.
455 """
456 hou.ui.reloadColorScheme()
457 hou.ui.reloadViewportColorSchemes()
458
459 @command("HC Info")
460 def hcInfo(self):
461 from . import __version__
462 from PySide6.QtCore import Qt
463 from PySide6.QtWidgets import (
464 QDialog, QVBoxLayout, QLabel, QTextBrowser, QPushButton,
465 )
466
467 win = QDialog(hou.qt.mainWindow())
468 win.setObjectName("hc_info_window")
469 win.setWindowTitle("HC Info")
470 win.setWindowFlags(Qt.Tool)
471 win.resize(960, 740)
472
473 layout = QVBoxLayout(win)
474
475 header = QLabel(f"<b>hou-control</b> v{__version__}")
476 header.setStyleSheet("font-size: 16px; padding-bottom: 6px;")
477 layout.addWidget(header)
478
479 info = QTextBrowser()
480 info.setReadOnly(True)
481 info.setOpenExternalLinks(True)
482
483 hc_path = hou.getenv("HC_PATH") or "(unknown)"
484 parts = [
485 f"<b>HC_PATH:</b> {hc_path}",
486 f"<b>Houdini:</b> {hou.applicationVersionString()}",
487 "",
488 "<b>Known limitations</b>",
489 "",
490 "<i>Color scheme switching</i>",
491 "Houdini 21 has no runtime API to switch UI color schemes. "
492 "hou.ui.reloadColorScheme() only re-reads the current .hcs "
493 "from disk (used for live-editing). The 'colors.scheme' "
494 "preference controls the scheme loaded at startup. Use "
495 "Edit > Preferences > UI to change it.",
496 "",
497 "<i>Gestures (removed)</i>",
498 "Multi-touch pinch-to-zoom was attempted via a global Qt "
499 "event filter (HCGestureFilter) but never worked reliably. "
500 "The gesture code caused launch segfaults and was removed.",
501 ]
502 info.setHtml("<br>".join(parts))
503
504 layout.addWidget(info)
505
506 close_btn = QPushButton("Close")
507 close_btn.clicked.connect(win.close)
508 layout.addWidget(close_btn)
509
510 win.show()
511 win.raise_()
512 win.activateWindow()
513
514 @command("Reload HC")
515 def reloadHC(self):
516 import sys
517 from PySide6.QtWidgets import QWidget
518 # Close any HC-owned Qt widgets that would dangle with stale class refs
519 main = hou.qt.mainWindow()
520 for name in ("hc_status_dialog", "hc_status_overlay"):
521 for w in main.findChildren(QWidget, name):
522 w.hide()
523 w.setParent(None)
524 w.deleteLater()
525 # The view-regions poll is a callback from the module about to be
526 # dropped; take it out first and start a fresh one below.
527 from .hcviewregions import stop as _stop_view_regions
528 _stop_view_regions()
529 # Drop every hc.* module so the next import reloads from disk
530 removed = [m for m in list(sys.modules) if m == "hc" or m.startswith("hc.")]
531 for m in removed:
532 del sys.modules[m]
533 import hc
534 # nodegraphhooks is Houdini's module, not hc's, but it holds
535 # `from hc import HCNetworkEditor` -- left alone it keeps dispatching
536 # every network editor event into the class object just dropped.
537 hooks = sys.modules.get("nodegraphhooks")
538 if hooks is not None:
539 import importlib
540 importlib.reload(hooks)
541 hou.ui.setStatusMessage(f"Reloaded hc ({len(removed)} modules)")
542 # Restore the status bar overlay (destroyed above)
543 from .hcstatusbar import HCStatusBar
544 HCStatusBar().show()
545 from .hcviewregions import HCViewRegions
546 HCViewRegions().start()
547 # An open HC Settings tab holds a widget built from the class object of
548 # the module we just dropped, so it would keep running pre-reload code.
549 # reloadActiveInterface() re-runs the pypanel's onCreateInterface()
550 # against the freshly imported module. Unsaved edits in it are lost,
551 # which is the same trade the status bar teardown above makes.
552 hou_tab = self.settingsTab()
553 if hou_tab is not None:
554 hou_tab.reloadActiveInterface()
555
556 @command("Reload Hotkeys")
557 def reloadHotkeys(self):
558 HCBindings().load()
559
560 def initializeNetworkEditors(self):
561 initialized = 0
562 for editor in self.allNetworkEditors():
563 try:
564 editor.initialize()
565 initialized += 1
566 except Exception as e:
567 print(f"[HCSession] Failed to initialize network editor: {e!r}")
568 return initialized
569
570 def _deferToEventLoop(self, callback_name, fn):
571 """Run fn once on the next event-loop turn.
572
573 The pending callback is published on hou.session under callback_name
574 so a repeat call replaces it rather than stacking a second run.
575 Headless (hython running 456.py for a batch load) there is no event
576 loop and no pane to act on, so this is a no-op there.
577 """
578 if not hou.isUIAvailable():
579 return
580 existing = getattr(hou.session, callback_name, None)
581 if existing is not None:
582 try:
583 hou.ui.removeEventLoopCallback(existing)
584 except Exception:
585 pass
586
587 def _callback():
588 try:
589 fn()
590 finally:
591 try:
592 hou.ui.removeEventLoopCallback(_callback)
593 except Exception:
594 pass
595 if getattr(hou.session, callback_name, None) is _callback:
596 delattr(hou.session, callback_name)
597
598 setattr(hou.session, callback_name, _callback)
599 hou.ui.addEventLoopCallback(_callback)
600
601 def initializeNetworkEditorsDeferred(self):
602 self._deferToEventLoop("_hc_init_network_editors_callback",
603 self.initializeNetworkEditors)
604
605 def resetHcnetcursors(self):
606 reset = 0
607 for editor in self.allNetworkEditors():
608 try:
609 editor.resetHcnetcursor()
610 reset += 1
611 except Exception as e:
612 print(f"[HCSession] Failed to reset hcnetcursor: {e!r}")
613 return reset
614
615 def resetHcnetcursorsDeferred(self):
616 """456.py runs while the load is still settling; the editors show the
617 loaded file's networks by the next event-loop turn."""
618 self._deferToEventLoop("_hc_reset_hcnetcursors_callback",
619 self.resetHcnetcursors)
620
621 def updateNodeColors(self):
622 """Recolor HC-managed nodes to the configured default.
623
624 A node counts as managed while its `hc_custom_color` userdata still
625 records the color HC last wrote to it. Color it any other way -- Set
626 Node Colors, Houdini's own color palette, a paste from another scene --
627 and the record stops matching, so the node is left alone from then on.
628
629 The tag used to be a bare "1" with no record of the color, and this
630 method recolored every tagged node unconditionally. Since 456.py calls
631 it on every hip load, a hand-picked color lasted exactly until the next
632 time the file was opened.
633 """
634 from .hcsettings import HCSettings, colorsMatch, parseHex
635 settings = HCSettings()
636 if not settings.nodeColoringEnabled():
637 # Nodes already carrying a tag keep it, so turning coloring back on
638 # resumes maintaining exactly the nodes it was maintaining before.
639 return 0
640 new_color = settings.nodeColor()
641 new_hex = settings.nodeColorHex()
642
643 # Decide first, write second. Writing nothing when nothing changed is
644 # the point -- every setColor marks the hip modified, so an
645 # unconditional pass left a scene you had only opened asking to be
646 # saved -- and collecting the work first means the undo group below is
647 # never opened on a scene that needs none.
648 pending = []
649 # Nodes inside a locked HDA cannot be colored anyway. Skipping them at
650 # the traversal rather than per node means the walk never expands an
651 # asset's contents -- this runs on every hip load.
652 for node in hou.node("/").allSubChildren(recurse_in_locked_nodes=False):
653 tag = node.userData("hc_custom_color")
654 if tag is None:
655 continue
656 try:
657 current = node.color()
658 except hou.Error:
659 continue
660 recorded = parseHex(tag)
661 if recorded is None:
662 # Legacy "1" tags predate recording the color. Adopt them once
663 # -- this load still overwrites whatever they carry -- and they
664 # follow the rule above from then on.
665 if tag != "1":
666 continue
667 elif not colorsMatch(current, hou.Color(recorded)):
668 continue # colored by hand since HC last wrote it
669
670 recolor = not colorsMatch(current, new_color)
671 retag = tag != new_hex
672 if recolor or retag:
673 pending.append((node, recolor, retag))
674
675 count = 0
676 if pending:
677 # One undo entry for the whole scene, not one per node.
678 with hou.undos.group("Update Node Colors"):
679 for node, recolor, retag in pending:
680 try:
681 if recolor:
682 node.setColor(new_color)
683 count += 1
684 if retag:
685 node.setUserData("hc_custom_color", new_hex)
686 except hou.Error:
687 continue
688
689 # Deliberately silent. This runs on every hip load and at startup, so
690 # anything it says is said when you opened a file rather than when you
691 # asked for it -- and now that an unchanged scene writes nothing, what
692 # it had to say was almost always "0 nodes". The count is returned for
693 # callers that do want it.
694 return count
695
696 def keycam(self):
697 tab = self.currentTab()
698 if tab is not None and tab.type() == 'HCSceneViewer':
699 tab.keycam()
700 return
701 scene_viewers = self.allSceneViewers()
702 if scene_viewers:
703 scene_viewers[0].keycam()
704 return
705 hou.ui.setStatusMessage('No scene viewer available', hou.severityType.Error)
706
707 @command("Reload Keycam")
708 def reloadKeycam(self):
709 hou.ui.reloadViewerState('keycam')
710
711 def updateMode(self):
712 """The current update mode's name: 'AutoUpdate', 'Manual', 'OnMouseUp'."""
713 return hou.updateModeSetting().name()
714
715 @command("Update Mode", state="updateMode", choices=UPDATE_MODES)
716 def setUpdateMode(self, mode):
717 """Set the update mode, by hou.updateMode value or by its name."""
718 if isinstance(mode, str):
719 mode = getattr(hou.updateMode, mode)
720 hou.setUpdateMode(mode)
721 hou.ui.setStatusMessage(f"Update mode: {mode.name()}")
722
723 # The three below used to be separate panel entries. They are one
724 # dropdown now; the methods stay for hotkeys and menus.
725
726 def setUpdateModeAuto(self):
727 self.setUpdateMode(hou.updateMode.AutoUpdate)
728
729 def setUpdateModeManual(self):
730 self.setUpdateMode(hou.updateMode.Manual)
731
732 def toggleUpdateMode(self):
733 # hou.updateModeSetting is a function; the old code stringified the
734 # function object and then called the lookup dict, so this never ran.
735 if hou.updateModeSetting() == hou.updateMode.Manual:
736 self.setUpdateMode(hou.updateMode.AutoUpdate)
737 else:
738 self.setUpdateMode(hou.updateMode.Manual)
739
740 def triggerUpdate(self):
741 hou.ui.triggerUpdate()
742
743 @command("Autosave", state="isAutoSave")
744 def toggleAutoSave(self):
745 map = {'0': '1', '1': '0'}
746 hou.setPreference('autoSave', map[hou.getPreference('autoSave')])
747 from .hcstatusbar import HCStatusBar
748 HCStatusBar().updateAutosave()
749
750 def updateMainMenuBar(self):
751 hou.ui.updateMainMenuBar()
752
753
754 """ Utils """
755
756 def projectPath(self):
757 return hou.hipFile.path()
758
759 def removeEventLoopCallbacks(self):
760 callbacks = hou.ui.eventLoopCallbacks()
761 for callback in callbacks:
762 hou.ui.removeEventLoopCallback(callback)
763
764 def recentHipFiles(self):
765 """Houdini's recent hips, most recent first, existing files only.
766
767 HOM exposes no recent-file list (nothing on hou.hipFile or hou.ui),
768 so this reads the HIP block of $HOUDINI_USER_PREF_DIR/file.history,
769 the file Houdini's own File > Open Recent Files strip is built from.
770 """
771 import os
772 base = hou.getenv("HOUDINI_USER_PREF_DIR") or ""
773 try:
774 text = open(os.path.join(base, "file.history")).read()
775 except OSError:
776 return []
777 return [f for f in parseFileHistory(text) if os.path.isfile(f)]
778
779 @command("Open Recent")
780 def openRecent(self):
781 """A fuzzy picker over the recent hips; picking one loads it."""
782 current = hou.hipFile.path()
783 files = [f for f in self.recentHipFiles() if f != current]
784 if not files:
785 hou.ui.setStatusMessage("No other recent files", hou.severityType.Warning)
786 return
787
788 def load(path):
789 try:
790 # Prompts to save unsaved changes first, like File > Open.
791 hou.hipFile.load(path)
792 except hou.OperationInterrupted:
793 return # cancelled at the save prompt
794 except hou.LoadWarning as e:
795 hou.ui.setStatusMessage(str(e), hou.severityType.Warning)
796 except hou.Error as e:
797 hou.ui.setStatusMessage(f"Cannot open {path}: {e}",
798 hou.severityType.Error)
799
800 import os
801 names = [os.path.basename(f) for f in files]
802 list_dict = {}
803 for path, name in zip(files, names):
804 # The directory only when the name alone would be ambiguous.
805 label = path if names.count(name) > 1 else \
806 f"{name} {os.path.basename(os.path.dirname(path))}"
807 list_dict[label] = (lambda p=path: load(p))
808
809 from .hcwidgets import HCWidgets
810 dialog = HCWidgets.SelectionDialog(
811 "Open Recent", list_dict, anchor_geometry=self.mainWindowGeometry())
812 dialog.show()
813 dialog.raise_()
814 dialog.activateWindow()
815
816 @command("Restart Houdini")
817 def restartHoudini(self):
818 import os
819 import subprocess
820 import sys
821
822 # Save current state
823 current_hip = hou.hipFile.path()
824 # Houdini usually names untitled files "untitled.hip" (or .hiplc / .hipnc)
825 if "untitled.hip" not in current_hip.lower():
826 try:
827 hou.hipFile.save()
828 except hou.Error:
829 pass # Continue with restart even if save fails
830
831 # Get the path to the houdini executable
832 # On Linux, this is usually in $HFS/bin/houdini
833 hfs = os.environ.get("HFS")
834 if hfs:
835 executable = os.path.join(hfs, "bin", "houdini")
836 else:
837 executable = "houdini" # Fallback to path
838
839 # Launch new process
840 # start_new_session=True detaches the new process from the current one
841 args = [executable, current_hip]
842 subprocess.Popen(args, close_fds=True, start_new_session=True)
843
844 # Force an immediate low-level exit to bypass any hangs
845 import os
846 os._exit(0)
847
848 def _verifyHipFileSaved(self, file_path, action_label):
849 import os
850
851 saved_path = self._normalizedHipPath(file_path)
852 if not os.path.isfile(saved_path):
853 raise hou.OperationFailed(f"{action_label} reported success, but file was not created: {saved_path}")
854
855 size = os.path.getsize(saved_path)
856 if size <= 0:
857 raise hou.OperationFailed(f"{action_label} reported success, but file is empty: {saved_path}")
858
859 hou.ui.setStatusMessage(
860 f"{action_label}: {saved_path} ({size:,} bytes)",
861 severity=hou.severityType.Message,
862 )
863 return saved_path
864
865 @command("Save")
866 def save(self):
867 try:
868 hou.hipFile.save()
869 self._verifyHipFileSaved(hou.hipFile.path(), "Saved")
870 except (hou.Error, OSError) as e:
871 hou.ui.displayMessage(f"Error saving file: {e}", severity=hou.severityType.Error)
872
873 @command("Save As")
874 def saveAs(self):
875 file_path = hou.ui.selectFile(
876 title="Save As",
877 file_type=hou.fileType.Hip,
878 chooser_mode=hou.fileChooserMode.Write
879 )
880 if file_path:
881 QTimer.singleShot(0, lambda: self._executeSaveAs(file_path))
882
883 def _normalizedHipPath(self, file_path):
884 import os
885
886 # Houdini's file chooser can return paths containing variables like
887 # $HOME/$HIP. hou.hipFile.save() usually expands them, but not in every
888 # chooser/QTimer path, and the error message can remain misleadingly
889 # literal. Normalize before extension checks and saving.
890 file_path = hou.text.expandString(file_path)
891 file_path = os.path.expandvars(os.path.expanduser(file_path))
892
893 if not any(file_path.endswith(ext) for ext in ('.hip', '.hiplc', '.hipnc')):
894 file_path += '.hip'
895 return file_path
896
897 def _executeSaveAs(self, file_path):
898 file_path = self._normalizedHipPath(file_path)
899 try:
900 hou.hipFile.save(file_path)
901 self._verifyHipFileSaved(file_path, "Saved as")
902 except (hou.Error, OSError) as e:
903 hou.ui.displayMessage(f"Error saving file: {e}", severity=hou.severityType.Error)
904
905
906 """ Layout """
907
908 # def clearLayout(self):
909 # tabs = self.allTabs()
910 # for tab in tabs:
911 # if tab != tabs[0]:
912 # tab.close()
913
914 def desktop(self):
915 return hou.ui.curDesktop()
916
917 # def layoutRoot(self):
918 # root = hou.ui.panes()[0].getSplitParent().getSplitParent()
919
920 # def printLayout(self):
921 # panes = hou.ui.panes()
922 # root = panes[0].getSplitParent()
923 # lefts = []
924 # tops = []
925 # for pane in self.panes():
926 # geo = pane.qtScreenGeometry()
927 # lefts.append(geo.left())
928 # tops.append(geo.top())
929 # print(lefts)
930 # print(tops)
931
932 # def renameTabs(self):
933 # tabs = hou.ui.paneTabs()
934 # i = 0
935 # for tab in tabs:
936 # tab.setName('placeholder' + str(i))
937 # i += 1
938 # i = 0
939 # for tab in tabs:
940 # tab.setName('panetab' + str(i))
941 # i += 1
942
943
944 """ Visibility """
945
946 def isVisibleMainMenu(self):
947 value = hou.getPreference('showmenu.val')
948 return int(value)
949
950 def isVisibleMenus(self):
951 """Whether any chrome is showing: main menu, tab chrome, or pane tabs."""
952 if self.isVisibleMainMenu():
953 return 1
954 if any(tab.isChromeVisible() for tab in self.allTabs()):
955 return 1
956 if any(pane.isShowingTabs() for pane in self.allPanes()):
957 return 1
958 return 0
959
960 @command("Hide Shelf")
961 def hideShelf(self):
962 self.desktop().shelfDock().show(0)
963
964 def showMainMenu(self, value):
965 hou.setPreference('showmenu.val', str(value))
966
967 @command("Show Shelf")
968 def showShelf(self):
969 self.desktop().shelfDock().show(1)
970
971 @command("Main Menu", state="isVisibleMainMenu")
972 def toggleMainMenu(self):
973 value = (self.isVisibleMainMenu()+1) % 2
974 self.showMainMenu(value)
975 tab = self.currentTab()
976 if tab is not None and tab.type() == 'HCNetworkEditor':
977 tab.toggleMenu()
978
979 @command("All Menus", state="isVisibleMenus")
980 def toggleMenus(self):
981 visible = self.isVisibleMenus()
982 show = not visible
983 self.showMainMenu(int(show))
984 for tab in self.allTabs():
985 tab.showChrome(show)
986 for pane in self.allPanes():
987 pane.showTabs(show)
988 # Apply (needs to be called twice for some reason)
989 # hou.ui.setHideAllMinimizedStowbars(visible)
990 # hou.ui.setHideAllMinimizedStowbars(visible)
991
992 def toggleAllNetworkControls(self):
993 visible = 0
994 tabs = self.allTabs()
995 for tab in tabs:
996 if tab.isShowingNetworkControls():
997 visible = 1
998 for tab in tabs:
999 tab.showNetworkControls(not visible)
1000
1001 def isStowbarsVisible(self):
1002 return not hou.ui.hideAllMinimizedStowbars()
1003
1004 @command("Stowbars", state="isStowbarsVisible")
1005 def toggleStowbars(self):
1006 if hou.ui.hideAllMinimizedStowbars():
1007 hou.ui.setHideAllMinimizedStowbars(False)
1008 else:
1009 hou.ui.setHideAllMinimizedStowbars(True)
1010 hou.ui.setHideAllMinimizedStowbars(True)
1011
1012 def toggleCurrentPaneTabs(self):
1013 pane = self.currentPane()
1014 if pane is not None:
1015 pane.toggleTabs()
1016
1017 def _paneTabTypeDialog(self, title, action, anchor_geometry=None):
1018 type_map = HCTab(None).paneTabTypes()
1019 list_dict = {name: (lambda t=tab_type: action(t)) for name, tab_type in type_map.items()}
1020 dialog = HCWidgets.SelectionDialog(title, list_dict, anchor_geometry=anchor_geometry)
1021 dialog.show()
1022 dialog.raise_()
1023 dialog.activateWindow()
1024 return dialog
1025
1026 @command("New Tab")
1027 def newTabDialog(self):
1028 pane = self.currentPane()
1029 if pane is None:
1030 hou.ui.setStatusMessage("No pane under cursor", hou.severityType.Warning)
1031 return None
1032 anchor_geometry = pane.qtScreenGeometry()
1033 return self._paneTabTypeDialog("New Tab", pane.newTab, anchor_geometry=anchor_geometry)
1034
1035 def newFloatingPane(self, tab_type, size=None):
1036 """Create a floating panel holding one tab of `tab_type`.
1037
1038 Pass `size` for a panel that should be a free-floating utility window
1039 rather than another tile: it is handed to createFloatingPanel() and the
1040 toplevel is marked as a dialog (see floatWindow), without which a
1041 tiling WM resizes it to the main window's tile. Left None, the panel
1042 keeps Houdini's default behaviour -- which is what the New Floating
1043 Pane dialog wants, since those are full working panes.
1044 """
1045 kwargs = {"immediate": True}
1046 if size is not None:
1047 kwargs["size"] = size
1048 panel = self.desktop().createFloatingPanel(tab_type, **kwargs)
1049 panel.attachToDesktop(True)
1050 for pane in panel.panes():
1051 HCPane(pane).initializeObject(pane)
1052 if size is not None:
1053 tabs = panel.paneTabs()
1054 window = tabs[0].qtParentWindow() if tabs else None
1055 if window is not None:
1056 floatWindow(window, size=size)
1057 window.show()
1058 window.raise_()
1059 window.activateWindow()
1060 hou.ui.setStatusMessage('Created new floating pane', hou.severityType.Message)
1061 return panel
1062
1063 @command("New Floating Pane")
1064 def newFloatingPaneDialog(self):
1065 pane = self.currentPane()
1066 anchor_geometry = pane.qtScreenGeometry() if pane is not None else None
1067 return self._paneTabTypeDialog("New Floating Pane", self.newFloatingPane, anchor_geometry=anchor_geometry)
1068
1069 def isAnyPaneShowingTabs(self):
1070 return any(pane.isShowingTabs() for pane in self.allPanes())
1071
1072 # This was listed as "Toggle All Paths", but it has only ever switched the
1073 # pane tab strips; the paths are toggleAllNetworkControls above.
1074 @command("All Pane Tabs", state="isAnyPaneShowingTabs")
1075 def toggleAllTabs(self):
1076 visible = self.isAnyPaneShowingTabs()
1077 for pane in self.allPanes():
1078 pane.showTabs(not visible)
1079
1080 def cyclablePanes(self):
1081 """Every pane a warp could land in, in reading order.
1082
1083 Houdini has no keyboard focus for panes: a pane-scoped hotkey goes
1084 to whichever pane is under the pointer, so "focusing" a pane means
1085 putting the pointer there. A stowed or collapsed pane reports a
1086 -1 by -1 rectangle and is left out; so is every other pane while
1087 one is maximized, since their rectangles are stale then.
1088 """
1089 panes = []
1090 for pane in self.allPanes():
1091 rect = pane.qtScreenGeometry()
1092 if rect.width() <= 0 or rect.height() <= 0:
1093 continue
1094 panes.append((rect.top(), rect.left(), pane))
1095 maximized = [entry for entry in panes if entry[2].isMaximized()]
1096 if maximized:
1097 panes = maximized
1098 panes.sort(key=lambda entry: entry[:2])
1099 return [entry[2] for entry in panes]
1100
1101 def _cyclePane(self, step):
1102 panes = self.cyclablePanes()
1103 if not panes:
1104 return None
1105 current = self.currentPane()
1106 ids = [pane.hou_pane.id() for pane in panes]
1107 if current is not None and current.hou_pane.id() in ids:
1108 index = (ids.index(current.hou_pane.id()) + step) % len(panes)
1109 else:
1110 index = 0 if step > 0 else len(panes) - 1
1111 target = panes[index]
1112 # Through the compositor: an X warp alone leaves the visible cursor
1113 # where it was (see hcviewregions.move_pointer).
1114 from .hcviewregions import move_pointer
1115 move_pointer(target.qtScreenGeometry().center())
1116 return target
1117
1118 @command("Next Pane")
1119 def focusNextPane(self):
1120 """Warp the pointer to the centre of the next pane in reading order."""
1121 self._cyclePane(1)
1122
1123 @command("Previous Pane")
1124 def focusPrevPane(self):
1125 """Warp the pointer to the centre of the previous pane in reading order."""
1126 self._cyclePane(-1)