SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
CLAUDE.md (14.1K)
1 # CLAUDE.md
2
3 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
5 ## Overview
6
7 `hou-control` (hc) is a SideFX Houdini customization package — not a standalone application. There is no build and no package manager, and the only automated check is `tools/check.py` (below). Code here is loaded by Houdini at runtime via the `hou-control.json` package manifest (which adds this directory to `HOUDINI_PATH`) and the `HC_PATH` env var, which the code reads with `hou.getenv("HC_PATH")` to locate config/scripts on disk.
8
9 `tools/check.py` is the only automated check: run it headlessly with
10 `$HFS/bin/hython3.13 tools/check.py` from the repo root. It imports `hc`, binds
11 every `HCMaps` entry (catching commands whose method was renamed or deleted),
12 and exercises the settings merge, the `replaceNode` rewire and the node grid
13 snap. It cannot cover anything needing `hou.ui` or a live pane — that still has
14 to be driven by hand in Houdini.
15
16 To drive a *running* Houdini from outside it, use `houdini-agent/hrun`, which
17 pipes a snippet through `houdini-agent/bridge.py` to the `hrpyc` server that
18 `uiready.py` starts on port 18811:
19
20 ```
21 houdini-agent/hrun 'hou.node("/obj").children()'
22 ```
23
24 It needs `houdini-agent/.venv` (`python3 -m venv`, then
25 `pip install -r houdini-agent/mcpserver/requirements.txt`) and tells you so if
26 it is missing. Anything it evaluates is marshalled onto Houdini's main thread
27 by `hc.hcmainthread` — HOM is not thread-safe and rpyc answers on a connection
28 thread.
29
30 Everything runs inside Houdini's embedded Python 3.13. To exercise changes, reload from within Houdini:
31 - From the `hc` main menu: **Reload Hotkeys**, **Reload Colors**, **Reload Keycam**.
32 - Full package reload: `hou.ui.reloadPackage(...)` (see `HCSession.reloadHC`).
33 - Hotkey JSON changes take effect via `HCSession.reloadHotkeys()` which calls `HCBindings().load()` (assigns from `hc_hotkeys.json`, auto-clearing any conflicts via `hou.hotkeys.findConflicts`).
34
35 ## Architecture
36
37 ### The `hc` Python package (`python3.13libs/hc/`)
38
39 The core abstraction is a set of wrapper classes around Houdini's `hou` API objects. Each wrapper holds the underlying `hou_*` object and adds higher-level behavior. Classes are re-exported from `hc/__init__.py`.
40
41 Key hierarchy:
42 - **`HCSession`** (`hcsession.py`) — top-level entry point. Enumerates panes/tabs/viewports, controls desktop-wide visibility (menus, stowbars, shelf), update mode, hotkey reloads, and launches the HC Panel. Most menu items and hotkey bindings go through `HCSession`.
43 - **`HCPane`** wraps `hou.Pane`; knows how to split, resize, convert its current `hou.PaneTab` into the right `HC*` subclass via `HCPane.convertTab`.
44 - **`HCTab`** → **`HCPathTab`** → **`HCNetworkEditor`** / **`HCSceneViewer`**. `convertTab` dispatches on `hou.paneTabType` to the correct subclass; `HCPathTab` is used for Parm and DetailsView tabs. Tab type is also identified via a string `.type()` method (e.g. `'HCNetworkEditor'`) used in `isinstance`-style branching throughout `HCSession` and `HCMaps`.
45 - **`HCBindings`** — loads hotkey assignments by reading `hc_hotkeys.json` and calling `hou.hotkeys.addAssignment`. Before each assignment, `hou.hotkeys.findConflicts` identifies any ancestor/descendant bindings using the same key and clears them. The JSON keys are Houdini symbol paths like `h.pane.gview.foo`; the context is derived via `symbol.rpartition('.')[0]`.
46 - **`hccommands.py`** — the `@command("Label")` decorator and the registry that reads it. A command's panel label lives on the method itself. Where a command appears is the class it is defined on, so put it on the narrowest class whose every instance can run it. When that varies within a class, name a predicate method with `available="hasNetworkControls"`, and `bind()` leaves the command out of tabs where it returns false. There is no list of tab type strings to keep in step with the class tree.
47 - **`HCMaps`** (`hcmaps.py`) — generated, not hand-written. `commands(session, pane, tab)` binds every `@command` reachable from those three objects, filtered by tab type. `HCSession.hcPanel()` calls it and hands the result to `SelectionDialog`.
48 - **`hcstate.py`** — all per-pane and per-network state, in one place with one key scheme. Wrappers are stateless (Houdini hands out fresh SWIG wrappers per callback and holding one is a crash), so anything persisting between events goes in a `Store` here. Two scopes: `hcstate.PANE` and `hcstate.NETWORK` (`(pane id, network path)`). Entries for closed panes are swept periodically — pane ids get reused.
49 - **`hcnetcursorimage.py`** — paints the network cursor's picture (a `hou.NetworkImage` outline) on demand into `$HOUDINI_TEMP_DIR/hc_hcnetcursor/`, one file per cell size and colour. Houdini cannot tint a background image, which is why the `hcnetcursor_color` setting needs a painted file; this replaced 144 static PNGs in `config/hcnetcursor_assets`, a path old hips may still carry in their background image lists (`is_cursor_image` matches on basename so they are filtered out).
50 - **`hcschema.py`** — one `Setting` per configurable value: kind, default, label, range. `HCSettings.DEFAULTS` and every control in `HCSettingsPanel` are generated from it.
51 - **`HCWidgets`** (`hcwidgets.py`) — PySide6 widgets, notably `SelectionDialog` used for the HC Panel.
52 - **`HCSettingsPanel`** (`hcsettings.py`) — the settings form. It is a plain `QWidget`, not a dialog, because it is served as a Python Panel (see `python_panels/` below) and Houdini adopts it into a pane tab. Keep it a `QWidget`: a `QDialog` in a pane tab keeps dialog behaviour, and Houdini overwrites the `objectName` of a widget it adopts, so don't try to find it by object name.
53
54 When adding a new command: implement it on the appropriate wrapper (`HCSession`/`HCPane`/`HC*Tab`) and decorate it with `@command("Label")`. That is the whole registration — there is no separate map to update. If it should be hotkey-bound, add a Houdini symbol entry to `hc_hotkeys.json`; menus still need their own `scriptItem` in the XML.
55
56 A command that has a state gets a control in the panel. `@command("Grid", state="isGridVisible")` draws a checkbox showing the getter's value (the getter is a method *name* on the same class, so `tools/check.py` can verify it exists; `'0'`/`'1'` pref strings are coerced correctly). `@command("Grid Mode", state="gridMode", choices=(("No Grid", "0"), ...))` draws a dropdown and the method receives the chosen value. Label toggles by what they control (`"Grid"`, not `"Toggle Grid"`) — the checkbox already says it toggles. In the panel, Enter on a toggle runs it and closes as before; Enter on a dropdown opens it; working the control with the mouse applies in place and keeps the panel open. Plain commands stay plain text items — a widget per row would make the Replace Node picker slow.
57
58 When adding a new setting: add a `Setting` to `hcschema.SCHEMA`. Defaults, the settings panel control, and the widget type all follow from it.
59
60 HC settings cannot live in Houdini's own **Edit > Preferences** window. That window is a compiled-in pane (`h.pane.preferences`) with a fixed page list; no `HOUDINI_PATH` directory contributes pages to it, and HOM exposes only value access (`hou.getPreference` / `setPreference` / `removePreference` / `savePreferences`), no page registration. The Python Panel above is the closest native equivalent.
61
62 ### Non-package Python (`python3.13libs/`)
63
64 These are recognized by Houdini's startup/event system by filename convention:
65 - `uiready.py` — runs once when the UI is ready; instantiates `HCSession` and calls `reloadHotkeys` + `toggleStowbars`.
66 - `hc/hcviewregions.py` — publishes the window-local rectangles of every SceneViewer and NetworkEditor tab to the user's compositor (`ccectl touchpad-view-regions x11:<id> ...`). `cce-fx` turns a two-finger swipe over Houdini into an emulated view drag (Space + button), because a trackpad gesture never survives Xwayland; the regions confine that drag to the view panes so the parameter editor and every other pane scroll normally. Every visible toplevel of the process is published, most with a zero-area rectangle: a window the compositor has not heard about gets the whole-window drag, and the HC Panel, HC Status and every dialog are separate X11 windows of the same app (`clear` means whole-window too, so an empty list is sent as `0,0,0,0`). Started from `uiready.py`, restarted by `reloadHC()`, and inert without `ccectl` on `PATH`. Verified headlessly in a `cce-shadow` session by injecting `ccectl pointer-scroll ... finger` over each pane and diffing screenshots.
67 - `nodegraphhooks.py` — Houdini's network-editor event hook. Implements `createEventHandler(uievent, pending_actions)` and dispatches `KeyboardEvent`s through a local `keymap` dict that calls into `HCNetworkEditor`. Return `(None, True)` to consume the event, `(None, False)` to let Houdini handle it.
68
69 ### Scripts (`scripts/`)
70
71 - `123.py` / `456.py` are Houdini's magic filenames: `123.py` runs when Houdini starts without a `.hip`, `456.py` runs after any `.hip` load. Currently used for "open last file" tracking via `$HOUDINI_USER_PREF_DIR/st_data/state.json`.
72 - `OnCreated.py` — node OnCreated event script.
73 - `hc_hotkeys.json` (at repo root) — source of truth for keybindings, loaded by `HCBindings`. Conflicts with existing bindings are detected and cleared automatically.
74 - `hc_settings.json` (at repo root) — runtime settings for the `keycam` viewer state and node graph defaults, read through `HCSettings`, which overlays the file on `HCSettings.DEFAULTS` (generated from `hcschema.SCHEMA`) so a missing key never reaches a caller. Use `settings.section("keycam", "units")` rather than chained `.get()` — the chained form returns `None` and raises on the next `.get`.
75
76 ### Viewer states (`viewer_states/`)
77
78 `keycam.py` is a custom Houdini viewer state ("keycam" navigator) registered via the viewer state API. `HCSession.reloadKeycam()` calls `hou.ui.reloadViewerState('keycam')`.
79
80 ### Configuration (`config/`)
81
82 Houdini-format config files:
83 - `UIDark.hcs` — UI color scheme.
84 - `3DSceneColors.dark` — viewport color scheme.
85 - `NodeGraphDark.inc` + `NodeGraphCommon.inc` — node graph styling (included via Houdini's `.inc` mechanism).
86 - `NodeShapes/`, `NodeShapeFlags/` — custom node shape definitions.
87
88 These are reloaded via `HCSession.reloadColorSchemes()` (`hou.ui.reloadColorScheme()` + `hou.ui.reloadViewportColorSchemes()`).
89
90 ### Other asset directories
91
92 - `otls/` — HDAs (Houdini Digital Assets). `otls/backup/` is gitignored.
93 - `desktop/hc_attached.desk` — main desktop layout (single-window, multi-pane). `desktop/hc_detached.desk` — minimal layout (single SceneViewer pane) used when `desktop_mode` is `detached` and floating panels handle the rest.
94 - `python_panels/` — Python Panel interface definitions. `hc_settings.pypanel` registers **HC Settings** as a real pane tab type, so it appears in the pane tab menu, docks and splits like any built-in panel. Its `onCreateInterface()` returns an `HCSettingsPanel`. `HCSession.openSettings()` finds an open one by active interface name (`HCSettingsPanel.INTERFACE_NAME`, which must match the `name` attribute in the XML — `tools/check.py` asserts this) and otherwise floats a new one. Note Houdini scans `python_panels/` only at **startup**: a newly added `.pypanel` needs a restart, or `hou.pypanel.installFile(path)` — `reloadHC()` will not pick it up. `reloadHC()` does call `reloadActiveInterface()` on an open settings tab, so the widget is rebuilt from the reloaded module rather than running stale code.
95 - `radialmenu/`, `toolbar/`, `presets/`, `vex/`, `help/` — Houdini conventional subdirectories loaded by path.
96 - `MainMenuCommon.xml`, `OPmenu.xml`, `PARMmenu.xmlx`, `ParmGearMenu.xml` — menu definitions. Each `scriptItem` typically does `from hc import HCSession; HCSession().someMethod()`.
97
98 ## Conventions
99
100 - Wrappers never subclass `hou.*` types; they store the hou object on `self.hou_tab` / `self.hou_pane` and delegate.
101 - Wrap *tabs and panes*, not nodes. `HCPathTab.pwd()` returns a plain `hou.Node`. There used to be an `HCNode` wrapper; the package bypassed it 22 calls to 1, two of its sixteen methods called `hou` APIs that do not exist, and it shadowed `hou.Node.path()` so `Show Path Message` raised. Node helpers that earn their keep are module functions instead — `hcvisibility.childCategory` / `collect_visible_nodes`, `hcgeometryutils.merged_visible_geo`.
102 - `.type()` methods return string discriminators (`'HCNetworkEditor'`, `'HCSceneViewer'`, `'HCParameterTab'`, `'HCPathTab'`). Use them for *filtering* — which tabs a command applies to, which tabs to collect — not for dispatching behaviour. When behaviour differs by tab kind, put a method on each tab class and let the caller iterate: `isChromeVisible()` / `showChrome(visible)` are the worked example, and they replaced two if/elif ladders in `HCSession`. When you do filter, remember `'HCParameterTab'` is a sibling of `'HCPathTab'` (Parm tabs return the former, DetailsView the latter) — code wanting both checks `tab.type() in ('HCPathTab', 'HCParameterTab')`.
103 - Per-pane or per-network state belongs in an `hcstate.Store`, never a bare module dict. Bare dicts drifted into four incompatible key schemes and never evicted closed panes.
104 - Toggle-style methods often use a small string map (e.g. `{'0': '1', '1': '0'}`) because Houdini prefs are stored as strings.
105 - Paths to bundled files are built from `hou.getenv("HC_PATH")` — don't hardcode absolute paths.
106 - **`HCWidgets.SelectionDialog.execute()` surfaces command errors**: it wraps the callable from `self.list_dict` in a try/except that prints a traceback and writes the exception to the status bar. A HC Panel command that fails now says so — check the console. (Before this it swallowed everything and the user just saw "nothing happens.")
107 - **`HCSession.reloadHC()` destroys HC-owned Qt widgets** (`hc_status_overlay`, `hc_status_dialog`) and must re-create any that should persist (e.g. status bar). Forgetting to restore them causes them to disappear until Houdini restarts.
108 - **PySide6 widget API mismatches**: `setOpenExternalLinks()` belongs to `QTextBrowser`, not `QTextEdit`. Using the wrong base class causes a silent `AttributeError` inside SelectionDialog callables.