SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
python3.13libs/hc/hcschema.py (9.3K)
1 """Declarative schema for hc_settings.json.
2
3 Every setting is declared once, here. Three things read from this:
4
5 - ``HCSettings.DEFAULTS`` is generated from it, so a section missing from the
6 file always merges back to something complete. Before this, ``keycam`` -- the
7 largest section -- had no defaults at all, and ``keycam.py`` and
8 ``hcguides.py`` both did unguarded ``prefs().get('keycam').get('units')``
9 chains that raise AttributeError on None the moment the section is absent.
10 - ``HCSettingsPanel`` builds its controls from it rather than by reflecting
11 over whatever happens to be in the JSON, so deleting a key from the file no
12 longer makes its control disappear.
13 - The widget for a setting comes from its declared ``kind``, not from guessing
14 at the Python type of the current value. The guess was wrong for
15 ``delta_ow`` and ``delta_z``: both are step magnitudes that happen to be 1,
16 and the panel rendered them as checkboxes.
17
18 Adding a setting means adding a Setting here and nothing else.
19 """
20
21 DESKTOP_MODES = ("attached", "detached")
22 NODE_SHAPES = (
23 "rect", "rounded_rect", "circle", "diamond",
24 "tilted_rect", "trapezoid_down", "trapezoid_up",
25 )
26 ZOOM_CENTERS = (("Mouse Cursor", "mouse_cursor"), ("HC Cursor", "hc_cursor"))
27
28
29 class Setting:
30 """One leaf setting.
31
32 kind is one of:
33 bool -- JSON true/false, checkbox
34 flag -- JSON 0/1 integer, checkbox (round-trips as int)
35 int -- spin box
36 float -- spin box
37 slider -- slider + spin box, needs `range`
38 text -- line edit
39 choice -- combo box, needs `choices`
40 color -- "#rrggbb" line edit
41
42 restart marks a value that is read only while Houdini starts (uiready.py,
43 123.py). The settings panel flags such rows and shows a restart notice
44 once the saved value differs from the one this session started with.
45
46 group is a heading the settings panel draws the row under. It is purely
47 visual: the JSON stays flat, so readers of e.g. node_graph.grid_x_step
48 are untouched. A nested dict in SCHEMA would have grouped the panel too,
49 but it also renames every key in the file.
50 """
51
52 __slots__ = ("kind", "default", "label", "choices", "range", "decimals",
53 "help", "restart", "group")
54
55 def __init__(self, kind, default, label=None, choices=None, range=None,
56 decimals=2, help=None, restart=False, group=None):
57 self.kind = kind
58 self.default = default
59 self.label = label
60 self.choices = choices
61 self.range = range
62 self.decimals = decimals
63 self.help = help
64 self.restart = restart
65 self.group = group
66
67
68 # Nested dicts mirror the JSON. A dict value is a section; a Setting is a leaf.
69 SCHEMA = {
70 "desktop_mode": Setting(
71 "choice", "attached", label="Desktop Mode",
72 choices=tuple((m.title(), m) for m in DESKTOP_MODES),
73 restart=True,
74 ),
75 "startup": {
76 "show_prompt": Setting(
77 "bool", True, label="Show Startup Prompt",
78 help="The 'Open last file?' dialog shown when Houdini starts with "
79 "no .hip. With it off, Houdini starts file-less.",
80 restart=True,
81 ),
82 "default_autosave_state": Setting(
83 "bool", True, label="Default Autosave State", restart=True,
84 ),
85 "show_main_menu": Setting(
86 "bool", False, label="Show Main Menu Bar",
87 help="Houdini's main menu bar at startup. Hidden, its runnable "
88 "entries are in the status circle's menu, which can also "
89 "show the bar again.",
90 restart=True,
91 ),
92 },
93 "keycam": {
94 "guides": {
95 "axis_size": Setting("float", 0.05, decimals=4),
96 "tie_axis_to_radius": Setting("flag", 0),
97 "bbox": Setting("flag", 0, label="Bounding Box"),
98 "cam_axis": Setting("flag", 0, label="Camera Axis"),
99 "cam_geo": Setting("flag", 0, label="Camera Geometry"),
100 "pivot_axis": Setting("flag", 1),
101 "pivot_2d": Setting("flag", 0, label="Pivot 2D"),
102 "pivot_3d": Setting("flag", 0, label="Pivot 3D"),
103 "perim": Setting("flag", 0, label="Perimeter"),
104 "ray": Setting("flag", 0),
105 },
106 "startup": {
107 "center_on_geo": Setting("flag", 1),
108 "lock_cam": Setting("flag", 1, label="Lock Camera"),
109 "reset": Setting("flag", 1),
110 },
111 "units": {
112 "delta_t": Setting("float", 0.2, label="Translate Step"),
113 "delta_r": Setting("float", 15.0, label="Rotate Step (degrees)"),
114 # delta_z and delta_ow are declared so the panel shows them and the
115 # file round-trips, but nothing reads them yet -- keycam.py only
116 # pulls delta_r and delta_t.
117 "delta_z": Setting("float", 1.0, label="Zoom Step"),
118 "delta_ow": Setting("float", 1.0, label="Ortho Width Step"),
119 },
120 # Guides, Startup and Units are sections and box themselves; this
121 # one is a loose value, so it gets a heading like the node graph rows.
122 "drag_sensitivity": Setting(
123 "slider", 0.25, range=(0.0, 1.0), group="Mouse",
124 help="How far a mouse drag tumbles the camera, from smooth to twitchy.",
125 ),
126 },
127 "node_graph": {
128 # Groups are panel headings only; the keys stay flat in the file.
129 "node_shape": Setting("choice", "rect", choices=tuple((s, s) for s in NODE_SHAPES),
130 group="Nodes"),
131 "node_coloring": Setting(
132 "bool", True, label="Custom Node Coloring", group="Nodes",
133 help="Color new nodes with the colour below and keep them in sync. "
134 "Off leaves every node the colour Houdini gives it.",
135 ),
136 "node_color": Setting("color", "#607070", group="Nodes"),
137 "zoom_center": Setting("choice", "mouse_cursor", choices=ZOOM_CENTERS,
138 group="Navigation"),
139 "current_node_arrow_color": Setting(
140 "color", "#618f8f", label="Current Node Arrow Color", group="Navigation",
141 help="The off-screen current-node arrow drawn over the network editor.",
142 ),
143 "hcnetcursor": Setting(
144 "bool", True, label="Network Cursor", group="Network Cursor",
145 help="The grid cursor in the network editor. Off also returns the "
146 "unmodified f key to Houdini's own frame-selection.",
147 ),
148 "hcnetcursor_color": Setting(
149 "color", "#d8b34b", label="Cursor Color", group="Network Cursor",
150 help="The cursor's outline. The picture is painted on demand, so "
151 "a change shows on the next network editor event.",
152 ),
153 "hcnetcursor_margin": Setting(
154 "slider", 0.15, range=(0.0, 1.0), label="Cursor Margin", group="Network Cursor",
155 help="Gap between the drawn box and the nodes in the cells it "
156 "covers, in network units, the same on every side. "
157 "Selection and movement always use the whole cells.",
158 ),
159 "grid_snap": Setting(
160 "bool", True, label="Hard Grid Snap", group="Grid",
161 help="Every drag, Tab-menu placement and box resize lands on the "
162 "grid. Off restores Houdini's magnetic snap radius and its "
163 "node-to-node alignment guides.",
164 ),
165 "drop_swap": Setting(
166 "bool", True, label="Drop to Swap", group="Grid",
167 help="Dragging a node onto another node's cell, or stepping it "
168 "there with the move keys, moves that node into the cell the "
169 "first one left. Two nodes wired directly to each other also "
170 "trade places in the chain.",
171 ),
172 "grid_x_step": Setting("slider", 2.0, range=(0.25, 8.0), group="Grid"),
173 "grid_y_step": Setting("slider", 1.0, range=(0.25, 8.0), group="Grid"),
174 "node_center_offset_x": Setting("slider", 0.5, range=(0.0, 2.0), group="Grid"),
175 "node_center_offset_y": Setting("slider", 0.15, range=(0.0, 2.0), group="Grid"),
176 },
177 }
178
179
180 def defaults(schema=None):
181 """The SCHEMA collapsed to a plain nested dict of default values."""
182 if schema is None:
183 schema = SCHEMA
184 out = {}
185 for key, value in schema.items():
186 out[key] = defaults(value) if isinstance(value, dict) else value.default
187 return out
188
189
190 def restart_paths(schema=None, prefix=()):
191 """Tuple paths of every Setting declared restart=True."""
192 if schema is None:
193 schema = SCHEMA
194 out = []
195 for key, value in schema.items():
196 if isinstance(value, dict):
197 out.extend(restart_paths(value, prefix + (key,)))
198 elif value.restart:
199 out.append(prefix + (key,))
200 return out
201
202
203 def lookup(path, schema=None):
204 """Return the Setting at a tuple path, or None if the path is not declared."""
205 node = schema if schema is not None else SCHEMA
206 for key in path:
207 if not isinstance(node, dict) or key not in node:
208 return None
209 node = node[key]
210 return node if isinstance(node, Setting) else None
211
212
213 def label_for(key, setting=None):
214 if setting is not None and setting.label:
215 return setting.label
216 return key.replace("_", " ").title()