houdini-node-library 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. hnl/__init__.py +3 -0
  2. hnl/__main__.py +5 -0
  3. hnl/_houdini/OPmenu.xml +15 -0
  4. hnl/_houdini/python/hnl_bridge/__init__.py +2 -0
  5. hnl/_houdini/python/hnl_bridge/autostart.py +12 -0
  6. hnl/_houdini/python/hnl_bridge/cmdio.py +98 -0
  7. hnl/_houdini/python/hnl_bridge/export.py +292 -0
  8. hnl/_houdini/python/hnl_bridge/extras.py +439 -0
  9. hnl/_houdini/python/hnl_bridge/hdas.py +137 -0
  10. hnl/_houdini/python/hnl_bridge/importer.py +533 -0
  11. hnl/_houdini/python/hnl_bridge/launcher.py +83 -0
  12. hnl/_houdini/python/hnl_bridge/scan.py +392 -0
  13. hnl/_houdini/python/hnl_bridge/server.py +416 -0
  14. hnl/_houdini/python/hnl_bridge/structure.py +122 -0
  15. hnl/_houdini/python/hnl_bridge/userset.py +342 -0
  16. hnl/_houdini/python3.11libs/uiready.py +7 -0
  17. hnl/_houdini/python3.13libs/uiready.py +7 -0
  18. hnl/_houdini/toolbar/hnl.shelf +42 -0
  19. hnl/app.py +146 -0
  20. hnl/core/__init__.py +1 -0
  21. hnl/core/cmdio.py +38 -0
  22. hnl/core/cmdscan.py +38 -0
  23. hnl/core/cmdtext.py +87 -0
  24. hnl/core/collect.py +212 -0
  25. hnl/core/index.py +219 -0
  26. hnl/core/library.py +474 -0
  27. hnl/core/model.py +152 -0
  28. hnl/core/refrules.py +128 -0
  29. hnl/core/settings.py +108 -0
  30. hnl/core/structure.py +195 -0
  31. hnl/core/thumbmake.py +67 -0
  32. hnl/core/trust.py +77 -0
  33. hnl/core/vocab.py +46 -0
  34. hnl/houdini/__init__.py +1 -0
  35. hnl/houdini/client.py +215 -0
  36. hnl/houdini/launcher.py +110 -0
  37. hnl/houdini/package.py +129 -0
  38. hnl/ui/__init__.py +1 -0
  39. hnl/ui/app_icon.py +57 -0
  40. hnl/ui/detail_cmd.py +215 -0
  41. hnl/ui/detail_header.py +207 -0
  42. hnl/ui/detail_info.py +130 -0
  43. hnl/ui/detail_panel.py +219 -0
  44. hnl/ui/detail_tables.py +145 -0
  45. hnl/ui/empty_state.py +34 -0
  46. hnl/ui/grid.py +322 -0
  47. hnl/ui/hou_icons.py +203 -0
  48. hnl/ui/import_dialog.py +305 -0
  49. hnl/ui/import_flow.py +233 -0
  50. hnl/ui/import_logic.py +483 -0
  51. hnl/ui/item_actions.py +117 -0
  52. hnl/ui/item_model.py +144 -0
  53. hnl/ui/item_table.py +74 -0
  54. hnl/ui/main_toolbar.py +72 -0
  55. hnl/ui/main_window.py +400 -0
  56. hnl/ui/md_pane.py +420 -0
  57. hnl/ui/md_render.py +225 -0
  58. hnl/ui/rebuild.py +32 -0
  59. hnl/ui/register/__init__.py +5 -0
  60. hnl/ui/register/capture_dialogs.py +120 -0
  61. hnl/ui/register/dialog.py +393 -0
  62. hnl/ui/register/logic.py +137 -0
  63. hnl/ui/register/meta_form.py +162 -0
  64. hnl/ui/register/picker.py +42 -0
  65. hnl/ui/register/protocol.py +17 -0
  66. hnl/ui/register/refs_tab.py +314 -0
  67. hnl/ui/register/simple_tabs.py +240 -0
  68. hnl/ui/register/structure_tab.py +21 -0
  69. hnl/ui/register/thumb_panel.py +320 -0
  70. hnl/ui/register/workers.py +55 -0
  71. hnl/ui/register_flow.py +55 -0
  72. hnl/ui/settings_dialog.py +348 -0
  73. hnl/ui/sidebar.py +247 -0
  74. hnl/ui/structure_view.py +231 -0
  75. hnl/ui/tag_edit.py +329 -0
  76. hnl/ui/theme.py +211 -0
  77. hnl/ui/theme_icons.py +61 -0
  78. hnl/ui/thumbs.py +93 -0
  79. hnl/ui/vcs_controller.py +431 -0
  80. hnl/ui/vcs_dialogs.py +63 -0
  81. hnl/ui/vcs_history.py +71 -0
  82. hnl/ui/vcs_ops.py +121 -0
  83. hnl/ui/vcs_style.py +62 -0
  84. hnl/ui/vocab_combo.py +73 -0
  85. hnl/vcs/__init__.py +33 -0
  86. hnl/vcs/base.py +142 -0
  87. hnl/vcs/none.py +69 -0
  88. hnl/vcs/svn.py +393 -0
  89. houdini_node_library-0.1.0.dist-info/METADATA +128 -0
  90. houdini_node_library-0.1.0.dist-info/RECORD +94 -0
  91. houdini_node_library-0.1.0.dist-info/WHEEL +5 -0
  92. houdini_node_library-0.1.0.dist-info/entry_points.txt +5 -0
  93. houdini_node_library-0.1.0.dist-info/licenses/LICENSE +21 -0
  94. houdini_node_library-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,439 @@
1
+ """cmd に入らない情報(付箋・ネットワークボックス・ドット・シーン設定)の保存と復元。"""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ import re
7
+
8
+ import hou
9
+
10
+ from . import scan
11
+
12
+ VERSION = 1
13
+ # set -g に最初から入っている変数(ユーザー定義ではない)
14
+ BUILTIN_VARS = frozenset("""F FF T FPS NFRAMES TEND TLENGTH TSTART FSTART FEND RFSTART RFEND UNITLENGTH
15
+ UNITMASS CHOPMOTIONSAMPLES oldFF ACTIVETAKE E EYE HIP HIPFILE HIPNAME JOB PI POSE status HFS HH HOME""".split())
16
+
17
+
18
+ def sub_children(n):
19
+ try:
20
+ return n.allSubChildren(recurse_in_locked_nodes=False)
21
+ except TypeError:
22
+ return n.allSubChildren()
23
+
24
+
25
+ def rel_path(node, base) -> str:
26
+ return node.path()[len(base.path()):].lstrip("/")
27
+
28
+
29
+ def _rgb(c) -> list:
30
+ return [round(float(x), 6) for x in c.rgb()]
31
+
32
+
33
+ def _locked(n) -> bool:
34
+ return bool(getattr(n, "isLockedHDA", lambda: False)())
35
+
36
+
37
+ def _dot_source(d):
38
+ it = d.inputItem()
39
+ for _ in range(1000):
40
+ if not isinstance(it, hou.NetworkDot):
41
+ break
42
+ it = it.inputItem()
43
+ return it
44
+
45
+
46
+ def _overlaps(s, rects) -> bool:
47
+ x, y = s.position()
48
+ w, h = s.size()
49
+ return any(x < r[2] and x + w > r[0] and y < r[3] and y + h > r[1] for r in rects)
50
+
51
+
52
+ def collect(parent, top_nodes, whole: bool, scope=None) -> dict:
53
+ """parent 配下の付箋・ボックス・ドットを集める。whole=False なら parent 直下は選択に関係するものだけ。"""
54
+ sel = set(top_nodes)
55
+ ex = {"version": VERSION, "source_parent": parent.path(), "stickies": [], "boxes": [], "dots": []}
56
+ nets = [parent] + [m for n in top_nodes for m in (n,) + tuple(sub_children(n))
57
+ if m.isNetwork() and not _locked(m)]
58
+ rects = [(n.position()[0], n.position()[1], n.position()[0] + n.size()[0],
59
+ n.position()[1] + n.size()[1]) for n in top_nodes]
60
+ for net in nets:
61
+ top = net == parent
62
+ sh = None # ロックされた HDA の中のネットワーク: 出荷時から持つ項目は除く
63
+ if scope is not None and not top:
64
+ sh = scope.shipped_items(net)
65
+ if sh is False:
66
+ continue
67
+ rel = "" if top else rel_path(net, parent)
68
+ flt = top and not whole
69
+ for b in net.networkBoxes():
70
+ if sh and b.name() in sh["boxes"]:
71
+ continue
72
+ names = [n.name() for n in b.nodes() if not flt or n in sel]
73
+ if flt and not names and not b.isSelected():
74
+ continue
75
+ ex["boxes"].append({
76
+ "parent": rel, "name": b.name(), "pos": list(b.position()), "size": list(b.restoredSize()),
77
+ "color": _rgb(b.color()), "comment": b.comment(), "minimized": b.isMinimized(),
78
+ "members": names})
79
+ for s in net.stickyNotes():
80
+ if sh and s.name() in sh["stickies"]:
81
+ continue
82
+ if flt and not (s.isSelected() or _overlaps(s, rects)):
83
+ continue
84
+ ex["stickies"].append({
85
+ "parent": rel, "name": s.name(), "text": s.text(), "pos": list(s.position()),
86
+ "size": list(s.restoredSize()), "color": _rgb(s.color()), "text_color": _rgb(s.textColor()),
87
+ "text_size": s.textSize(), "draw_background": s.drawBackground(), "minimized": s.isMinimized()})
88
+ for d in net.networkDots():
89
+ if sh and d.name() in sh["dots"]:
90
+ continue
91
+ if flt:
92
+ src = _dot_source(d)
93
+ if src not in sel or not any(c.outputItem() in sel for c in d.outputConnections()):
94
+ continue
95
+ it = d.inputItem()
96
+ inp = None
97
+ if isinstance(it, hou.NetworkDot):
98
+ inp = {"kind": "dot", "name": it.name()}
99
+ elif it is not None:
100
+ inp = {"kind": "node", "name": it.name(), "out": d.inputItemOutputIndex()}
101
+ outs = [{"node": c.outputItem().name(), "idx": c.inputIndex()} for c in d.outputConnections()
102
+ if isinstance(c.outputItem(), hou.Node) and (not flt or c.outputItem() in sel)]
103
+ ex["dots"].append({"parent": rel, "name": d.name(), "pos": list(d.position()),
104
+ "color": _rgb(d.color()), "pinned": d.isPinned(), "input": inp, "outputs": outs})
105
+ return ex
106
+
107
+
108
+ def top_rects(ex: dict) -> list:
109
+ """parent 直下の付箋・ボックス・ドットの矩形 (x0, y0, x1, y1)。"""
110
+ out = []
111
+ for k in ("boxes", "stickies"):
112
+ for e in ex.get(k, []):
113
+ if not e["parent"]:
114
+ out.append((e["pos"][0], e["pos"][1], e["pos"][0] + e["size"][0], e["pos"][1] + e["size"][1]))
115
+ out += [(d["pos"][0], d["pos"][1], d["pos"][0], d["pos"][1]) for d in ex.get("dots", []) if not d["parent"]]
116
+ return out
117
+
118
+ # ---------------------------------------------------------------- 検証(extras.json は他人から届く入力)
119
+
120
+ def _is_num(x, lo=-1e7, hi=1e7) -> bool:
121
+ return isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x) and lo <= x <= hi
122
+
123
+
124
+ def _vec(v, n=2, lo=-1e6, hi=1e6) -> bool:
125
+ return isinstance(v, (list, tuple)) and len(v) == n and all(_is_num(x, lo, hi) for x in v)
126
+
127
+
128
+ def _color(v) -> bool:
129
+ return _vec(v, 3, 0.0, 1.0)
130
+
131
+
132
+ def _node_name(s) -> bool:
133
+ """親を含まない単一のノード名(/ や .. を含まない)。"""
134
+ return (isinstance(s, str) and 0 < len(s) <= 256 and "/" not in s and "\\" not in s
135
+ and s not in (".", "..") and s.isprintable())
136
+
137
+
138
+ def _rel(s) -> bool:
139
+ """取り込み先からの相対パス。空は取り込み先そのもの。絶対パスや .. は不可。"""
140
+ return s == "" or (isinstance(s, str) and len(s) <= 1024 and all(_node_name(p) for p in s.split("/")))
141
+
142
+
143
+ def _text(s) -> bool:
144
+ return isinstance(s, str) and len(s) <= 200000
145
+
146
+
147
+ def _int(x, hi=10000) -> bool:
148
+ return isinstance(x, int) and not isinstance(x, bool) and 0 <= x <= hi
149
+
150
+
151
+ def _valid_box(e) -> bool:
152
+ return (isinstance(e, dict) and _rel(e.get("parent")) and _node_name(e.get("name")) and _vec(e.get("pos"))
153
+ and _vec(e.get("size"), 2, 0, 1e5) and _color(e.get("color")) and _text(e.get("comment", ""))
154
+ and isinstance(e.get("minimized", False), bool)
155
+ and isinstance(e.get("members"), list) and len(e["members"]) <= 100000
156
+ and all(_node_name(m) for m in e["members"]))
157
+
158
+
159
+ def _valid_sticky(e) -> bool:
160
+ return (isinstance(e, dict) and _rel(e.get("parent")) and _node_name(e.get("name")) and _text(e.get("text", ""))
161
+ and _vec(e.get("pos")) and _vec(e.get("size"), 2, 0, 1e5) and _color(e.get("color"))
162
+ and _color(e.get("text_color")) and _is_num(e.get("text_size"), 0, 1000)
163
+ and isinstance(e.get("draw_background"), bool) and isinstance(e.get("minimized", False), bool))
164
+
165
+
166
+ def _valid_dot(e) -> bool:
167
+ if not (isinstance(e, dict) and _rel(e.get("parent")) and _node_name(e.get("name")) and _vec(e.get("pos"))
168
+ and _color(e.get("color")) and isinstance(e.get("pinned"), bool)):
169
+ return False
170
+ inp = e.get("input")
171
+ if inp is not None:
172
+ if not (isinstance(inp, dict) and inp.get("kind") in ("dot", "node") and _node_name(inp.get("name"))):
173
+ return False
174
+ if inp["kind"] == "node" and not _int(inp.get("out")):
175
+ return False
176
+ outs = e.get("outputs")
177
+ return (isinstance(outs, list) and len(outs) <= 10000
178
+ and all(isinstance(o, dict) and _node_name(o.get("node")) and _int(o.get("idx")) for o in outs))
179
+
180
+
181
+ def sanitize(ex) -> tuple[dict, list[str]]:
182
+ """extras の項目を検証し、不正な項目を除いた dict と警告を返す。"""
183
+ warns: list[str] = []
184
+ if not isinstance(ex, dict):
185
+ return {}, ["extras.json の形式が不正なため適用しませんでした"]
186
+ out: dict = {}
187
+ sp = ex.get("source_parent")
188
+ if isinstance(sp, str) and sp.startswith("/") and len(sp) <= 1024:
189
+ out["source_parent"] = sp
190
+ for key, ok, label in (("boxes", _valid_box, "ネットワークボックス"), ("stickies", _valid_sticky, "付箋"),
191
+ ("dots", _valid_dot, "ドット")):
192
+ raw = ex.get(key, [])
193
+ if not isinstance(raw, list):
194
+ warns.append("%s の一覧が不正なため適用しませんでした" % label)
195
+ continue
196
+ good = [e for e in raw if ok(e)]
197
+ if len(good) != len(raw):
198
+ warns.append("不正な %s %d 件を適用しませんでした" % (label, len(raw) - len(good)))
199
+ out[key] = good
200
+ rm = ex.get("removed_nodes", [])
201
+ if isinstance(rm, list):
202
+ out["removed_nodes"] = [r for r in rm if isinstance(r, str) and 0 < len(r) <= 1024 and not r.startswith("/")
203
+ and all(seg and seg != ".." and seg != "." for seg in r.split("/")) and _plain(r)][:2000]
204
+ elif rm:
205
+ warns.append("削除するノードの一覧が不正なため適用しませんでした")
206
+ wi = ex.get("editable_wires", [])
207
+ if isinstance(wi, list):
208
+ out["editable_wires"] = [e for e in wi[:20000] if isinstance(e, dict) and isinstance(e.get("node"), str)
209
+ and e["node"] and _rel(e["node"]) and _int(e.get("idx"), 100000)
210
+ and (e.get("src") is None or _node_name(e.get("src"))) and _int(e.get("out", 0), 100000)]
211
+ elif wi:
212
+ warns.append("接続の一覧が不正なため適用しませんでした")
213
+ if ex.get("scene") is not None:
214
+ sc, w = sanitize_scene(ex["scene"])
215
+ out["scene"] = sc
216
+ warns += w
217
+ return out, warns
218
+
219
+
220
+ def sanitize_scene(s) -> tuple[dict, list[str]]:
221
+ """シーン設定を検証する。不正な項目は除き、警告を返す。"""
222
+ warns: list[str] = []
223
+ if not isinstance(s, dict):
224
+ return {}, ["シーン設定の形式が不正なため適用しませんでした"]
225
+ out: dict = {}
226
+ if _is_num(s.get("fps"), 0.001, 1000):
227
+ out["fps"] = float(s["fps"])
228
+ elif "fps" in s:
229
+ warns.append("シーン設定の fps が不正なため適用しませんでした")
230
+ for k in ("frame_range", "playback_range"):
231
+ v = s.get(k)
232
+ if _vec(v, 2, -1e7, 1e7) and v[0] <= v[1]:
233
+ out[k] = [float(v[0]), float(v[1])]
234
+ elif k in s:
235
+ warns.append("シーン設定の %s が不正なため適用しませんでした" % k)
236
+ if _is_num(s.get("frame")):
237
+ out["frame"] = float(s["frame"])
238
+ elif "frame" in s:
239
+ warns.append("シーン設定の frame が不正なため適用しませんでした")
240
+ g = s.get("globals", {})
241
+ if not isinstance(g, dict):
242
+ warns.append("グローバル変数の一覧が不正なため適用しませんでした")
243
+ g = {}
244
+ good = {}
245
+ for k, v in g.items():
246
+ why = scan.env_name_problem(k)
247
+ if why:
248
+ warns.append("グローバル変数 %s は設定しませんでした(%s)" % (str(k)[:40], why))
249
+ elif not isinstance(v, str) or len(v) > 4096 or not _plain(v):
250
+ warns.append("グローバル変数 %s の値が不正なため設定しませんでした" % k)
251
+ else:
252
+ good[k] = v
253
+ out["globals"] = good
254
+ return out, warns
255
+
256
+
257
+ def _plain(v: str) -> bool:
258
+ """値に制御文字(改行など)を含まない。"""
259
+ return all(c >= " " and c != chr(127) for c in v)
260
+
261
+
262
+ def apply(base, ex: dict, shift=(0.0, 0.0), name_map=None) -> tuple[list, list]:
263
+ """extras を base 配下に復元する。(作成した項目, 警告) を返す。"""
264
+ nm = name_map or {}
265
+ items: list = []
266
+ ex, warns = sanitize(ex)
267
+ sv = hou.Vector2(shift[0], shift[1])
268
+
269
+ def container(rel):
270
+ if not rel:
271
+ return base
272
+ parts = rel.split("/")
273
+ parts[0] = nm.get(parts[0], parts[0])
274
+ return base.node("/".join(parts))
275
+
276
+ def mapped(rel, name):
277
+ return nm.get(name, name) if not rel else name
278
+
279
+ def pos(rel, p):
280
+ v = hou.Vector2(p[0], p[1])
281
+ return v if rel else v + sv
282
+
283
+ for e in ex.get("boxes", []):
284
+ c = container(e["parent"])
285
+ if c is None:
286
+ warns.append("ネットワークボックス %s の親が見つからないため復元しませんでした" % e["name"])
287
+ continue
288
+ try:
289
+ b = c.createNetworkBox(e["name"])
290
+ except hou.Error:
291
+ b = c.createNetworkBox()
292
+ items.append(b)
293
+ members = [n for n in (c.node(mapped(e["parent"], m)) for m in e["members"]) if n is not None]
294
+ saved = [n.position() for n in members]
295
+ for n in members:
296
+ b.addNode(n)
297
+ b.setPosition(pos(e["parent"], e["pos"]))
298
+ for n, pp in zip(members, saved): # ボックス移動でメンバーが動くので戻す
299
+ n.setPosition(pp)
300
+ b.setSize(hou.Vector2(*e["size"]))
301
+ b.setColor(hou.Color(e["color"]))
302
+ b.setComment(e["comment"])
303
+ if e["minimized"]:
304
+ b.setMinimized(True)
305
+ for e in ex.get("stickies", []):
306
+ c = container(e["parent"])
307
+ if c is None:
308
+ warns.append("付箋 %s の親が見つからないため復元しませんでした" % e["name"])
309
+ continue
310
+ try:
311
+ s = c.createStickyNote(e["name"])
312
+ except hou.Error:
313
+ s = c.createStickyNote()
314
+ items.append(s)
315
+ s.setText(e["text"])
316
+ s.setPosition(pos(e["parent"], e["pos"]))
317
+ s.setSize(hou.Vector2(*e["size"]))
318
+ s.setColor(hou.Color(e["color"]))
319
+ s.setTextColor(hou.Color(e["text_color"]))
320
+ s.setTextSize(e["text_size"])
321
+ s.setDrawBackground(e["draw_background"])
322
+ if e["minimized"]:
323
+ s.setMinimized(True)
324
+ dots: dict = {}
325
+ for e in ex.get("dots", []):
326
+ c = container(e["parent"])
327
+ if c is None:
328
+ continue
329
+ d = c.createNetworkDot()
330
+ d.setPosition(pos(e["parent"], e["pos"]))
331
+ d.setColor(hou.Color(e["color"]))
332
+ d.setPinned(e["pinned"])
333
+ dots[(e["parent"], e["name"])] = (c, d)
334
+ items.append(d)
335
+ for e in ex.get("dots", []):
336
+ if (e["parent"], e["name"]) not in dots:
337
+ continue
338
+ c, d = dots[(e["parent"], e["name"])]
339
+ inp = e["input"]
340
+ try:
341
+ if inp and inp["kind"] == "node":
342
+ src = c.node(mapped(e["parent"], inp["name"]))
343
+ if src is not None:
344
+ d.setInput(0, src, inp["out"])
345
+ elif inp and (e["parent"], inp["name"]) in dots:
346
+ d.setInput(0, dots[(e["parent"], inp["name"])][1])
347
+ for o in e["outputs"]:
348
+ dst = c.node(mapped(e["parent"], o["node"]))
349
+ if dst is not None:
350
+ dst.setInput(o["idx"], d)
351
+ except hou.Error as exc:
352
+ warns.append("ドット %s の接続を復元できませんでした: %s" % (e["name"], exc))
353
+ d.setPosition(pos(e["parent"], e["pos"])) # 接続時に動くことがあるので再設定
354
+ return items, warns
355
+
356
+
357
+ def all_vars() -> dict:
358
+ out = {}
359
+ for ln in hou.hscript("set -g")[0].splitlines():
360
+ m = re.match(r"^(\S+)\s*=\s?(.*)$", ln)
361
+ if m:
362
+ out[m.group(1)] = m.group(2)
363
+ return out
364
+
365
+
366
+ def user_globals() -> dict:
367
+ out = {}
368
+ for k, v in all_vars().items():
369
+ if k in BUILTIN_VARS:
370
+ continue
371
+ try: # 一覧表示はバックスラッシュを二重に表示するので、実際の値を取り直す
372
+ v = hou.text.expandString("$" + k)
373
+ except hou.Error:
374
+ pass
375
+ out[k] = v
376
+ return out
377
+
378
+
379
+ def set_var(name: str, value: str) -> None:
380
+ """環境変数とグローバル hscript 変数を設定する。名前・値が不正なら ValueError。
381
+ 値は hscript の文字列として解釈させない: 変数を空で作り(名前は検証済み)、値は hou.putenv で渡す。"""
382
+ why = scan.env_name_problem(name)
383
+ if why:
384
+ raise ValueError("変数名 %s は使えません: %s" % (str(name)[:40], why))
385
+ if not isinstance(value, str) or not _plain(value):
386
+ raise ValueError("変数 %s の値が不正です(文字列で、制御文字や改行を含まないこと)" % name)
387
+ hou.hscript("set -g %s = ''" % name) # 一覧(set -g)に載る hscript 変数を作る
388
+ hou.putenv(name, value) # hscript 変数の値
389
+ os.environ[name] = value # プロセスの環境変数(hscript は \ を解釈して値を変えるため揃える)
390
+
391
+
392
+ class VarJournal:
393
+ """設定した変数の元の値を覚えておき、失敗時に戻す。"""
394
+
395
+ def __init__(self) -> None:
396
+ self.prev: dict = {}
397
+
398
+ def set(self, name: str, value: str) -> None:
399
+ if name not in self.prev:
400
+ self.prev[name] = (name in all_vars(), hou.getenv(name, None))
401
+ set_var(name, value)
402
+
403
+ def rollback(self) -> None:
404
+ for name, (listed, env) in self.prev.items():
405
+ try:
406
+ if listed and env is not None:
407
+ set_var(name, env)
408
+ else:
409
+ hou.hscript("set -u %s" % name)
410
+ hou.unsetenv(name)
411
+ os.environ.pop(name, None)
412
+ except Exception:
413
+ pass
414
+ self.prev = {}
415
+
416
+
417
+ def collect_scene() -> dict:
418
+ return {"fps": hou.fps(), "frame_range": list(hou.playbar.frameRange()),
419
+ "playback_range": list(hou.playbar.playbackRange()), "frame": hou.frame(),
420
+ "globals": user_globals()}
421
+
422
+
423
+ def apply_scene(s: dict, journal: "VarJournal | None" = None) -> list[str]:
424
+ """検証済みの項目だけを適用する。警告を返す。"""
425
+ s, warns = sanitize_scene(s)
426
+ if "fps" in s:
427
+ hou.setFps(s["fps"])
428
+ if "frame_range" in s:
429
+ hou.playbar.setFrameRange(*s["frame_range"])
430
+ if "playback_range" in s:
431
+ hou.playbar.setPlaybackRange(*s["playback_range"])
432
+ for k, v in s.get("globals", {}).items():
433
+ if journal is not None:
434
+ journal.set(k, v)
435
+ else:
436
+ set_var(k, v)
437
+ if "frame" in s:
438
+ hou.setFrame(s["frame"])
439
+ return warns
@@ -0,0 +1,137 @@
1
+ """HDA の同梱(書き出し側)とインストール(読み込み側)。"""
2
+ from __future__ import annotations
3
+
4
+ import glob
5
+ import os
6
+ import re
7
+ import shutil
8
+
9
+ import hou
10
+
11
+
12
+ def _hfs() -> str:
13
+ return os.path.realpath(hou.text.expandString("$HFS")).lower()
14
+
15
+
16
+ def _safe(name: str) -> str:
17
+ return re.sub(r"[^\w.\-]+", "_", name)
18
+
19
+
20
+ # スクリプトを含む HDA セクション(読み込み・作成時に自動実行され得るもの)
21
+ _SCRIPT_SECTIONS = frozenset(n.lower() for n in (
22
+ "OnCreated OnLoaded OnUpdated OnInputChanged OnNameChanged OnDeleted PreFirstCreate PostLastDelete "
23
+ "OnUnloaded OnInstall PythonModule ViewerStateModule ViewerHandleModule").split())
24
+ _SCRIPT_SUFFIXES = (".py", ".cmd", ".hscript", ".pypanel")
25
+
26
+
27
+ class HdaRefused(Exception):
28
+ """インストールしてはいけない HDA が含まれている。"""
29
+
30
+
31
+ def _template_callbacks(entries) -> bool:
32
+ for t in entries:
33
+ try:
34
+ if t.type() == hou.parmTemplateType.Folder or hasattr(t, "parmTemplates"):
35
+ if _template_callbacks(t.parmTemplates()):
36
+ return True
37
+ if t.scriptCallback():
38
+ return True
39
+ except Exception:
40
+ continue
41
+ return False
42
+
43
+
44
+ def script_info(d) -> tuple[list[str], bool]:
45
+ """定義が持つスクリプト入りのセクション名と、パラメータのコールバックの有無。"""
46
+ names: list[str] = []
47
+ try:
48
+ for n in d.sections():
49
+ low = n.lower()
50
+ if low in _SCRIPT_SECTIONS or low.endswith(_SCRIPT_SUFFIXES):
51
+ names.append(n)
52
+ except Exception:
53
+ pass
54
+ try:
55
+ cb = _template_callbacks(d.parmTemplateGroup().entries())
56
+ except Exception:
57
+ cb = False
58
+ return sorted(names), cb
59
+
60
+
61
+ def collect_hdas(nodes, staging_dir: str) -> list[dict]:
62
+ """nodes が使う非標準 HDA を staging_dir/hda へ書き出し、依存一覧を返す。"""
63
+ hfs = _hfs()
64
+ deps: list[dict] = []
65
+ seen: set = set()
66
+ copied: dict = {}
67
+ for n in nodes:
68
+ t = n.type()
69
+ d = t.definition()
70
+ key = t.nameWithCategory()
71
+ if d is None or key in seen:
72
+ continue
73
+ lib = d.libraryFilePath()
74
+ embedded = lib == "Embedded"
75
+ if not embedded and os.path.realpath(lib).lower().startswith(hfs):
76
+ continue
77
+ seen.add(key)
78
+ hda_dir = os.path.join(staging_dir, "hda")
79
+ os.makedirs(hda_dir, exist_ok=True)
80
+ if embedded:
81
+ fname = _safe(key) + ".hda"
82
+ d.copyToHDAFile(os.path.join(hda_dir, fname))
83
+ else:
84
+ if lib not in copied:
85
+ fname = os.path.basename(lib)
86
+ while os.path.exists(os.path.join(hda_dir, fname)):
87
+ fname = "_" + fname
88
+ shutil.copy2(lib, os.path.join(hda_dir, fname))
89
+ copied[lib] = fname
90
+ fname = copied[lib]
91
+ secs, cb = script_info(d)
92
+ deps.append({"type_name": key, "library": lib, "embedded": embedded, "bundled": "hda/" + fname,
93
+ "sections": secs, "callbacks": cb})
94
+ return deps
95
+
96
+
97
+ def install_dir(hda_dir: str) -> list[str]:
98
+ """hda_dir の HDA をインストールする。同じ定義が入っていれば何もしない。警告を返す。
99
+ Houdini 組み込みの(HDA ではない)ノード型と同名の定義があれば、何もインストールせず HdaRefused を投げる。"""
100
+ warns: list[str] = []
101
+ files = []
102
+ for pat in ("*.hda", "*.otl", "*.hdalc", "*.otllc"):
103
+ files += glob.glob(os.path.join(hda_dir, pat))
104
+ loaded: list[tuple[str, list]] = []
105
+ for f in sorted(files):
106
+ try:
107
+ defs = list(hou.hda.definitionsInFile(f))
108
+ except hou.Error as e:
109
+ warns.append("HDA %s を読めませんでした: %s" % (os.path.basename(f), e))
110
+ continue
111
+ for d in defs:
112
+ cur = hou.nodeType(d.nodeTypeCategory(), d.nodeTypeName())
113
+ if cur is not None and cur.definition() is None:
114
+ raise HdaRefused("HDA %s の %s は Houdini 組み込みのノード型と同名のためインストールできません"
115
+ % (os.path.basename(f), d.nodeTypeName()))
116
+ loaded.append((f, defs))
117
+ for f, defs in loaded:
118
+ need = False
119
+ for d in defs:
120
+ secs, cb = script_info(d)
121
+ if secs or cb:
122
+ warns.append("HDA %s はスクリプトを含みます: %s" % (
123
+ d.nodeTypeName(), ", ".join(secs + (["パラメータのコールバック"] if cb else []))))
124
+ cur = hou.nodeType(d.nodeTypeCategory(), d.nodeTypeName())
125
+ cd = cur.definition() if cur is not None else None
126
+ if cd is None:
127
+ need = True
128
+ elif cd.version() == d.version() and cd.modificationTime() == d.modificationTime():
129
+ continue # 同じ定義がインストール済み
130
+ else:
131
+ warns.append("HDA %s は同名の別定義が既にあるため、既存の定義を使います" % d.nodeTypeName())
132
+ if need:
133
+ try:
134
+ hou.hda.installFile(f, change_oplibraries_file=False)
135
+ except hou.Error as e:
136
+ warns.append("HDA %s をインストールできませんでした: %s" % (os.path.basename(f), e))
137
+ return warns