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