git.lucas.co / hou-control
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()