SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcstate.py (5K)
1 """Per-pane and per-network state, kept in one place with one key scheme.
2
3 The HC wrappers are deliberately stateless: Houdini hands out fresh SWIG
4 wrappers on every callback and holding one past the current call is a
5 dangling-pointer crash (see hcleader for the segfault that taught us). So
6 anything that has to persist between events lives here instead of on the
7 wrapper.
8
9 That state used to sit in module globals scattered across five files, each
10 keyed differently -- ``(pane.id(), pwd().path())`` in one place, ``pane.id()``
11 in two others, ``editor.name()`` in a fourth. They disagreed: the hcnetcursor
12 was tracked per network-within-pane while the selection signature compared
13 against it was tracked per pane, so descending into a subnetwork compared the
14 child's selection against the parent's.
15
16 Two scopes are legitimate and both are offered:
17
18 ``PANE`` -- belongs to the pane regardless of what it is looking at
19 (modifier state, focused parameter, the grid pref mirror).
20 ``NETWORK`` -- belongs to a specific network inside a specific pane
21 (hcnetcursor position, selection and overlay signatures).
22
23 Entries for panes Houdini has closed are swept periodically; pane ids get
24 reused, and a reopened pane inheriting a stale cursor position was the
25 practical symptom.
26 """
27
28 import time
29
30 import hou
31
32
33 PANE = "pane"
34 NETWORK = "network"
35
36 _SWEEP_INTERVAL = 10.0
37
38 _stores = {}
39 _last_sweep = 0.0
40
41
42 def _houTab(obj):
43 """Accept an HC wrapper, a hou.PaneTab, or anything with .pane()."""
44 return getattr(obj, "hou_tab", obj)
45
46
47 def paneId(obj):
48 """Stable id for the pane holding `obj`, or a per-object fallback."""
49 tab = _houTab(obj)
50 pane = None
51 if hasattr(tab, "pane"):
52 try:
53 pane = tab.pane()
54 except hou.ObjectWasDeleted:
55 pane = None
56 if pane is not None:
57 return pane.id()
58 if hasattr(tab, "id"):
59 return tab.id()
60 return id(tab)
61
62
63 def paneKey(obj):
64 return paneId(obj)
65
66
67 def networkKey(obj):
68 """(pane id, network path) -- distinguishes each network within a pane."""
69 tab = _houTab(obj)
70 try:
71 path = tab.pwd().path()
72 except (AttributeError, hou.ObjectWasDeleted):
73 path = ""
74 return (paneId(obj), path)
75
76
77 _KEYERS = {PANE: paneKey, NETWORK: networkKey}
78
79
80 class Store:
81 """A named dict of per-pane or per-network values.
82
83 Construct one at module level and use it in place of a bare global dict:
84
85 _cursors = hcstate.Store("hcnetcursor", hcstate.NETWORK)
86 state = _cursors.get(editor)
87 _cursors.set(editor, state)
88 """
89
90 def __init__(self, name, scope):
91 if scope not in _KEYERS:
92 raise ValueError(f"unknown state scope: {scope}")
93 self.name = name
94 self.scope = scope
95 self._data = {}
96 _stores[name] = self
97
98 def key(self, obj):
99 return _KEYERS[self.scope](obj)
100
101 def get(self, obj, default=None):
102 return self._data.get(self.key(obj), default)
103
104 def set(self, obj, value):
105 self._data[self.key(obj)] = value
106 sweep()
107 return value
108
109 def setdefault(self, obj, factory):
110 """Value for `obj`, calling `factory()` to create it if absent."""
111 key = self.key(obj)
112 if key not in self._data:
113 self._data[key] = factory()
114 sweep()
115 return self._data[key]
116
117 def pop(self, obj, default=None):
118 return self._data.pop(self.key(obj), default)
119
120 def clear(self):
121 self._data.clear()
122
123 def __len__(self):
124 return len(self._data)
125
126 def _evict(self, live_pane_ids):
127 for key in list(self._data):
128 pane_id = key[0] if isinstance(key, tuple) else key
129 if pane_id not in live_pane_ids:
130 del self._data[key]
131
132
133 def livePaneIds():
134 ids = set()
135 desktop = hou.ui.curDesktop() if hou.isUIAvailable() else None
136 if desktop is not None:
137 for pane in desktop.panes():
138 ids.add(pane.id())
139 for panel in hou.ui.floatingPanels():
140 if panel.qtParentWindow() is None:
141 continue
142 for pane in panel.panes():
143 ids.add(pane.id())
144 return ids
145
146
147 def sweep(force=False):
148 """Drop entries for panes that no longer exist.
149
150 Rate-limited, because writes happen on the network editor's event path.
151 """
152 global _last_sweep
153 # Cheapest check first: this runs on every state write, which for the
154 # hcnetcursor means every network editor UI event.
155 now = time.monotonic()
156 if not force and (now - _last_sweep) < _SWEEP_INTERVAL:
157 return
158 _last_sweep = now
159 if not hou.isUIAvailable():
160 return
161
162 live = livePaneIds()
163 if not live:
164 # A desktop swap can momentarily report nothing; never evict on that.
165 return
166 for store in _stores.values():
167 store._evict(live)
168
169
170 def info():
171 """Entry counts per store, for debugging."""
172 return {name: len(store) for name, store in sorted(_stores.items())}
173
174
175 def clearAll():
176 for store in _stores.values():
177 store.clear()