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
hnl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Houdini Node Library."""
2
+
3
+ __version__ = "0.1.0"
hnl/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .app import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- Houdini drops non-ASCII characters in menu/shelf XML, so keep labels ASCII-only. -->
3
+ <menuDocument>
4
+ <menu>
5
+ <separatorItem/>
6
+ <scriptItem id="hnl_register_selection">
7
+ <label>HNL: Register to Library</label>
8
+ <scriptCode><![CDATA[
9
+ import hnl_bridge.launcher as launcher
10
+ launcher.run_tool("selection")
11
+ ]]></scriptCode>
12
+ <language>python</language>
13
+ </scriptItem>
14
+ </menu>
15
+ </menuDocument>
@@ -0,0 +1,2 @@
1
+ """HNL Houdini ブリッジ(標準ライブラリと hou のみ)。"""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ """GUI 起動時の自動起動(python3.13libs/uiready.py から呼ぶ)。"""
2
+ from __future__ import annotations
3
+
4
+
5
+ def on_ui_ready() -> None:
6
+ from . import server
7
+ server.start()
8
+ try:
9
+ import hdefereval
10
+ hdefereval.executeDeferred(server.run_startup_job)
11
+ except Exception:
12
+ server.run_startup_job()
@@ -0,0 +1,98 @@
1
+ r"""Hscript cmd テキストの読み書きと文(論理行)分割。標準ライブラリのみ(hou 不要)。
2
+
3
+ opscript はキーフレーム式の改行を \n(バックスラッシュ + n)にエスケープするが、CR は生のまま出す
4
+ (CRLF の式 = 生の CR + \n)。ファイルをテキストモードの既定(universal newlines)で読むと生の CR が LF に
5
+ 化けて、クォートの途中に実改行が入り、hscript が読めなくなる。cmd は必ず改行変換なしで読み書きすること。
6
+ cmd の加工は物理行ではなく、クォートを考慮した split_statements() の単位で行うこと。"""
7
+ from __future__ import annotations
8
+
9
+ _QUOTES = "\"'`"
10
+ _LF = "\n"
11
+ _BS_N = "\\n" # バックスラッシュ + n の 2 文字
12
+
13
+
14
+ def read_cmd(path, repair: bool = True) -> str:
15
+ """cmd を改行変換なしで読む。repair=True なら repair_broken() も適用する。"""
16
+ with open(path, "rb") as f:
17
+ text = f.read().decode("utf-8-sig")
18
+ return repair_broken(text) if repair else text
19
+
20
+
21
+ def write_cmd(path, text: str) -> None:
22
+ """cmd を改行変換なしで書く(Windows でも LF を CRLF にしない)。"""
23
+ with open(path, "wb") as f:
24
+ f.write(text.encode("utf-8"))
25
+
26
+
27
+ def _skip_comment(text: str, i: int, n: int) -> int:
28
+ """行頭の # から行末(LF の位置)までを飛ばした位置。コメント中の ' は引用符ではない。"""
29
+ j = text.find(_LF, i)
30
+ return n if j < 0 else j
31
+
32
+
33
+ def split_statements(text: str) -> list[str]:
34
+ r"""LF 区切りで分けるが、クォート内の LF と、バックスラッシュ + LF(行継続)では切らない。
35
+ _LF.join(戻り値) == text が常に成り立つ。閉じていないクォートがあれば、全体を物理行で分け直す。"""
36
+ out: list[str] = []
37
+ n = len(text)
38
+ start = i = 0
39
+ quote = ""
40
+ while i < n:
41
+ c = text[i]
42
+ if c == "\\" and i + 1 < n:
43
+ i += 2
44
+ continue
45
+ if quote:
46
+ if c == quote:
47
+ quote = ""
48
+ elif c == "#" and (i == 0 or text[i - 1] == _LF):
49
+ i = _skip_comment(text, i, n)
50
+ continue
51
+ elif c in _QUOTES:
52
+ quote = c
53
+ elif c == _LF:
54
+ out.append(text[start:i])
55
+ start = i + 1
56
+ i += 1
57
+ if quote:
58
+ out.extend(text[start:].split(_LF))
59
+ return out
60
+ out.append(text[start:])
61
+ return out
62
+
63
+
64
+ def join_statements(parts: list[str]) -> str:
65
+ return _LF.join(parts)
66
+
67
+
68
+ def repair_broken(text: str) -> str:
69
+ r"""旧版が壊した cmd を元に戻す。単一引用符の中の「実改行 + \n(バックスラッシュ + n)」は、元は
70
+ 「生の CR + \n」(opscript の出力そのまま)だったので、実改行を CR に戻す。正しい cmd には現れない並び。"""
71
+ if _LF + _BS_N not in text:
72
+ return text
73
+ out: list[str] = []
74
+ n = len(text)
75
+ i = last = 0
76
+ quote = ""
77
+ while i < n:
78
+ c = text[i]
79
+ if c == "\\" and i + 1 < n:
80
+ i += 2
81
+ continue
82
+ if quote:
83
+ if c == quote:
84
+ quote = ""
85
+ elif c == _LF and quote == "'" and text.startswith(_BS_N, i + 1):
86
+ out.append(text[last:i])
87
+ out.append("\r")
88
+ last = i + 1
89
+ elif c == "#" and (i == 0 or text[i - 1] == _LF):
90
+ i = _skip_comment(text, i, n)
91
+ continue
92
+ elif c in _QUOTES:
93
+ quote = c
94
+ i += 1
95
+ if quote: # 閉じていないクォートがある = 判断できない。そのまま返す
96
+ return text
97
+ out.append(text[last:])
98
+ return "".join(out)
@@ -0,0 +1,292 @@
1
+ """シーン / ネットワーク / 選択ノードの書き出し(作業中のシーンは変更しない)。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import posixpath
7
+ import re
8
+
9
+ import hou
10
+
11
+ from . import cmdio
12
+ from . import extras as ex_mod
13
+ from . import hdas
14
+ from . import structure as structure_mod
15
+ from . import userset
16
+
17
+ _VAR = re.compile(r"\$\{?([A-Za-z_]\w*)")
18
+ _CH_FUNC = re.compile(r"\bch[a-z]*\(\s*[\"']([^\"']+)[\"']")
19
+ MAX_LISTED = 20 # 警告に 1 件ずつ並べる上限
20
+ _HOU_NODE = re.compile(r"\bhou\.(node|parm|evalParm|ch\w*)\(\s*[\"']([^\"']+)[\"']")
21
+
22
+
23
+ def _run_opscript(cwd: str, args: str) -> tuple[str, str]:
24
+ old = hou.hscript("oppwf")[0].strip()
25
+ try:
26
+ hou.hscript("opcf " + cwd)
27
+ return hou.hscript("opscript -r -b -s " + args) # -s: キー時刻をフレーム単位に(FPS ずれ対策)
28
+ finally:
29
+ hou.hscript("opcf " + (old or "/"))
30
+
31
+
32
+ _STICKY_PREFIX = ("python -c 'hou.pwd().createStickyNote(", "python -c 'hou.pwd().findStickyNote(")
33
+ # ネットワークボックスの入れ子を作る python 行(ロック解除した HDA の中身などで出る)。実行しない
34
+ _NETBOX_PREFIX = 'python -c "hou.pwd().findNetworkBox('
35
+
36
+
37
+
38
+ def _strip_stickies(cmd: str) -> str:
39
+ """opscript が付箋・ネットワークボックスの入れ子用に出す python 行を除く(付箋・ボックスは extras.json で保存する)。"""
40
+ keep = [ln for ln in cmdio.split_statements(cmd)
41
+ if not (ln.startswith(_STICKY_PREFIX) and ln.rstrip().endswith("'"))
42
+ and not (ln.startswith(_NETBOX_PREFIX) and ln.rstrip().endswith('"'))]
43
+ return "\n".join(keep)
44
+
45
+
46
+ _OPCF = re.compile(r"^opcf\s+(\S+)\s*$")
47
+ _OPADD = re.compile(r"^opadd((?:\s+-\w+)*)\s+(\S+)\s+(\S+)\s*$")
48
+
49
+
50
+ def _drop_shipped_opadd(cmd: str, start: str, scope) -> str:
51
+ """ロックされた HDA の出荷時の中身(編集可能ノードの中を含む)を作る opadd 行を除く。
52
+
53
+ HDA は作成された時点で同じ中身を持っているので、読み込み側で opadd すると同名の衝突で別名のノードが増える
54
+ (force_output1 など)。以降の oplocate / opparm / opwire 行は既存のノードに対して効く。"""
55
+ cwd, out = start or "/", []
56
+ for ln in cmdio.split_statements(cmd):
57
+ m = _OPCF.match(ln)
58
+ if m:
59
+ tok = m.group(1).strip("\"'")
60
+ if tok.startswith("$"):
61
+ pass # $saved_path(最後の復帰)
62
+ else:
63
+ cwd = posixpath.normpath(tok if tok.startswith("/") else cwd.rstrip("/") + "/" + tok)
64
+ else:
65
+ m = _OPADD.match(ln)
66
+ if m and "u" not in m.group(1) and scope.is_shipped(cwd.rstrip("/") + "/" + m.group(3), m.group(2)):
67
+ continue
68
+ out.append(ln)
69
+ return "\n".join(out)
70
+
71
+
72
+ def _quote_bare_backticks(cmd: str) -> str:
73
+ """opparm の値が `@pdg_input` のようにバッククォートだけで書かれていると、読み込み時に hscript が式として評価して
74
+ しまう(値が失われ、警告が出る)。他の文字列と同じく単一引用符で包んで、文字列のまま設定させる。"""
75
+ out = []
76
+ for ln in cmdio.split_statements(cmd):
77
+ out.append(_quote_line(ln) if ln.startswith("opparm ") and "`" in ln else ln)
78
+ return "\n".join(out)
79
+
80
+
81
+ def _quote_line(ln: str) -> str:
82
+ toks, cur, q, i = [], "", False, 0
83
+ while i < len(ln):
84
+ c = ln[i]
85
+ if c == "\\" and i + 1 < len(ln):
86
+ cur += ln[i:i + 2]
87
+ i += 2
88
+ continue
89
+ if c == "'":
90
+ q = not q
91
+ if c == " " and not q:
92
+ toks.append(cur)
93
+ cur = ""
94
+ else:
95
+ cur += c
96
+ i += 1
97
+ toks.append(cur)
98
+ return " ".join(("'%s'" % t) if ("`" in t and "'" not in t and "\\" not in t and "\"" not in t
99
+ and not t.startswith("-")) else t for t in toks)
100
+
101
+
102
+ def _parm_texts(p) -> list[str]:
103
+ out: list[str] = []
104
+ try:
105
+ for k in p.keyframes():
106
+ try:
107
+ out.append(k.expression())
108
+ except hou.Error:
109
+ pass
110
+ except hou.Error:
111
+ pass
112
+ try:
113
+ if p.parmTemplate().type() == hou.parmTemplateType.String:
114
+ out.append(p.unexpandedString())
115
+ except hou.Error:
116
+ pass
117
+ return out
118
+
119
+
120
+ def _scan_parms(visible, inside: set, parent, scope) -> tuple[list, list, dict]:
121
+ """利用者が設定したパラメータだけを調べ、(選択外チャンネル参照, 警告, 環境変数の使用箇所) を返す。"""
122
+ refs, warns, seen, usage = [], [], set(), {}
123
+ for n in visible:
124
+ for p in n.parms():
125
+ if not scope.user_parm(p):
126
+ continue
127
+ where = ex_mod.rel_path(p, parent)
128
+ for text in _parm_texts(p):
129
+ for name in _VAR.findall(text):
130
+ usage.setdefault(name, set()).add(where)
131
+ cands = [(m.group(1), m.group(1).rsplit("/", 1)[0] if "/" in m.group(1) else "")
132
+ for m in _CH_FUNC.finditer(text)]
133
+ for m in _HOU_NODE.finditer(text):
134
+ path = m.group(2)
135
+ cands.append((path, path if m.group(1) == "node" else (path.rsplit("/", 1)[0] if "/" in path else "")))
136
+ for path, cand in cands:
137
+ if cand:
138
+ _check(n, p, path, cand, inside, parent, refs, warns, seen)
139
+ return refs, warns, usage
140
+
141
+
142
+ def _check(n, p, path, cand, inside, parent, refs, warns, seen):
143
+ try:
144
+ target = n.node(cand)
145
+ except hou.Error:
146
+ target = None
147
+ key = (n.path(), p.name(), path)
148
+ if target is None or key in seen:
149
+ return
150
+ seen.add(key)
151
+ rel = ex_mod.rel_path(n, parent)
152
+ if target not in inside:
153
+ refs.append({"kind": "channel", "node": rel, "parm": p.name(), "target": path,
154
+ "detail": "%s -> %s" % (p.name(), path)})
155
+ elif path.startswith("/"):
156
+ warns.append("%s の %s は絶対パス %s で参照しており、読み込み後も元のパスを指します" % (rel, p.name(), path))
157
+
158
+
159
+ def _file_refs(inside_prefix: tuple, parent, scope) -> list[dict]:
160
+ hfs = _hfs_lower()
161
+ out = []
162
+ for p, raw in hou.fileReferences():
163
+ if p is None or not raw or not p.path().startswith(inside_prefix):
164
+ continue
165
+ if not scope.user_parm(p):
166
+ continue
167
+ try:
168
+ exp = p.evalAsString() if p.parmTemplate().type() == hou.parmTemplateType.String else raw
169
+ except hou.Error:
170
+ exp = hou.text.expandString(raw)
171
+ if os.path.realpath(exp).lower().startswith(hfs):
172
+ continue
173
+ try:
174
+ ntype = p.node().type().name()
175
+ except Exception:
176
+ ntype = ""
177
+ out.append({"parm": ex_mod.rel_path(p, parent), "node_type": ntype,
178
+ "raw": raw, "expanded": exp, "exists": os.path.exists(exp),
179
+ "kind": "output" if userset.is_output_parm(p) else "input"})
180
+ return out
181
+
182
+
183
+ def _hfs_lower() -> str:
184
+ return os.path.realpath(hou.text.expandString("$HFS")).lower()
185
+
186
+
187
+ def _env_for(cmd: str) -> dict:
188
+ names = set(_VAR.findall(cmd))
189
+ allv = ex_mod.all_vars()
190
+ return {n: allv[n] for n in sorted(names)
191
+ if n in allv and (n in ("HIP", "JOB") or n not in ex_mod.BUILTIN_VARS)}
192
+
193
+
194
+ def export(scope: str, staging_dir: str, node_paths=None, parent_path=None) -> dict:
195
+ """scope: "selection" | "network" | "scene"。staging_dir に nodes.cmd / extras.json / hda/ を書く。"""
196
+ warns: list[str] = []
197
+ if scope == "selection":
198
+ nodes = [hou.node(p) for p in (node_paths or [])]
199
+ if not nodes or any(n is None for n in nodes):
200
+ raise ValueError("選択ノードが見つかりません")
201
+ parent = nodes[0].parent()
202
+ if any(n.parent() != parent for n in nodes):
203
+ raise ValueError("選択ノードは同じ親を持つ必要があります")
204
+ kind, whole = ("node" if len(nodes) == 1 else "nodes"), False
205
+ opargs = "-N " + " ".join(n.name() for n in nodes)
206
+ elif scope == "network":
207
+ parent = hou.node(parent_path or "")
208
+ if parent is None:
209
+ raise ValueError("ネットワークが見つかりません: %s" % parent_path)
210
+ nodes, kind, whole = list(parent.children()), "nodes", True
211
+ opargs = ("-N " + " ".join(n.name() for n in nodes)) if nodes else ""
212
+ elif scope == "scene":
213
+ parent = hou.node("/")
214
+ nodes, kind, whole = list(parent.children()), "scene", True
215
+ opargs = "/"
216
+ else:
217
+ raise ValueError("不明な scope: %s" % scope)
218
+
219
+ scope_ = userset.Scope(parent)
220
+ try:
221
+ return _export_body(scope, staging_dir, parent, nodes, kind, whole, opargs, warns, scope_)
222
+ finally:
223
+ scope_.close()
224
+
225
+
226
+ def _export_body(scope, staging_dir, parent, nodes, kind, whole, opargs, warns, scope_) -> dict:
227
+ # opscript を最初に実行する。scope_.visible() / user_parm() は HDA の「出荷時の見本」を親の中に一時ノードとして
228
+ # 作るので、先に呼ぶと見本まで cmd に書き出され、同じ内容が二重に出力される
229
+ cmd, err = _run_opscript(parent.path(), opargs) if opargs else ("", "")
230
+ total = scope_.total(nodes)
231
+ visible = scope_.visible(nodes)
232
+ if scope == "scene":
233
+ total = [n for n in total if n.parent() != parent]
234
+ visible = [n for n in visible if n.parent() != parent]
235
+ inside = set(total) | set(nodes)
236
+
237
+ cmd = _quote_bare_backticks(_strip_stickies(cmd))
238
+ cmd = _drop_shipped_opadd(cmd, parent.path(), scope_)
239
+ removed = scope_.removed_shipped(nodes, parent)
240
+ wires = scope_.editable_wires(nodes, parent)
241
+ if err.strip():
242
+ warns.append("opscript がエラーを返しました: " + err.strip().splitlines()[0])
243
+ for n in visible:
244
+ if getattr(n, "isHardLocked", lambda: False)():
245
+ warns.append("%s はハードロックされており、ジオメトリは保存されません(復元後は空になります)" % n.path())
246
+ if scope != "scene" and parent.nodeGroups():
247
+ warns.append("ノードグループは保存されません")
248
+
249
+ ext = [{"kind": "input", "node": ex_mod.rel_path(n, parent), "detail": "入力%d <- %s" % (i, src.path())}
250
+ for n in visible for i, src in enumerate(n.inputs()) if src is not None and src not in inside]
251
+ if ext:
252
+ warns.append("選択外のノードからの入力接続が %d 件あり、読み込み後は失われます" % len(ext))
253
+ crefs, cwarns, usage = _scan_parms(visible, inside, parent, scope_)
254
+ ext += crefs
255
+ warns += cwarns
256
+ for c in crefs[:MAX_LISTED]:
257
+ warns.append("%s の %s が選択外 %s を参照しており、読み込み後は参照切れになります" % (c["node"], c["parm"], c["target"]))
258
+ if len(crefs) > MAX_LISTED:
259
+ warns.append("選択外へのチャンネル参照はほか %d 件あります" % (len(crefs) - MAX_LISTED))
260
+
261
+ os.makedirs(staging_dir, exist_ok=True)
262
+ deps = hdas.collect_hdas(total, staging_dir)
263
+ extras = ex_mod.collect(parent, nodes, whole, scope_)
264
+ if removed:
265
+ extras["removed_nodes"] = removed
266
+ if wires:
267
+ extras["editable_wires"] = wires
268
+ if scope == "scene":
269
+ extras["scene"] = ex_mod.collect_scene()
270
+ prefix = tuple(n.path() + "/" for n in nodes) if scope != "scene" else ("/",)
271
+ frefs = _file_refs(prefix, parent, scope_)
272
+ scope_.close() # 一時ノード(HDA の見本)を先に片付ける
273
+ env = _env_for(cmd)
274
+
275
+ cmdio.write_cmd(os.path.join(staging_dir, "nodes.cmd"), cmd)
276
+ with open(os.path.join(staging_dir, "extras.json"), "w", encoding="utf-8") as f:
277
+ json.dump(extras, f, ensure_ascii=False, indent=1)
278
+ try: # 表示用の構造情報。失敗しても登録は妨げない
279
+ tree = structure_mod.build(scope, parent, nodes)
280
+ with open(os.path.join(staging_dir, "structure.json"), "w", encoding="utf-8") as f:
281
+ json.dump(tree, f, ensure_ascii=False, indent=1)
282
+ except Exception as e: # noqa: BLE001
283
+ warns.append("構造情報を作れませんでした: %s" % e)
284
+ return {
285
+ "type": kind, "context": parent.childTypeCategory().name(), "source_parent": parent.path(),
286
+ "source_nodes": [n.path() for n in nodes] if scope == "selection" else [],
287
+ "node_count": len(visible), "node_count_total": len(total),
288
+ "node_types": sorted({n.type().name() for n in visible}),
289
+ "houdini_version": hou.applicationVersionString(), "file_refs": frefs, "hda_deps": deps,
290
+ "external_refs": ext, "warnings": warns, "env": env,
291
+ "env_usage": {k: sorted(usage.get(k, ())) for k in env},
292
+ }