nodebpy 520.37.0__tar.gz → 520.39.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 (120) hide show
  1. {nodebpy-520.37.0 → nodebpy-520.39.0}/PKG-INFO +1 -1
  2. {nodebpy-520.37.0 → nodebpy-520.39.0}/pyproject.toml +1 -1
  3. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/__init__.py +2 -0
  4. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/__init__.py +4 -1
  5. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/_utils.py +4 -0
  6. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/socket.py +4 -2
  7. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/tree.py +152 -3
  8. nodebpy-520.39.0/src/nodebpy/export/_groups.py +168 -0
  9. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/codegen.py +112 -4
  10. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/DESIGN.md +3 -2
  11. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/api.py +3 -0
  12. nodebpy-520.39.0/src/nodebpy/layout/keep.py +535 -0
  13. nodebpy-520.39.0/src/nodebpy/live.py +767 -0
  14. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/manual.py +9 -1
  15. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/skills/nodebpy/SKILL.md +1 -0
  16. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/skills/nodebpy/references/existing-trees.md +19 -6
  17. nodebpy-520.37.0/src/nodebpy/live.py +0 -363
  18. {nodebpy-520.37.0 → nodebpy-520.39.0}/README.md +0 -0
  19. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/__main__.py +0 -0
  20. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/_lookup.py +0 -0
  21. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/_skill.py +0 -0
  22. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/assets/__init__.py +0 -0
  23. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/assets/__main__.py +0 -0
  24. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/assets/_codegen.py +0 -0
  25. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/assets/_library.py +0 -0
  26. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/assets/_pipeline.py +0 -0
  27. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/assets/_textconv.py +0 -0
  28. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/_combine.py +0 -0
  29. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/_infer.py +0 -0
  30. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/_registry.py +0 -0
  31. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/_socket_order.py +0 -0
  32. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/accessor.py +0 -0
  33. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/asset.py +0 -0
  34. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/items.py +0 -0
  35. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/mixins.py +0 -0
  36. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/builder/node.py +0 -0
  37. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/__init__.py +0 -0
  38. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/_floats.py +0 -0
  39. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/diagram.py +0 -0
  40. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/parity.py +0 -0
  41. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/plot.py +0 -0
  42. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/export/web_render.py +0 -0
  43. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/__init__.py +0 -0
  44. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/apply.py +0 -0
  45. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/balancing.py +0 -0
  46. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/build.py +0 -0
  47. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/common.py +0 -0
  48. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/config.py +0 -0
  49. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/digraph.py +0 -0
  50. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/dna.py +0 -0
  51. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/edits.py +0 -0
  52. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/extract.py +0 -0
  53. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/feeders.py +0 -0
  54. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/long_links.py +0 -0
  55. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/model.py +0 -0
  56. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/node_size.py +0 -0
  57. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/ordering.py +0 -0
  58. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/packing.py +0 -0
  59. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/pipeline.py +0 -0
  60. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/placement.py +0 -0
  61. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/priority.py +0 -0
  62. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/ranking.py +0 -0
  63. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/realize.py +0 -0
  64. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/reroutes.py +0 -0
  65. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/routing.py +0 -0
  66. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/snapping.py +0 -0
  67. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/spacing.py +0 -0
  68. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/stacking.py +0 -0
  69. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/sugiyama.py +0 -0
  70. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/layout/zones.py +0 -0
  71. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/__init__.py +0 -0
  72. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/_mixins.py +0 -0
  73. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  74. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
  75. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  76. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
  77. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  78. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  79. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  80. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  81. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
  82. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
  83. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  84. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  85. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  86. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  87. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
  88. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
  89. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  90. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
  91. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
  92. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
  93. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  94. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  95. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/input.py +0 -0
  96. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
  97. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/manual.py +0 -0
  98. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  99. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  100. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  101. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  102. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
  103. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
  104. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/assets.py +0 -0
  105. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/color.py +0 -0
  106. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/converter.py +0 -0
  107. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  108. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/group.py +0 -0
  109. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/input.py +0 -0
  110. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/output.py +0 -0
  111. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/script.py +0 -0
  112. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/shader.py +0 -0
  113. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  114. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/nodes/shader/vector.py +0 -0
  115. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/py.typed +0 -0
  116. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/skills/nodebpy/references/shader-compositor.md +0 -0
  117. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/skills/nodebpy/references/verifying.md +0 -0
  118. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/skills/nodebpy/references/writing-trees.md +0 -0
  119. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/skills/nodebpy/references/zones-and-items.md +0 -0
  120. {nodebpy-520.37.0 → nodebpy-520.39.0}/src/nodebpy/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.37.0
3
+ Version: 520.39.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.37.0"
3
+ version = "520.39.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -5,6 +5,7 @@ from .builder import (
5
5
  SugiyamaOptions,
6
6
  TreeBuilder,
7
7
  arrange,
8
+ default_keep_edits,
8
9
  default_split_inputs,
9
10
  default_sugiyama_options,
10
11
  )
@@ -20,6 +21,7 @@ __all__ = [
20
21
  "arrange",
21
22
  "builder",
22
23
  "compositor",
24
+ "default_keep_edits",
23
25
  "default_split_inputs",
24
26
  "default_sugiyama_options",
25
27
  "export",
@@ -5,7 +5,7 @@ from ..layout import (
5
5
  arrange,
6
6
  default_sugiyama_options,
7
7
  )
8
- from ._utils import SocketError, denormalize_name, normalize_name
8
+ from ._utils import InvalidLinkWarning, SocketError, denormalize_name, normalize_name
9
9
  from .accessor import SocketAccessor
10
10
  from .asset import (
11
11
  AssetCompositorGroup,
@@ -82,6 +82,7 @@ from .tree import (
82
82
  SocketContext,
83
83
  TreeBuilder,
84
84
  WorldBuilder,
85
+ default_keep_edits,
85
86
  default_split_inputs,
86
87
  )
87
88
 
@@ -123,6 +124,7 @@ __all__ = [
123
124
  "IntegerSocketGrid",
124
125
  "IntegerSocketList",
125
126
  "IntegerVectorSocket",
127
+ "InvalidLinkWarning",
126
128
  "Item",
127
129
  "ItemsMixin",
128
130
  "LinkingMixin",
@@ -162,6 +164,7 @@ __all__ = [
162
164
  "arrange",
163
165
  "asset_group_base",
164
166
  "build_from_source",
167
+ "default_keep_edits",
165
168
  "default_split_inputs",
166
169
  "default_sugiyama_options",
167
170
  "denormalize_name",
@@ -20,6 +20,10 @@ class SocketError(Exception):
20
20
  """Raised when a socket operation fails."""
21
21
 
22
22
 
23
+ class InvalidLinkWarning(UserWarning):
24
+ """Emitted when a built tree holds links Blender can't evaluate."""
25
+
26
+
23
27
  _ENUM_NOT_FOUND = re.compile(
24
28
  r'enum "(?P<value>[^"]*)" not found in \((?P<options>.*)\)'
25
29
  )
@@ -15,6 +15,7 @@ Organization (top to bottom):
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import math
18
19
  from collections.abc import Iterable, Iterator, Mapping
19
20
  from typing import (
20
21
  TYPE_CHECKING,
@@ -1958,8 +1959,9 @@ class _FloatMixin[IntegerResult: (IntegerSocket, IntegerSocketGrid, IntegerSocke
1958
1959
  self._assert_output("power")
1959
1960
  return self._math.power(self.socket, exponent).o.value # ty: ignore[invalid-return-type]
1960
1961
 
1961
- def log(self, base: InputFloat = 2.0) -> Self:
1962
- """Return the logarithm of this value to *base*."""
1962
+ def log(self, base: InputFloat = math.e) -> Self:
1963
+ """Return the logarithm of this value to *base*, the natural logarithm
1964
+ by default (as ``math.log``)."""
1963
1965
  self._assert_output("log")
1964
1966
  return self._math.logarithm(self.socket, base).o.value # ty: ignore[invalid-return-type]
1965
1967
 
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import warnings
3
4
  from collections import Counter
4
- from collections.abc import Iterator
5
+ from collections.abc import Collection, Iterator
5
6
  from contextlib import AbstractContextManager, contextmanager
6
7
  from contextvars import ContextVar
7
8
  from dataclasses import dataclass, field
8
9
  from pathlib import Path
9
- from typing import Any, ClassVar, Literal, Self, TypeVar, cast
10
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, TypeVar, cast
10
11
 
11
12
  import bpy
12
13
  from bpy.types import (
@@ -22,6 +23,7 @@ from bpy.types import (
22
23
 
23
24
  from ..layout import ArrangeMethod
24
25
  from ..layout import arrange as _arrange_nodes
26
+ from ..layout import keep as _keep
25
27
  from ..types import (
26
28
  SOCKET_COMPATIBILITY,
27
29
  Default,
@@ -33,6 +35,7 @@ from ..types import (
33
35
  _SocketShapeStructureType,
34
36
  )
35
37
  from ._utils import (
38
+ InvalidLinkWarning,
36
39
  SocketError,
37
40
  _allow_innactive_sockets,
38
41
  enum_value_error,
@@ -62,6 +65,9 @@ from .socket import (
62
65
  VectorSocket,
63
66
  )
64
67
 
68
+ if TYPE_CHECKING:
69
+ from ..live import NodeAnimation
70
+
65
71
  _SocketT = TypeVar("_SocketT", bound=Socket)
66
72
 
67
73
  # What TreeBuilder's split_inputs=None resolves to; see `default_split_inputs`.
@@ -86,6 +92,41 @@ def default_split_inputs(split: bool = True) -> Iterator[None]:
86
92
  _DEFAULT_SPLIT_INPUTS.reset(token)
87
93
 
88
94
 
95
+ # What TreeBuilder's keep_edits=None resolves to; see `default_keep_edits`.
96
+ _DEFAULT_KEEP_EDITS: ContextVar[bool] = ContextVar(
97
+ "nodebpy_default_keep_edits", default=False
98
+ )
99
+
100
+
101
+ @contextmanager
102
+ def default_keep_edits(keep: bool = True) -> Iterator[None]:
103
+ """Scope in which every ``TreeBuilder`` left at its default
104
+ ``keep_edits`` keeps the layout edits made to the tree it rebuilds with
105
+ ``clear=True`` (see :class:`TreeBuilder`). :func:`nodebpy.live.run_source`
106
+ runs its code in this scope.
107
+ """
108
+ token = _DEFAULT_KEEP_EDITS.set(keep)
109
+ try:
110
+ yield
111
+ finally:
112
+ _DEFAULT_KEEP_EDITS.reset(token)
113
+
114
+
115
+ # Whether every TreeBuilder records what it built (see nodebpy.live.stamp_build),
116
+ # not only the clear=True rebuilds; run_source sets it for the group classes
117
+ # it rebuilds. Recording costs a pass over the nodes, so other builds skip it.
118
+ _RECORD_BUILDS: ContextVar[bool] = ContextVar("nodebpy_record_builds", default=False)
119
+
120
+
121
+ @contextmanager
122
+ def _record_builds() -> Iterator[None]:
123
+ token = _RECORD_BUILDS.set(True)
124
+ try:
125
+ yield
126
+ finally:
127
+ _RECORD_BUILDS.reset(token)
128
+
129
+
89
130
  # The interface ``default_input`` values Blender actually accepts at runtime,
90
131
  # per socket type (the RNA enum lists every option on every type, but
91
132
  # assignment validates a per-type subset — union across tree types here).
@@ -1138,6 +1179,7 @@ class TreeBuilder[TreeT: NodeTree]:
1138
1179
  ignore_visibility: bool = False,
1139
1180
  split_inputs: bool | None = None,
1140
1181
  clear: bool = False,
1182
+ keep_edits: bool | None = None,
1141
1183
  is_modifier: bool | None = None,
1142
1184
  is_tool: bool | None = None,
1143
1185
  ):
@@ -1154,6 +1196,10 @@ class TreeBuilder[TreeT: NodeTree]:
1154
1196
  # interface gives its sockets new identifiers, which is what
1155
1197
  # Geometry Nodes modifiers key their input values by, so those values
1156
1198
  # are snapshotted by socket name here and reapplied on exit.
1199
+ # ``keep_edits`` (default: see default_keep_edits) carries the layout
1200
+ # edits made to the tree since it was last arranged (moved, resized,
1201
+ # collapsed, framed or relabelled nodes) over to the rebuild, onto
1202
+ # the nodes that match; see nodebpy.layout.keep.
1157
1203
  if isinstance(tree, str):
1158
1204
  existing = bpy.data.node_groups.get(tree) if clear else None
1159
1205
  if existing is not None and existing.bl_idname == tree_type:
@@ -1163,12 +1209,33 @@ class TreeBuilder[TreeT: NodeTree]:
1163
1209
  else:
1164
1210
  self.tree = tree # type: ignore
1165
1211
  self._preserved_inputs: AbstractContextManager[None] | None = None
1212
+ self._kept_edits: _keep.Edits | None = None
1213
+ if keep_edits is None:
1214
+ keep_edits = _DEFAULT_KEEP_EDITS.get()
1215
+ if clear and keep_edits and arrange is not None:
1216
+ self._kept_edits = _keep.record(self.tree)
1217
+ self._animation: NodeAnimation | None = None
1218
+ self._unedited_users: list[NodeTree] = []
1219
+ self._record = clear or _RECORD_BUILDS.get()
1166
1220
  if clear:
1167
- from ..live import preserve_modifier_inputs
1221
+ from ..live import (
1222
+ NodeAnimation,
1223
+ keep_edited,
1224
+ preserve_modifier_inputs,
1225
+ unedited_users,
1226
+ was_edited,
1227
+ )
1168
1228
 
1229
+ self._unedited_users = unedited_users(self.tree)
1230
+ if was_edited(self.tree):
1231
+ copy = self.tree.copy()
1232
+ assert copy is not None
1233
+ keep_edited(copy, self.tree.name)
1169
1234
  assert self.tree.interface is not None
1170
1235
  self._preserved_inputs = preserve_modifier_inputs([self.tree])
1171
1236
  self._preserved_inputs.__enter__()
1237
+ # Removing the nodes would delete their keyframes and drivers.
1238
+ self._animation = NodeAnimation(self.tree)
1172
1239
  self.tree.nodes.clear()
1173
1240
  self.tree.interface.clear()
1174
1241
 
@@ -1197,6 +1264,7 @@ class TreeBuilder[TreeT: NodeTree]:
1197
1264
  fake_user: bool = False,
1198
1265
  split_inputs: bool | None = None,
1199
1266
  clear: bool = False,
1267
+ keep_edits: bool | None = None,
1200
1268
  is_modifier: bool | None = None,
1201
1269
  is_tool: bool | None = None,
1202
1270
  ) -> TreeBuilder[GeometryNodeTree]:
@@ -1215,6 +1283,7 @@ class TreeBuilder[TreeT: NodeTree]:
1215
1283
  fake_user=fake_user,
1216
1284
  split_inputs=split_inputs,
1217
1285
  clear=clear,
1286
+ keep_edits=keep_edits,
1218
1287
  is_modifier=is_modifier,
1219
1288
  is_tool=is_tool,
1220
1289
  ),
@@ -1230,6 +1299,7 @@ class TreeBuilder[TreeT: NodeTree]:
1230
1299
  fake_user: bool = False,
1231
1300
  split_inputs: bool | None = None,
1232
1301
  clear: bool = False,
1302
+ keep_edits: bool | None = None,
1233
1303
  ) -> TreeBuilder[ShaderNodeTree]:
1234
1304
  """Create a shader node tree."""
1235
1305
  return cast(
@@ -1242,6 +1312,7 @@ class TreeBuilder[TreeT: NodeTree]:
1242
1312
  fake_user=fake_user,
1243
1313
  split_inputs=split_inputs,
1244
1314
  clear=clear,
1315
+ keep_edits=keep_edits,
1245
1316
  ),
1246
1317
  )
1247
1318
 
@@ -1255,6 +1326,7 @@ class TreeBuilder[TreeT: NodeTree]:
1255
1326
  fake_user: bool = False,
1256
1327
  split_inputs: bool | None = None,
1257
1328
  clear: bool = False,
1329
+ keep_edits: bool | None = None,
1258
1330
  scene: bool = False,
1259
1331
  ) -> TreeBuilder[CompositorNodeTree]:
1260
1332
  """Create a compositor node tree; ``scene=True`` also makes it the
@@ -1269,6 +1341,7 @@ class TreeBuilder[TreeT: NodeTree]:
1269
1341
  fake_user=fake_user,
1270
1342
  split_inputs=split_inputs,
1271
1343
  clear=clear,
1344
+ keep_edits=keep_edits,
1272
1345
  ),
1273
1346
  )
1274
1347
  if scene:
@@ -1318,6 +1391,8 @@ class TreeBuilder[TreeT: NodeTree]:
1318
1391
  format: bool = True,
1319
1392
  nodebpy_pkg: str = "nodebpy",
1320
1393
  in_place: bool = False,
1394
+ groups: Literal["inline", "reference"] = "inline",
1395
+ group_modules: Collection[str] | None = None,
1321
1396
  ) -> str:
1322
1397
  """Generate Python source that recreates this tree using nodebpy.
1323
1398
 
@@ -1336,6 +1411,8 @@ class TreeBuilder[TreeT: NodeTree]:
1336
1411
  format=format,
1337
1412
  nodebpy_pkg=nodebpy_pkg,
1338
1413
  in_place=in_place,
1414
+ groups=groups,
1415
+ group_modules=group_modules,
1339
1416
  )
1340
1417
 
1341
1418
  def to_mermaid(self, fenced: bool = True) -> str:
@@ -1415,16 +1492,82 @@ class TreeBuilder[TreeT: NodeTree]:
1415
1492
  self.split_group_inputs()
1416
1493
  if self._arrange is not None:
1417
1494
  self.arrange()
1495
+ if self._kept_edits is not None:
1496
+ _keep.restore(self._kept_edits, self.tree)
1497
+ self._kept_edits = None
1498
+ if self._animation is not None:
1499
+ self._animation.restore()
1500
+ self._animation = None
1418
1501
  self._apply_input_defaults()
1419
1502
  if self._preserved_inputs is not None:
1420
1503
  self._preserved_inputs.__exit__(None, None, None)
1421
1504
  self._preserved_inputs = None
1505
+ self._warn_invalid_links()
1506
+ if self._record:
1507
+ from ..live import stamp_build
1508
+
1509
+ stamp_build(self.tree)
1510
+ for user in self._unedited_users:
1511
+ stamp_build(user)
1512
+ self._unedited_users = []
1422
1513
  # Interface-menu defaults assigned from here on can't wait for a
1423
1514
  # context exit that already happened — apply them immediately
1424
1515
  # (see MenuSocket.default_value).
1425
1516
  self._exited = True
1426
1517
  self.deactivate_tree()
1427
1518
 
1519
+ def invalid_links(self) -> list[bpy.types.NodeLink]:
1520
+ """Links Blender can't evaluate, drawn red in the node editor: most
1521
+ often a field reaching an input that takes a single value, such as a
1522
+ Repeat Zone's Iterations or Points' Count. Muted links and links to
1523
+ sockets the node's settings hide or grey out aren't evaluated, so they
1524
+ aren't reported."""
1525
+
1526
+ def evaluated(socket: NodeSocket | None) -> bool:
1527
+ return (
1528
+ socket is not None
1529
+ and not socket.is_unavailable
1530
+ and not socket.is_inactive
1531
+ )
1532
+
1533
+ return [
1534
+ link
1535
+ for link in self.tree.links
1536
+ if not link.is_valid
1537
+ and not link.is_muted
1538
+ and not link.is_hidden
1539
+ and evaluated(link.from_socket)
1540
+ and evaluated(link.to_socket)
1541
+ ]
1542
+
1543
+ def _warn_invalid_links(self) -> None:
1544
+ invalid = self.invalid_links()
1545
+ if not invalid:
1546
+ return
1547
+ lines = []
1548
+ for link in invalid:
1549
+ source, target = link.from_socket, link.to_socket
1550
+ assert source is not None and target is not None
1551
+ assert link.from_node is not None and link.to_node is not None
1552
+ line = (
1553
+ f" {link.from_node.name}.{source.name} -> "
1554
+ f"{link.to_node.name}.{target.name}"
1555
+ )
1556
+ # Blender draws inputs that only take a single value as a line.
1557
+ if (
1558
+ target.display_shape == "LINE"
1559
+ and source.inferred_structure_type == "FIELD"
1560
+ ):
1561
+ line += f" ('{target.name}' takes a single value, not a field)"
1562
+ lines.append(line)
1563
+ count = "1 link" if len(invalid) == 1 else f"{len(invalid)} links"
1564
+ warnings.warn(
1565
+ f"Blender can't evaluate {count} in '{self.tree.name}':\n"
1566
+ + "\n".join(lines),
1567
+ InvalidLinkWarning,
1568
+ stacklevel=3,
1569
+ )
1570
+
1428
1571
  def _apply_input_defaults(self) -> None:
1429
1572
  if not self._menu_defaults:
1430
1573
  return
@@ -1880,12 +2023,14 @@ class MaterialBuilder(TreeBuilder[ShaderNodeTree]):
1880
2023
  fake_user: bool = False,
1881
2024
  ignore_visibility: bool = False,
1882
2025
  clear: bool = False,
2026
+ keep_edits: bool | None = None,
1883
2027
  ):
1884
2028
  # With ``clear`` an existing material of that name is rebuilt in place
1885
2029
  # (objects keep it assigned) instead of creating ``Name.001``. A new
1886
2030
  # material starts with Blender's default Principled BSDF and Material
1887
2031
  # Output nodes, which are removed so the body builds from empty.
1888
2032
  material = bpy.data.materials.get(name) if clear else None
2033
+ reused = material is not None
1889
2034
  if material is None:
1890
2035
  material = bpy.data.materials.new(name)
1891
2036
  assert material is not None
@@ -1899,6 +2044,7 @@ class MaterialBuilder(TreeBuilder[ShaderNodeTree]):
1899
2044
  fake_user=fake_user,
1900
2045
  ignore_visibility=ignore_visibility,
1901
2046
  clear=True,
2047
+ keep_edits=keep_edits if reused else False,
1902
2048
  )
1903
2049
 
1904
2050
 
@@ -1915,12 +2061,14 @@ class WorldBuilder(TreeBuilder[ShaderNodeTree]):
1915
2061
  fake_user: bool = False,
1916
2062
  ignore_visibility: bool = False,
1917
2063
  clear: bool = False,
2064
+ keep_edits: bool | None = None,
1918
2065
  scene: bool = False,
1919
2066
  ):
1920
2067
  # With ``clear`` an existing world of that name is rebuilt in place (a
1921
2068
  # scene using it keeps it). A new world's default Background and World
1922
2069
  # Output nodes are removed so the body builds from empty.
1923
2070
  world = bpy.data.worlds.get(name) if clear else None
2071
+ reused = world is not None
1924
2072
  if world is None:
1925
2073
  world = bpy.data.worlds.new(name)
1926
2074
  assert world is not None and world.node_tree is not None
@@ -1933,6 +2081,7 @@ class WorldBuilder(TreeBuilder[ShaderNodeTree]):
1933
2081
  fake_user=fake_user,
1934
2082
  ignore_visibility=ignore_visibility,
1935
2083
  clear=True,
2084
+ keep_edits=keep_edits if reused else False,
1936
2085
  )
1937
2086
  if scene:
1938
2087
  active = bpy.context.scene
@@ -0,0 +1,168 @@
1
+ """Group classes that exist in Python already, for
2
+ ``to_python(groups="reference")``.
3
+
4
+ A tree can use a group that a loaded class builds or links: one of Blender's
5
+ bundled assets (``g.Array``), one of nodebpy's own groups, a class from a
6
+ generated asset module or one the live script defines. Codegen can then call
7
+ that class instead of writing the group out again.
8
+
9
+ A tree belongs to a class when:
10
+
11
+ - an asset class linked it: the tree is linked from the class's library under
12
+ the asset's name;
13
+ - it was appended from that library (the asset browser's default), which
14
+ leaves a weak reference to the file and the asset's name;
15
+ - it is a local tree with the name the class gives its tree, which is the tree
16
+ the class reuses when it runs. A tree changed by hand since nodebpy built
17
+ it in this session is not matched, so the edits are written out.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import inspect
24
+ import os
25
+ import pkgutil
26
+ import sys
27
+ from collections.abc import Iterable, Iterator
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+ import bpy
32
+
33
+ from ..builder._utils import typed_param_names
34
+ from ..builder.asset import BundledLibrary, _AssetGroupMixin
35
+ from ..builder.node import NodeGroupBuilder
36
+
37
+ # Modules a script's own classes live in: run_source runs code as
38
+ # __nodebpy_live__, Blender runs scripts and Text blocks as __main__.
39
+ SCRIPT_MODULES = frozenset({"__main__", "__nodebpy_live__"})
40
+
41
+ # Modules holding nodebpy's own group classes, which nothing else imports.
42
+ _BUILTIN_MODULES = ("nodebpy.nodes.geometry.groups",)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Reference:
47
+ """How codegen calls the class that builds a group node's tree."""
48
+
49
+ cls: type[NodeGroupBuilder]
50
+ params: dict[str, str] | None
51
+ """Socket identifier → ``__init__`` parameter for a typed class, or
52
+ ``None`` to pass inputs by socket name."""
53
+
54
+
55
+ def import_modules(names: Iterable[str]) -> None:
56
+ """Import each module, and every submodule of a package, so the group
57
+ classes they define can be found."""
58
+ for name in names:
59
+ module = importlib.import_module(name)
60
+ if hasattr(module, "__path__"):
61
+ for info in pkgutil.walk_packages(module.__path__, f"{name}."):
62
+ importlib.import_module(info.name)
63
+
64
+
65
+ def configured_modules(start: str | Path | None = None) -> list[str]:
66
+ """The package holding the dumped sources that ``[tool.nodebpy.assets]``
67
+ names in the nearest ``pyproject.toml``, if it can be imported from a
68
+ ``sys.path`` entry."""
69
+ from ..assets._pipeline import load_config
70
+
71
+ table, root = load_config(start)
72
+ source = table.get("source")
73
+ if not source or root is None:
74
+ return []
75
+ path = (root / source).resolve()
76
+ names = []
77
+ for entry in sys.path:
78
+ try:
79
+ parts = path.relative_to(Path(entry or ".").resolve()).parts
80
+ except ValueError:
81
+ continue
82
+ if parts and all(part.isidentifier() for part in parts):
83
+ names.append(".".join(parts))
84
+ return [min(names, key=len)] if names else []
85
+
86
+
87
+ def _subclasses[T](cls: type[T]) -> Iterator[type[T]]:
88
+ for sub in cls.__subclasses__():
89
+ yield sub
90
+ yield from _subclasses(sub)
91
+
92
+
93
+ def _group_classes() -> list[type[NodeGroupBuilder]]:
94
+ import_modules(_BUILTIN_MODULES)
95
+ return [
96
+ cls
97
+ for cls in dict.fromkeys(_subclasses(NodeGroupBuilder))
98
+ if not inspect.isabstract(cls)
99
+ and (getattr(cls, "_asset_name", None) or getattr(cls, "_name", None))
100
+ ]
101
+
102
+
103
+ def _path(path: str) -> str:
104
+ return os.path.normcase(os.path.realpath(bpy.path.abspath(path)))
105
+
106
+
107
+ def _from_library(cls: type[_AssetGroupMixin], path: str) -> bool:
108
+ library = cls._library
109
+ if isinstance(library, BundledLibrary):
110
+ # Blender installs differ, the bundled file names don't.
111
+ return os.path.basename(bpy.path.abspath(path)) == library.filename
112
+ return _path(path) == _path(library.path())
113
+
114
+
115
+ def _builds(cls: type[NodeGroupBuilder], tree: bpy.types.NodeTree) -> bool:
116
+ if cls._tree_idname != tree.bl_idname:
117
+ return False
118
+ if issubclass(cls, _AssetGroupMixin):
119
+ name = cls._asset_name
120
+ if tree.library is not None:
121
+ return tree.name == name and _from_library(cls, tree.library.filepath)
122
+ weak = tree.library_weak_reference
123
+ if weak is not None:
124
+ return weak.id_name[2:] == name and _from_library(cls, weak.filepath)
125
+ return tree.name == name
126
+ # A class choosing its tree's name per instance can't be matched by name.
127
+ return (
128
+ tree.library is None
129
+ and cls._group_name is NodeGroupBuilder._group_name
130
+ and tree.name == getattr(cls, "_name", None)
131
+ )
132
+
133
+
134
+ def find_class(tree: bpy.types.NodeTree) -> type[NodeGroupBuilder] | None:
135
+ """The loaded class that builds or links *tree*, the latest defined when
136
+ several do (a live script defines its classes again on every run)."""
137
+ from ..live import was_edited
138
+
139
+ if tree.library is None and was_edited(tree):
140
+ return None
141
+ found = [cls for cls in _group_classes() if _builds(cls, tree)]
142
+ return found[-1] if found else None
143
+
144
+
145
+ def reference(node: bpy.types.Node) -> Reference | None:
146
+ """How to call the class that builds *node*'s group, or ``None`` when no
147
+ loaded class does or its ``__init__`` can't take the node's inputs."""
148
+ tree = getattr(node, "node_tree", None)
149
+ cls = find_class(tree) if tree is not None else None
150
+ if cls is None:
151
+ return None
152
+ if cls.__init__ is NodeGroupBuilder.__init__:
153
+ return Reference(cls, None)
154
+ parameters = list(inspect.signature(cls.__init__).parameters.values())[1:]
155
+ named = {
156
+ p.name: p
157
+ for p in parameters
158
+ if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)
159
+ }
160
+ if not named and any(p.kind is p.VAR_KEYWORD for p in parameters):
161
+ # ``__init__(self, **kwargs)`` passes socket names through.
162
+ return Reference(cls, None)
163
+ params = typed_param_names(node.inputs)
164
+ if not set(params.values()) <= named.keys() or any(
165
+ p.default is p.empty for p in named.values()
166
+ ):
167
+ return None
168
+ return Reference(cls, params)