SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcsettings.py (24.6K)
1 import copy
2 import hou
3 import json
4 from pathlib import Path
5
6 from PySide6.QtCore import QFileSystemWatcher, QRegularExpression, Qt
7 from PySide6.QtGui import QRegularExpressionValidator
8 from PySide6.QtWidgets import (
9 QCheckBox,
10 QComboBox,
11 QDialogButtonBox,
12 QDoubleSpinBox,
13 QFormLayout,
14 QGroupBox,
15 QHBoxLayout,
16 QLabel,
17 QLineEdit,
18 QPushButton,
19 QScrollArea,
20 QSlider,
21 QSpinBox,
22 QTabWidget,
23 QVBoxLayout,
24 QWidget,
25 )
26
27 from . import hcschema
28 from .hcschema import DESKTOP_MODES
29
30 # prefs() sits on the network editor's hot path -- nodegraphhooks reads it on
31 # every UI event, and a single overlay update reads it several times over. Hold
32 # the parsed file and re-read only when its mtime/size changes, so an external
33 # edit (or the settings panel writing it) is still picked up immediately.
34 _cache_stat = None
35 _cache_prefs = None
36
37
38 def parseHex(value):
39 """'#rrggbb' (or 'rrggbb') -> an (r, g, b) tuple of floats, or None.
40
41 None means "not a color", and every caller has to decide what to do about
42 it rather than being handed a silent substitute.
43 """
44 text = str(value).strip().lstrip("#")
45 if len(text) != 6:
46 return None
47 try:
48 return tuple(int(text[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
49 except ValueError:
50 return None
51
52
53 def formatHex(color):
54 """An (r, g, b) tuple or a hou.Color -> '#rrggbb'."""
55 rgb = color.rgb() if hasattr(color, "rgb") else color
56 return "#" + "".join(f"{max(0, min(255, round(c * 255))):02x}" for c in rgb)
57
58
59 def _schemaDefaultRGB(path):
60 """The declared default of a `color` setting, as an (r, g, b) tuple."""
61 setting = hcschema.lookup(path)
62 return parseHex(setting.default if setting else None) or (0.0, 0.0, 0.0)
63
64
65 def colorsMatch(a, b, tolerance=1.0 / 255.0):
66 """True if two hou.Colors are equal to within one 8-bit step.
67
68 Node colors round-trip through float32, so the 0x60/255 we write does not
69 come back bit-identical and an exact compare never matches.
70 """
71 return all(abs(x - y) <= tolerance for x, y in zip(a.rgb(), b.rgb()))
72
73
74 def _dig(data, path):
75 for key in path:
76 if not isinstance(data, dict):
77 return None
78 data = data.get(key)
79 return data
80
81
82 class HCSettings:
83 # Generated from hcschema.SCHEMA -- add settings there, not here.
84 DEFAULTS = hcschema.defaults()
85
86 def _path(self):
87 base = hou.getenv("HC_PATH")
88 if not base:
89 return None
90 return Path(base) / "hc_settings.json"
91
92 @classmethod
93 def _merged(cls, defaults, overrides):
94 """Recursively overlay `overrides` onto `defaults`, returning a copy."""
95 merged = dict(defaults)
96 for key, value in overrides.items():
97 base = merged.get(key)
98 if isinstance(base, dict) and isinstance(value, dict):
99 merged[key] = cls._merged(base, value)
100 else:
101 merged[key] = value
102 return merged
103
104 def prefs(self):
105 """Settings from disk, overlaid on DEFAULTS.
106
107 Callers index straight into nested sections (prefs()['keycam']['units']
108 ['delta_r']), so a key missing from the file must never produce a
109 missing key here -- unmerged reads used to raise AttributeError on None
110 in keycam.py and hcguides.py.
111 """
112 global _cache_stat, _cache_prefs
113
114 path = self._path()
115 if path is None:
116 return copy.deepcopy(self.DEFAULTS)
117 try:
118 st = path.stat()
119 except OSError:
120 return copy.deepcopy(self.DEFAULTS)
121
122 stat_key = (str(path), st.st_mtime_ns, st.st_size)
123 if _cache_stat == stat_key and _cache_prefs is not None:
124 return _cache_prefs
125
126 try:
127 data = json.loads(path.read_text())
128 except (OSError, ValueError) as e:
129 print(f"[HCSettings] could not read {path}: {e}")
130 return copy.deepcopy(self.DEFAULTS)
131 if not isinstance(data, dict):
132 return copy.deepcopy(self.DEFAULTS)
133
134 _cache_prefs = self._merged(self.DEFAULTS, data)
135 _cache_stat = stat_key
136 return _cache_prefs
137
138 def prefsCopy(self):
139 """A deep copy of prefs(), safe to mutate before writing back."""
140 return copy.deepcopy(self.prefs())
141
142 def get(self, key, default=None):
143 return self.prefs().get(key, default)
144
145 def section(self, *path):
146 """A nested section by path, always a dict (never None).
147
148 Replaces `prefs().get('keycam').get('units')` chains, which raise
149 AttributeError as soon as any level is absent.
150 """
151 node = self.prefs()
152 for key in path:
153 if not isinstance(node, dict):
154 return {}
155 node = node.get(key)
156 if node is None:
157 return {}
158 return node if isinstance(node, dict) else {}
159
160 def set(self, key, value):
161 data = self.prefsCopy()
162 data[key] = value
163 return self.write(data)
164
165 def write(self, data):
166 path = self._path()
167 if path is None:
168 return False
169 path.write_text(json.dumps(data, indent=4) + "\n")
170 return True
171
172 # Values in effect when this session started. Held on hou.session, not a
173 # module global, because reloadHC re-imports this module and would lose a
174 # global -- and then every restart-only setting would read as unchanged.
175 _STARTUP_ATTR = "_hc_settings_at_startup"
176
177 def captureStartupValues(self):
178 """Record the settings this session started with. uiready calls it
179 once the startup dialog in 123.py has had its say."""
180 snapshot = self.prefsCopy()
181 setattr(hou.session, self._STARTUP_ATTR, snapshot)
182 return snapshot
183
184 def startupValues(self):
185 snapshot = getattr(hou.session, self._STARTUP_ATTR, None)
186 if snapshot is None:
187 snapshot = self.captureStartupValues()
188 return snapshot
189
190 def restartPending(self):
191 """Paths of restart-only settings whose saved value differs from the
192 value this session started with -- what the panel's notice lists."""
193 current = self.prefs()
194 startup = self.startupValues()
195 pending = []
196 for path in hcschema.restart_paths():
197 was = _dig(startup, path)
198 if was is None:
199 # A setting added to the schema after this session started
200 # (a reload after an edit) is not a change the user made.
201 was = hcschema.lookup(path).default
202 if _dig(current, path) != was:
203 pending.append(path)
204 return pending
205
206 def desktopMode(self):
207 return self.get("desktop_mode", self.DEFAULTS["desktop_mode"])
208
209 def setDesktopMode(self, mode):
210 if mode not in DESKTOP_MODES:
211 raise ValueError(f"invalid desktop mode: {mode}")
212 self.set("desktop_mode", mode)
213
214 def nodeGraph(self):
215 return self.section("node_graph")
216
217 def keycam(self, *path):
218 return self.section("keycam", *path)
219
220 def hcnetcursorEnabled(self):
221 return bool(self.nodeGraph().get("hcnetcursor"))
222
223 def hcnetcursorColorHex(self):
224 return self.colorHex("node_graph", "hcnetcursor_color")
225
226 def hcnetcursorMargin(self):
227 """Gap between the drawn cursor box and the nodes it frames, in units."""
228 setting = hcschema.lookup(("node_graph", "hcnetcursor_margin"))
229 try:
230 margin = float(self.nodeGraph().get("hcnetcursor_margin", setting.default))
231 except (TypeError, ValueError):
232 margin = setting.default
233 low, high = setting.range
234 return min(max(margin, low), high)
235
236 def dropSwapEnabled(self):
237 """Dropping a node on another node's cell swaps their positions."""
238 return bool(self.nodeGraph().get("drop_swap", True))
239
240 def gridSnapEnabled(self):
241 """Hard grid snapping: the wide snap radius and the post-action sweep."""
242 return bool(self.nodeGraph().get("grid_snap", True))
243
244 def nodeColoringEnabled(self):
245 """Whether HC colors nodes at all.
246
247 Governs the automatic half: the colour and tag OnCreated applies, and
248 the maintenance pass in HCSession.updateNodeColors(). Set Node Colors
249 and Reset Node Colors still work -- invoking those is asking for a
250 colour outright.
251 """
252 return bool(self.nodeGraph().get("node_coloring"))
253
254 def nodeShape(self):
255 return self.nodeGraph().get("node_shape")
256
257 def colorRGB(self, *path):
258 """A `color` setting as an (r, g, b) tuple, addressed by schema path.
259
260 An unparseable value falls back to that setting's own declared default,
261 never to a hardcoded stand-in somewhere else -- node_color used to
262 answer with a blue-purple that appears nowhere in hcschema, so a typo
263 in the hex silently produced a color the settings panel never showed.
264 """
265 stored = self.section(*path[:-1]).get(path[-1])
266 return parseHex(stored) or _schemaDefaultRGB(path)
267
268 def color(self, *path):
269 """A `color` setting as a hou.Color."""
270 return hou.Color(self.colorRGB(*path))
271
272 def colorHex(self, *path):
273 """A `color` setting normalised to '#rrggbb'."""
274 return formatHex(self.colorRGB(*path))
275
276 def nodeColorRGB(self):
277 return self.colorRGB("node_graph", "node_color")
278
279 def nodeColor(self):
280 """The configured default node color as a hou.Color."""
281 return hou.Color(self.nodeColorRGB())
282
283 def nodeColorHex(self):
284 """The configured default node color, normalised to '#rrggbb'.
285
286 This is what gets recorded in a node's `hc_custom_color` userdata, so
287 it has to come from the same place as nodeColor().
288 """
289 return formatHex(self.nodeColorRGB())
290
291
292 class HCSettingsPanel(QWidget):
293 """The HC Settings form.
294
295 Built as a plain QWidget, not a QDialog, because it is served to Houdini
296 through python_panels/hc_settings.pypanel: onCreateInterface() returns one
297 of these and Houdini reparents it into a PythonPanel pane tab. That makes
298 HC Settings a real pane tab type -- dockable, splittable, listed in the
299 pane tab menu next to Houdini's own panels -- rather than a floater
300 parented to the main window.
301
302 Consequences of being a pane tab rather than a dialog: no window title (the
303 tab carries the label), no Qt.Tool flag, no fixed size (the pane decides),
304 and no Close button (the tab's own close does that).
305 """
306
307 # Must match the interface name in python_panels/hc_settings.pypanel.
308 # There is no OBJECT_NAME any more: the old one existed so openSettings()
309 # could findChild() the dialog under the main window, and Houdini
310 # overwrites objectName on a widget it adopts into a pane tab anyway. The
311 # tab is found by its active interface name instead.
312 INTERFACE_NAME = "hc_settings"
313 SLIDER_STEPS = 1000
314
315 def __init__(self, parent=None):
316 super().__init__(parent)
317 self.settings = HCSettings()
318
319 # path tuple, e.g. ("keycam","guides","axis_size") -> widget
320 self._fields = {}
321 # path tuple -> the marker beside a restart-only setting's widget
322 self._restart_marks = {}
323
324 self.tabs = QTabWidget()
325
326 buttons = QDialogButtonBox(QDialogButtonBox.Save)
327 buttons.accepted.connect(self._save)
328
329 # The notice under the tabs: hidden until a restart-only setting has
330 # been saved with a new value, then it names the settings and offers
331 # the restart.
332 self._restart_banner = QLabel()
333 self._restart_banner.setWordWrap(True)
334 self._restart_banner.setStyleSheet("color: #e0a040;")
335 self._restart_button = QPushButton("Restart Houdini")
336 self._restart_button.clicked.connect(self._restart)
337 notice = QHBoxLayout()
338 notice.addWidget(self._restart_banner, 1)
339 notice.addWidget(self._restart_button)
340
341 layout = QVBoxLayout(self)
342 layout.addWidget(self.tabs)
343 layout.addLayout(notice)
344 layout.addWidget(buttons)
345
346 self._watcher = QFileSystemWatcher(self)
347 self._watcher.fileChanged.connect(self._onFileChanged)
348 self._startWatching()
349 self._rebuild()
350
351 def showEvent(self, event):
352 # Only re-arm the watcher here. As a dialog this also rebuilt the form,
353 # which was harmless because show() meant "opening". A pane tab gets a
354 # showEvent every time it becomes the current tab in its pane, and
355 # rebuilding there would throw away unsaved edits on every tab switch.
356 # The watcher runs whether or not the tab is visible, so an external
357 # edit is still picked up without this.
358 self._startWatching()
359 super().showEvent(event)
360
361 def _startWatching(self):
362 path = self.settings._path()
363 if path is None:
364 return
365 if str(path) not in self._watcher.files():
366 self._watcher.addPath(str(path))
367
368 def _onFileChanged(self, path):
369 # Some editors replace the file on save, which drops the watch.
370 self._startWatching()
371 self._rebuild()
372
373
374 """ Form builder """
375
376
377 def _rebuild(self):
378 """Build the form from the schema, filling in values from prefs().
379
380 Walking the schema rather than the file means every declared setting
381 gets a control even when the file omits it.
382 """
383 self._fields.clear()
384 self._restart_marks.clear()
385 while self.tabs.count():
386 widget = self.tabs.widget(0)
387 self.tabs.removeTab(0)
388 if widget:
389 widget.deleteLater()
390
391 values = self.settings.prefs()
392
393 scalars = {k: v for k, v in hcschema.SCHEMA.items()
394 if isinstance(v, hcschema.Setting)}
395 sections = {k: v for k, v in hcschema.SCHEMA.items() if isinstance(v, dict)}
396
397 if scalars:
398 self.tabs.addTab(self._createTabPage(scalars, values, ()), "General")
399 for key, section in sections.items():
400 page = self._createTabPage(section, values.get(key, {}), (key,))
401 self.tabs.addTab(page, hcschema.label_for(key))
402 self._updateRestartIndicators()
403
404 RESTART_MARK = "\u27f3" # the clockwise-arrow glyph beside restart-only rows
405
406 def _updateRestartIndicators(self):
407 """Show which saved restart-only settings differ from this session's
408 startup values, on their rows and in the notice under the tabs."""
409 pending = self.settings.restartPending()
410 for path, mark in self._restart_marks.items():
411 if path in pending:
412 mark.setText(f"{self.RESTART_MARK} restart required")
413 mark.setStyleSheet("color: #e0a040; font-weight: bold;")
414 else:
415 mark.setText(self.RESTART_MARK)
416 mark.setStyleSheet("color: #808080;")
417 # Nothing under the tabs until a restart is actually owed; the row
418 # glyph and its tooltip carry the explanation the rest of the time.
419 if pending:
420 names = ", ".join(hcschema.label_for(path[-1], hcschema.lookup(path))
421 for path in pending)
422 self._restart_banner.setText(
423 f"{self.RESTART_MARK} Restart Houdini to apply: {names}")
424 self._restart_banner.setVisible(bool(pending))
425 self._restart_button.setVisible(bool(pending))
426
427 def _restart(self):
428 from .hcsession import HCSession
429 HCSession().restartHoudini()
430
431 def _createTabPage(self, schema, values, path):
432 scroll = QScrollArea()
433 scroll.setWidgetResizable(True)
434 content = QWidget()
435 layout = QVBoxLayout(content)
436 self._addSection(schema, values, layout, path)
437 layout.addStretch()
438 scroll.setWidget(content)
439 return scroll
440
441 def _addSection(self, schema, values, parent_layout, path):
442 if not isinstance(values, dict):
443 values = {}
444
445 scalars = {k: v for k, v in schema.items() if isinstance(v, hcschema.Setting)}
446 sections = {k: v for k, v in schema.items() if isinstance(v, dict)}
447
448 # Ungrouped rows first, then one box per `group` in order of first
449 # appearance. The groups are headings only -- the field paths, and
450 # so the file, are the same as for a flat form.
451 groups = {}
452 for key, setting in scalars.items():
453 groups.setdefault(setting.group, {})[key] = setting
454 for name, members in groups.items():
455 if name is None:
456 self._addForm(members, values, parent_layout, path)
457 for name, members in groups.items():
458 if name is not None:
459 group = QGroupBox(name)
460 group_layout = QVBoxLayout(group)
461 self._addForm(members, values, group_layout, path)
462 parent_layout.addWidget(group)
463
464 for key, section in sections.items():
465 group = QGroupBox(hcschema.label_for(key))
466 group_layout = QVBoxLayout(group)
467 self._addSection(section, values.get(key, {}), group_layout, path + (key,))
468 parent_layout.addWidget(group)
469
470 def _addForm(self, scalars, values, parent_layout, path):
471 if scalars:
472 form = QFormLayout()
473 parent_layout.addLayout(form)
474 for key, setting in scalars.items():
475 value = values.get(key, setting.default)
476 widget = self._makeWidget(setting, value)
477 self._fields[path + (key,)] = widget
478 label = hcschema.label_for(key, setting)
479 tip = setting.help or ""
480 if setting.restart:
481 tip = (tip + " " if tip else "") + "Takes effect on next start."
482 if tip:
483 widget.setToolTip(tip)
484 row = widget
485 if setting.restart:
486 # The widget stays the field; only the row gets a marker.
487 row = QWidget()
488 row_layout = QHBoxLayout(row)
489 row_layout.setContentsMargins(0, 0, 0, 0)
490 mark = QLabel()
491 mark.setToolTip("Read only while Houdini starts.")
492 row_layout.addWidget(widget, 1)
493 row_layout.addWidget(mark)
494 self._restart_marks[path + (key,)] = mark
495 form.addRow(label + ":", row)
496
497 def _makeWidget(self, setting, value):
498 """Widget from the declared kind -- never guessed from the value's type."""
499 kind = setting.kind
500
501 if kind in ("bool", "flag"):
502 widget = QCheckBox()
503 widget.setChecked(bool(value))
504 return widget
505
506 if kind == "slider":
507 return self._makeSliderWidget(setting, value)
508
509 if kind == "float":
510 widget = QDoubleSpinBox()
511 widget.setDecimals(setting.decimals)
512 widget.setRange(-1e9, 1e9)
513 widget.setSingleStep(0.1)
514 widget.setValue(float(value))
515 return widget
516
517 if kind == "int":
518 widget = QSpinBox()
519 widget.setRange(-1000000, 1000000)
520 widget.setValue(int(value))
521 return widget
522
523 if kind == "color":
524 return self._makeColorWidget(setting, value)
525
526 if kind == "choice":
527 widget = QComboBox()
528 for label, data in setting.choices:
529 widget.addItem(label, data)
530 index = widget.findData(value)
531 if index < 0:
532 # Preserve an unrecognised value rather than silently resetting.
533 widget.addItem(str(value), value)
534 index = widget.count() - 1
535 widget.setCurrentIndex(index)
536 return widget
537
538 return QLineEdit(str(value))
539
540 def _makeColorWidget(self, setting, value):
541 """A '#rrggbb' field with a swatch that opens Houdini's color editor.
542
543 `color` was declared in the schema but had no branch here, so it fell
544 through to the bare QLineEdit at the bottom of _makeWidget -- the only
545 way to set the default node color was to type six hex digits, and a
546 typo was accepted and then silently replaced by a fallback.
547 """
548 rgb = parseHex(value) or parseHex(setting.default) or (0.0, 0.0, 0.0)
549
550 container = QWidget()
551 layout = QHBoxLayout(container)
552 layout.setContentsMargins(0, 0, 0, 0)
553
554 field = QLineEdit(formatHex(rgb))
555 # Permissive enough to type through: a partial hex is valid mid-edit,
556 # and _readWidget is what refuses to write an incomplete one.
557 field.setValidator(QRegularExpressionValidator(
558 QRegularExpression("#?[0-9A-Fa-f]{0,6}"), field))
559
560 swatch = QPushButton()
561 swatch.setFixedWidth(44)
562 swatch.setToolTip("Pick a color")
563
564 def _paint():
565 parsed = parseHex(field.text())
566 # No swatch at all while the text is not a color, so a half-typed
567 # or bad value is visible rather than showing a stale color.
568 swatch.setStyleSheet(
569 f"background-color: {formatHex(parsed)}; border: 1px solid #202020;"
570 if parsed else ""
571 )
572
573 def _pick():
574 chosen = hou.ui.selectColor(hou.Color(parseHex(field.text()) or rgb))
575 if chosen is not None: # None means the dialog was cancelled
576 field.setText(formatHex(chosen))
577
578 field.textChanged.connect(_paint)
579 swatch.clicked.connect(_pick)
580 _paint()
581
582 layout.addWidget(swatch)
583 layout.addWidget(field, 1)
584
585 container._hexfield = field
586 return container
587
588 def _makeSliderWidget(self, setting, value):
589 """A QSlider + QDoubleSpinBox that stay in sync."""
590 vmin, vmax = setting.range
591 decimals = setting.decimals
592 steps = self.SLIDER_STEPS
593
594 container = QWidget()
595 layout = QHBoxLayout(container)
596 layout.setContentsMargins(0, 0, 0, 0)
597
598 slider = QSlider(Qt.Horizontal)
599 slider.setMinimum(0)
600 slider.setMaximum(steps)
601 slider.setPageStep(steps // 20)
602
603 spin = QDoubleSpinBox()
604 spin.setDecimals(decimals)
605 spin.setRange(vmin, vmax)
606 spin.setSingleStep(round((vmax - vmin) / 100, decimals))
607 spin.setValue(float(value))
608
609 span = (vmax - vmin) or 1.0
610
611 def _onSliderChanged(pos):
612 spin.blockSignals(True)
613 spin.setValue(round(vmin + (pos / steps) * span, decimals))
614 spin.blockSignals(False)
615
616 def _onSpinChanged(v):
617 slider.blockSignals(True)
618 slider.setValue(int((v - vmin) / span * steps))
619 slider.blockSignals(False)
620
621 slider.valueChanged.connect(_onSliderChanged)
622 spin.valueChanged.connect(_onSpinChanged)
623 slider.setValue(int((float(value) - vmin) / span * steps))
624
625 layout.addWidget(slider, 3)
626 layout.addWidget(spin, 1)
627
628 container._spinbox = spin
629 return container
630
631
632 """ IO """
633
634
635 def _readWidget(self, setting, widget):
636 """Read a widget back in the JSON type the schema declares."""
637 if isinstance(widget, QCheckBox):
638 # `flag` settings round-trip as 0/1 ints, `bool` as true/false.
639 return int(widget.isChecked()) if setting.kind == "flag" else widget.isChecked()
640 if hasattr(widget, "_hexfield"):
641 # Never write an unparseable color to the file: nodeColor() would
642 # fall back to the default while the panel kept showing the bad
643 # text, so the scene and the form would disagree with no sign why.
644 parsed = parseHex(widget._hexfield.text())
645 return formatHex(parsed) if parsed else setting.default
646 if hasattr(widget, "_spinbox"):
647 return widget._spinbox.value()
648 if isinstance(widget, QDoubleSpinBox):
649 return widget.value()
650 if isinstance(widget, QSpinBox):
651 return widget.value()
652 if isinstance(widget, QComboBox):
653 data = widget.currentData()
654 return data if data is not None else widget.currentText()
655 if isinstance(widget, QLineEdit):
656 return widget.text()
657 return setting.default
658
659 def _save(self):
660 data = self.settings.prefsCopy()
661 for path, widget in self._fields.items():
662 setting = hcschema.lookup(path)
663 if setting is None:
664 continue
665 self._assign(data, path, self._readWidget(setting, widget))
666 self.settings.write(data)
667 # The file watcher rebuilds the form too, but not synchronously.
668 self._updateRestartIndicators()
669
670 def _assign(self, data, path, value):
671 node = data
672 for key in path[:-1]:
673 if key not in node or not isinstance(node[key], dict):
674 node[key] = {}
675 node = node[key]
676 node[path[-1]] = value