nodebpy 520.1.0__tar.gz → 520.2.0__tar.gz

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 (80) hide show
  1. {nodebpy-520.1.0 → nodebpy-520.2.0}/PKG-INFO +1 -1
  2. {nodebpy-520.1.0 → nodebpy-520.2.0}/pyproject.toml +1 -1
  3. nodebpy-520.2.0/src/nodebpy/assets/__init__.py +17 -0
  4. nodebpy-520.2.0/src/nodebpy/assets/__main__.py +57 -0
  5. nodebpy-520.2.0/src/nodebpy/assets/_codegen.py +318 -0
  6. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/__init__.py +17 -1
  7. nodebpy-520.2.0/src/nodebpy/builder/asset.py +139 -0
  8. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/export/codegen.py +102 -10
  9. nodebpy-520.2.0/src/nodebpy/nodes/_mixins.py +247 -0
  10. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/__init__.py +37 -1
  11. nodebpy-520.2.0/src/nodebpy/nodes/compositor/assets.py +783 -0
  12. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/converter.py +11 -43
  13. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/interface.py +5 -15
  14. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/__init__.py +140 -12
  15. nodebpy-520.2.0/src/nodebpy/nodes/geometry/assets.py +3342 -0
  16. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/attribute.py +5 -4
  17. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/converter.py +1553 -349
  18. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/geometry.py +156 -47
  19. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/grid.py +72 -69
  20. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/input.py +69 -0
  21. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/interface.py +5 -21
  22. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/manual.py +19 -1269
  23. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/__init__.py +10 -0
  24. nodebpy-520.2.0/src/nodebpy/nodes/shader/assets.py +157 -0
  25. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/converter.py +5 -12
  26. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/shader.py +2 -2
  27. {nodebpy-520.1.0 → nodebpy-520.2.0}/README.md +0 -0
  28. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/__init__.py +0 -0
  29. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/_registry.py +0 -0
  30. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/_utils.py +0 -0
  31. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/accessor.py +0 -0
  32. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/arrange.py +0 -0
  33. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/items.py +0 -0
  34. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/mixins.py +0 -0
  35. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/node.py +0 -0
  36. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/socket.py +0 -0
  37. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/builder/tree.py +0 -0
  38. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/export/__init__.py +0 -0
  39. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/export/diagram.py +0 -0
  40. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  41. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  42. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  43. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  44. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  45. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  46. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  47. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  48. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
  49. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
  50. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
  51. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
  52. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
  53. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/__init__.py +0 -0
  54. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  55. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  56. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  57. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  58. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  59. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
  60. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  61. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  62. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  63. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  64. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  65. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  66. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  67. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  68. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  69. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  70. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
  71. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/color.py +0 -0
  72. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  73. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/group.py +0 -0
  74. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/input.py +0 -0
  75. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/manual.py +0 -0
  76. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/output.py +0 -0
  77. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/script.py +0 -0
  78. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  79. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/nodes/shader/vector.py +0 -0
  80. {nodebpy-520.1.0 → nodebpy-520.2.0}/src/nodebpy/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.1.0
3
+ Version: 520.2.0
4
4
  Summary: Build nodes trees in Blender more elegantly with code
5
5
  Author: Brady Johnston
6
6
  Author-email: Brady Johnston <brady.johnston@me.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodebpy"
3
- version = "520.1.0"
3
+ version = "520.2.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,17 @@
1
+ """Typed APIs for node-group assets.
2
+
3
+ ``generate_asset_api`` builds typed :class:`~nodebpy.builder.AssetNodeGroup`
4
+ classes for the node groups in a ``.blend`` asset library. The bundled-essentials
5
+ APIs generated for nodebpy itself live alongside this module as
6
+ ``nodebpy.assets.geometry`` / ``.shader`` / ``.compositor``.
7
+ """
8
+
9
+ from ..builder import AssetLibrary, BundledLibrary, PackageLibrary
10
+ from ._codegen import generate_asset_api
11
+
12
+ __all__ = [
13
+ "generate_asset_api",
14
+ "AssetLibrary",
15
+ "BundledLibrary",
16
+ "PackageLibrary",
17
+ ]
@@ -0,0 +1,57 @@
1
+ """Regenerate the bundled-essentials asset APIs.
2
+
3
+ ``python -m nodebpy.assets`` introspects Blender's bundled node-group asset
4
+ libraries and writes typed classes to ``nodebpy/nodes/{geometry,shader,
5
+ compositor}/assets.py``, where ``python -m gen`` re-exports them so they are
6
+ available alongside the built-in nodes (e.g. ``g.SmoothByAngle()``). Run *before*
7
+ ``python -m gen`` and through the ruff/ty post-processing (see the Makefile).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ from ..builder import BundledLibrary
16
+ from ._codegen import generate_asset_api
17
+
18
+ # Bundled libraries shipped with Blender, grouped by output module. Each library
19
+ # holds node groups of a single tree type.
20
+ _ESSENTIALS: dict[str, tuple[str, ...]] = {
21
+ "geometry": (
22
+ "geometry_nodes_essentials.blend",
23
+ "geometry_nodes_dynamics_assets.blend",
24
+ "procedural_hair_node_assets.blend",
25
+ "principal_components.blend",
26
+ ),
27
+ "shader": ("shading_nodes_essentials.blend",),
28
+ "compositor": ("compositing_nodes_essentials.blend",),
29
+ }
30
+
31
+
32
+ def generate_essentials(nodes_dir: Path) -> dict[str, list[str]]:
33
+ """Generate the bundled-essentials asset modules into
34
+ ``<nodes_dir>/<tree>/assets.py``; returns the class names written per tree
35
+ (libraries not present in this Blender install are skipped)."""
36
+ written: dict[str, list[str]] = {}
37
+ for tree, filenames in _ESSENTIALS.items():
38
+ libraries = [
39
+ BundledLibrary(f)
40
+ for f in filenames
41
+ if os.path.exists(BundledLibrary(f).path())
42
+ ]
43
+ if not libraries: # pragma: no cover - depends on the Blender install
44
+ print(f" {tree}: no bundled libraries present, skipping")
45
+ continue
46
+ names = generate_asset_api(libraries, Path(nodes_dir) / tree / "assets.py")
47
+ written[tree] = names
48
+ print(f" nodes/{tree}/assets.py: {len(names)} asset classes")
49
+ return written
50
+
51
+
52
+ def main() -> None: # pragma: no cover - CLI wrapper over generate_essentials
53
+ generate_essentials(Path(__file__).parent.parent / "nodes")
54
+
55
+
56
+ if __name__ == "__main__": # pragma: no cover
57
+ main()
@@ -0,0 +1,318 @@
1
+ """Generate typed ``nodebpy`` API classes for node-group assets.
2
+
3
+ Given one or more asset ``.blend`` libraries, :func:`generate_asset_api` appends
4
+ each node group, introspects its interface, and writes a Python module of typed
5
+ :class:`~nodebpy.builder.AssetNodeGroup` subclasses — so an asset reads and
6
+ type-checks like any other node. The emitted classes append the asset at runtime
7
+ rather than rebuilding it.
8
+
9
+ This is a *shipped*, reusable tool: other projects call it on their own asset
10
+ libraries (via :class:`~nodebpy.builder.PackageLibrary`) to generate APIs for
11
+ their assets.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import Counter
17
+ from collections.abc import Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ import bpy
22
+
23
+ from ..builder import AssetLibrary, BundledLibrary, asset_group_base
24
+ from ..builder._utils import normalize_name
25
+
26
+ # bl_socket_type substring → (Socket accessor class, Input* parameter type).
27
+ # Order matters: more specific keys (IntVector before Int) come first.
28
+ _SOCKET_TYPES: dict[str, tuple[str, str]] = {
29
+ "NodeSocketFloat": ("FloatSocket", "InputFloat"),
30
+ "NodeSocketIntVector": ("IntegerVectorSocket", "InputIntegerVector"),
31
+ "NodeSocketInt": ("IntegerSocket", "InputInteger"),
32
+ "NodeSocketBool": ("BooleanSocket", "InputBoolean"),
33
+ "NodeSocketVector": ("VectorSocket", "InputVector"),
34
+ "NodeSocketColor": ("ColorSocket", "InputColor"),
35
+ "NodeSocketRotation": ("RotationSocket", "InputRotation"),
36
+ "NodeSocketMatrix": ("MatrixSocket", "InputMatrix"),
37
+ "NodeSocketString": ("StringSocket", "InputString"),
38
+ "NodeSocketMenu": ("MenuSocket", "InputMenu"),
39
+ "NodeSocketGeometry": ("GeometrySocket", "InputGeometry"),
40
+ "NodeSocketObject": ("ObjectSocket", "InputObject"),
41
+ "NodeSocketMaterial": ("MaterialSocket", "InputMaterial"),
42
+ "NodeSocketImage": ("ImageSocket", "InputImage"),
43
+ "NodeSocketCollection": ("CollectionSocket", "InputCollection"),
44
+ "NodeSocketBundle": ("BundleSocket", "InputBundle"),
45
+ "NodeSocketClosure": ("ClosureSocket", "InputClosure"),
46
+ "NodeSocketShader": ("ShaderSocket", "InputShader"),
47
+ "NodeSocketFont": ("FontSocket", "InputFont"),
48
+ "NodeSocketSound": ("SoundSocket", "InputSound"),
49
+ "NodeSocketVirtual": ("Socket", "InputLinkable"),
50
+ }
51
+
52
+
53
+ def _socket_types(bl_socket_type: str) -> tuple[str, str]:
54
+ for key, value in _SOCKET_TYPES.items():
55
+ if key in bl_socket_type:
56
+ return value
57
+ return ("Socket", "InputLinkable")
58
+
59
+
60
+ def _class_name(name: str) -> str:
61
+ """A valid, readable Python class name for an asset group display name."""
62
+ cleaned = "".join(c if c.isalnum() or c.isspace() else " " for c in name)
63
+ parts = cleaned.split()
64
+ cleaned = "".join(p[:1].upper() + p[1:] for p in parts)
65
+ if cleaned and cleaned[0].isdigit():
66
+ cleaned = "_" + cleaned
67
+ return cleaned or "AssetGroup"
68
+
69
+
70
+ def _format_default(socket: bpy.types.NodeSocket) -> str:
71
+ """Source for a socket's scalar default value, or ``None``.
72
+
73
+ Only plain scalars are emitted as parameter defaults. Vector/colour/matrix
74
+ defaults vary in arity (2D UVs, 3D vectors, 4-component colours) and don't
75
+ always fit the parameter's ``Input*`` type, so they're left as ``None`` —
76
+ the appended group keeps its own socket default regardless.
77
+ """
78
+ value = getattr(socket, "default_value", None)
79
+ if isinstance(value, bool):
80
+ return str(value)
81
+ if isinstance(value, int):
82
+ return str(value)
83
+ if isinstance(value, float):
84
+ return str(round(value, 6))
85
+ if isinstance(value, str):
86
+ return repr(value)
87
+ return "None"
88
+
89
+
90
+ @dataclass
91
+ class _Socket:
92
+ name: str
93
+ identifier: str
94
+ socket_class: str # e.g. "GeometrySocket"
95
+ input_type: str # e.g. "InputGeometry"
96
+ default: str # source for the default value
97
+ attr: str # normalized accessor/param name
98
+
99
+
100
+ @dataclass
101
+ class _AssetClass:
102
+ class_name: str
103
+ asset_name: str
104
+ description: str
105
+ library_source: str
106
+ tree_idname: str
107
+ inputs: list[_Socket]
108
+ outputs: list[_Socket]
109
+
110
+
111
+ def _collect(sockets) -> list[_Socket]:
112
+ raw = [
113
+ s
114
+ for s in sockets
115
+ if s.identifier != "__extend__" and not getattr(s, "is_inactive", False)
116
+ ]
117
+ # The accessor resolves attribute names by identifier first, then name, so a
118
+ # group socket's readable name works as the attr/param when it's unambiguous;
119
+ # fall back to the opaque-but-unique identifier only on a name collision.
120
+ name_counts = Counter(normalize_name(s.name) for s in raw)
121
+ out: list[_Socket] = []
122
+ for s in raw:
123
+ socket_class, input_type = _socket_types(type(s).__name__)
124
+ norm_name = normalize_name(s.name)
125
+ attr = (
126
+ norm_name if name_counts[norm_name] == 1 else normalize_name(s.identifier)
127
+ )
128
+ out.append(
129
+ _Socket(
130
+ name=s.name,
131
+ identifier=s.identifier,
132
+ socket_class=socket_class,
133
+ input_type=input_type,
134
+ default=_format_default(s),
135
+ attr=attr,
136
+ )
137
+ )
138
+ return out
139
+
140
+
141
+ def _introspect(library: AssetLibrary, names: set[str] | None) -> list[_AssetClass]:
142
+ """Append each requested group from the library and introspect its
143
+ interface into :class:`_AssetClass` records."""
144
+ path = library.path()
145
+ library_source = _library_source(library)
146
+
147
+ with bpy.data.libraries.load(path, link=False, assets_only=True) as (src, _):
148
+ available = list(src.node_groups)
149
+ wanted = [n for n in available if names is None or n in names]
150
+
151
+ classes: list[_AssetClass] = []
152
+ for name in wanted:
153
+ # Always append from *this* library — never reuse a same-named group by
154
+ # global lookup, since names collide across tree types (a geometry and a
155
+ # compositor "Combine Spherical" both exist) and we'd introspect the
156
+ # wrong one. Blender renames the appended copy on a clash; that's fine,
157
+ # we only read its interface (the emitted _asset_name uses the original).
158
+ with bpy.data.libraries.load(path, link=False, assets_only=True) as (
159
+ src,
160
+ dst,
161
+ ):
162
+ dst.node_groups = [name]
163
+ group = dst.node_groups[0]
164
+
165
+ host = bpy.data.node_groups.new("_introspect_host", group.bl_idname)
166
+ try:
167
+ node_type = {
168
+ "GeometryNodeTree": "GeometryNodeGroup",
169
+ "ShaderNodeTree": "ShaderNodeGroup",
170
+ "CompositorNodeTree": "CompositorNodeGroup",
171
+ }[group.bl_idname]
172
+ node = host.nodes.new(node_type)
173
+ node.node_tree = group
174
+ classes.append(
175
+ _AssetClass(
176
+ class_name=_class_name(name),
177
+ asset_name=name,
178
+ description=(group.description or name).strip(),
179
+ library_source=library_source,
180
+ tree_idname=group.bl_idname,
181
+ inputs=_collect(node.inputs),
182
+ outputs=_collect(node.outputs),
183
+ )
184
+ )
185
+ finally:
186
+ bpy.data.node_groups.remove(host)
187
+ return classes
188
+
189
+
190
+ def _library_source(library: AssetLibrary) -> str:
191
+ """Source expression that reconstructs ``library`` in the generated module."""
192
+ if isinstance(library, BundledLibrary):
193
+ return f"BundledLibrary({library.filename!r})"
194
+ from ..builder import PackageLibrary
195
+
196
+ if isinstance(library, PackageLibrary):
197
+ return f"PackageLibrary(__file__, {library.relative!r})"
198
+ raise TypeError(f"Cannot serialise asset library: {library!r}")
199
+
200
+
201
+ def _accessor(sockets: list[_Socket], kind: str) -> str:
202
+ if not sockets:
203
+ return f" class {kind}(SocketAccessor):\n pass"
204
+ lines = [f" class {kind}(SocketAccessor):"]
205
+ for s in sockets:
206
+ lines.append(f" {s.attr}: {s.socket_class}")
207
+ doc = s.name
208
+ if doc and doc != s.attr:
209
+ lines.append(f' """{doc}"""')
210
+ return "\n".join(lines)
211
+
212
+
213
+ def _render_class(cls: _AssetClass) -> str:
214
+ base = asset_group_base(cls.tree_idname).__name__
215
+ inputs_cls = _accessor(cls.inputs, "_Inputs")
216
+ outputs_cls = _accessor(cls.outputs, "_Outputs")
217
+
218
+ params = [f"{s.attr}: {s.input_type} = {s.default}" for s in cls.inputs]
219
+ signature = (
220
+ "(\n self,\n " + ",\n ".join(params) + ",\n )"
221
+ if params
222
+ else "(self)"
223
+ )
224
+ key_args = ", ".join(f'"{s.identifier}": {s.attr}' for s in cls.inputs)
225
+
226
+ return f'''class {cls.class_name}({base}):
227
+ """{cls.description}"""
228
+
229
+ _name = {cls.asset_name!r}
230
+ _asset_name = {cls.asset_name!r}
231
+ _library = {cls.library_source}
232
+
233
+ {inputs_cls}
234
+
235
+ {outputs_cls}
236
+
237
+ if TYPE_CHECKING:
238
+ @property
239
+ def i(self) -> _Inputs: ...
240
+ @property
241
+ def o(self) -> _Outputs: ...
242
+
243
+ def __init__{signature}:
244
+ super().__init__(**{{{key_args}}})
245
+ '''
246
+
247
+
248
+ def _render_module(classes: list[_AssetClass]) -> str:
249
+ socket_classes = sorted(
250
+ {s.socket_class for c in classes for s in c.inputs + c.outputs}
251
+ )
252
+ input_types = sorted({s.input_type for c in classes for s in c.inputs})
253
+ bases = sorted({asset_group_base(c.tree_idname).__name__ for c in classes})
254
+ libraries = sorted(
255
+ {
256
+ "BundledLibrary"
257
+ if c.library_source.startswith("BundledLibrary")
258
+ else "PackageLibrary"
259
+ for c in classes
260
+ }
261
+ )
262
+
263
+ builder_imports = sorted(
264
+ set(bases) | set(libraries) | {"SocketAccessor"} | set(socket_classes)
265
+ )
266
+
267
+ lines = [
268
+ "# Auto-generated by nodebpy.assets.generate_asset_api — do not edit manually.",
269
+ "from typing import TYPE_CHECKING",
270
+ "",
271
+ f"from nodebpy.builder import (\n {',\n '.join(builder_imports)},\n)",
272
+ f"from nodebpy.types import (\n {',\n '.join(input_types)},\n)"
273
+ if input_types
274
+ else "",
275
+ ]
276
+ header = "\n".join(line for line in lines if line) + "\n\n\n"
277
+ ordered = sorted(classes, key=lambda c: c.class_name)
278
+ body = "\n\n".join(_render_class(c) for c in ordered)
279
+ all_names = ",\n ".join(f'"{c.class_name}"' for c in ordered)
280
+ footer = (
281
+ f"\n\n__all__ = (\n {all_names},\n)\n" if ordered else "\n__all__ = ()\n"
282
+ )
283
+ return header + body + footer
284
+
285
+
286
+ def generate_asset_api(
287
+ libraries: AssetLibrary | Sequence[AssetLibrary],
288
+ output_path: str | Path,
289
+ *,
290
+ names: set[str] | None = None,
291
+ ) -> list[str]:
292
+ """Generate typed asset classes for ``libraries`` into ``output_path``.
293
+
294
+ Parameters
295
+ ----------
296
+ libraries:
297
+ One or more :class:`~nodebpy.builder.AssetLibrary` instances
298
+ (:class:`~nodebpy.builder.BundledLibrary` for Blender's bundled assets,
299
+ :class:`~nodebpy.builder.PackageLibrary` for a ``.blend`` shipped inside
300
+ your own package).
301
+ output_path:
302
+ The ``.py`` file to write.
303
+ names:
304
+ Restrict generation to these asset (node-group) names; defaults to all.
305
+
306
+ Returns the list of generated class names.
307
+ """
308
+ if isinstance(libraries, AssetLibrary):
309
+ libraries = [libraries]
310
+
311
+ classes: list[_AssetClass] = []
312
+ for library in libraries:
313
+ classes.extend(_introspect(library, names))
314
+
315
+ output_path = Path(output_path)
316
+ output_path.parent.mkdir(parents=True, exist_ok=True)
317
+ output_path.write_text(_render_module(classes), encoding="utf-8")
318
+ return [c.class_name for c in classes]
@@ -1,5 +1,14 @@
1
1
  from ._utils import SocketError, denormalize_name, normalize_name
2
2
  from .accessor import SocketAccessor
3
+ from .asset import (
4
+ AssetCompositorGroup,
5
+ AssetGeometryGroup,
6
+ AssetLibrary,
7
+ AssetShaderGroup,
8
+ BundledLibrary,
9
+ PackageLibrary,
10
+ asset_group_base,
11
+ )
3
12
  from .items import Item, ItemsMixin
4
13
  from .mixins import LinkingMixin, OperatorMixin
5
14
  from .node import (
@@ -83,7 +92,14 @@ __all__ = [
83
92
  "CustomCompositorGroup",
84
93
  "CustomGeometryGroup",
85
94
  "CustomShaderGroup",
86
- "CustomShaderGroup",
95
+ # Asset-backed node groups
96
+ "AssetLibrary",
97
+ "BundledLibrary",
98
+ "PackageLibrary",
99
+ "AssetGeometryGroup",
100
+ "AssetShaderGroup",
101
+ "AssetCompositorGroup",
102
+ "asset_group_base",
87
103
  # Runtime socket types
88
104
  "FloatSocket",
89
105
  "FloatSocketGrid",
@@ -0,0 +1,139 @@
1
+ """Asset-backed node groups.
2
+
3
+ An :class:`AssetNodeGroup` behaves like a :class:`NodeGroupBuilder`, but instead
4
+ of *building* its inner tree it **appends** a named node group from a ``.blend``
5
+ asset library and points a Group node at it. Subclasses (typically generated by
6
+ :func:`nodebpy.assets.generate_asset_api`) declare the library + asset name and a
7
+ typed interface, so an asset reads and type-checks like any other node.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from abc import ABC, abstractmethod
14
+ from pathlib import Path
15
+
16
+ import bpy
17
+
18
+ from .node import (
19
+ CustomCompositorGroup,
20
+ CustomGeometryGroup,
21
+ CustomShaderGroup,
22
+ )
23
+
24
+
25
+ class AssetLibrary(ABC):
26
+ """Locates a ``.blend`` asset library on disk at runtime."""
27
+
28
+ @abstractmethod
29
+ def path(self) -> str:
30
+ """Absolute path to the ``.blend`` file."""
31
+ ...
32
+
33
+
34
+ class BundledLibrary(AssetLibrary):
35
+ """A node-group asset library shipped with Blender itself, under the system
36
+ datafiles (``…/datafiles/assets/nodes/<filename>``) — e.g. the
37
+ ``geometry_nodes_essentials.blend`` essentials library."""
38
+
39
+ def __init__(self, filename: str):
40
+ self.filename = filename
41
+
42
+ def path(self) -> str:
43
+ nodes_dir = os.path.join(
44
+ bpy.utils.system_resource("DATAFILES"), # ty: ignore[unresolved-attribute]
45
+ "assets",
46
+ "nodes",
47
+ )
48
+ return os.path.join(nodes_dir, self.filename)
49
+
50
+
51
+ class PackageLibrary(AssetLibrary):
52
+ """A ``.blend`` shipped inside a Python package, located relative to a module
53
+ file. Pass ``anchor=__file__`` from the generated module and the path to the
54
+ ``.blend`` relative to it::
55
+
56
+ PackageLibrary(__file__, "../data/my_assets.blend")
57
+ """
58
+
59
+ def __init__(self, anchor: str, relative: str):
60
+ self.anchor = anchor
61
+ self.relative = relative
62
+
63
+ def path(self) -> str:
64
+ return str((Path(self.anchor).parent / self.relative).resolve())
65
+
66
+
67
+ class _AssetGroupMixin:
68
+ """Mixin that turns a ``Custom*Group`` into an asset-backed group: it appends
69
+ ``_asset_name`` from ``_library`` (reusing an already-appended group of that
70
+ name) rather than building the tree."""
71
+
72
+ _library: AssetLibrary
73
+ _asset_name: str
74
+ _tree_idname: str # provided by the Custom*Group this is mixed into
75
+
76
+ def _build_group(self, tree) -> None: # pragma: no cover - never called
77
+ raise NotImplementedError(
78
+ "Asset groups are appended from a library, not built."
79
+ )
80
+
81
+ @classmethod
82
+ def create_group(cls):
83
+ """Append the named asset group from the library and return its tree,
84
+ reusing a previously-appended group of the same name and tree type.
85
+
86
+ The tree-type check matters because asset names collide across editors
87
+ (a geometry and a compositor "Combine Spherical" both exist); reusing by
88
+ name alone could hand back a group of the wrong type.
89
+ """
90
+ existing = bpy.data.node_groups.get(cls._asset_name)
91
+ if existing is not None and existing.bl_idname == cls._tree_idname:
92
+ return existing
93
+ path = cls._library.path()
94
+ if not os.path.exists(path):
95
+ raise FileNotFoundError(f"Asset library not found: {path}")
96
+ with bpy.data.libraries.load(path, link=False, assets_only=True) as (src, dst):
97
+ if cls._asset_name not in src.node_groups:
98
+ raise KeyError(f"Node group {cls._asset_name!r} not found in {path}")
99
+ dst.node_groups = [cls._asset_name]
100
+ return dst.node_groups[0]
101
+
102
+
103
+ class AssetGeometryGroup(_AssetGroupMixin, CustomGeometryGroup):
104
+ """A Geometry Nodes group appended from an asset library."""
105
+
106
+
107
+ class AssetShaderGroup(_AssetGroupMixin, CustomShaderGroup):
108
+ """A Shader node group appended from an asset library."""
109
+
110
+
111
+ class AssetCompositorGroup(_AssetGroupMixin, CustomCompositorGroup):
112
+ """A Compositor node group appended from an asset library."""
113
+
114
+
115
+ _ASSET_GROUP_BASE = {
116
+ "GeometryNodeTree": AssetGeometryGroup,
117
+ "ShaderNodeTree": AssetShaderGroup,
118
+ "CompositorNodeTree": AssetCompositorGroup,
119
+ }
120
+
121
+
122
+ def asset_group_base(tree_idname: str) -> type:
123
+ """The ``Asset*Group`` base class for a tree-type bl_idname."""
124
+ return _ASSET_GROUP_BASE[tree_idname]
125
+
126
+
127
+ __all__ = [
128
+ "AssetLibrary",
129
+ "BundledLibrary",
130
+ "PackageLibrary",
131
+ "AssetNodeGroup",
132
+ "AssetGeometryGroup",
133
+ "AssetShaderGroup",
134
+ "AssetCompositorGroup",
135
+ "asset_group_base",
136
+ ]
137
+
138
+ # Public alias for the mixin, for type hints / isinstance in generated code.
139
+ AssetNodeGroup = _AssetGroupMixin