nodebpy 520.27.0__tar.gz → 520.29.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 (90) hide show
  1. {nodebpy-520.27.0 → nodebpy-520.29.0}/PKG-INFO +1 -1
  2. {nodebpy-520.27.0 → nodebpy-520.29.0}/pyproject.toml +1 -1
  3. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/__init__.py +2 -1
  4. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/tree.py +26 -1
  5. nodebpy-520.29.0/src/nodebpy/export/_floats.py +114 -0
  6. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/export/codegen.py +22 -106
  7. nodebpy-520.29.0/src/nodebpy/live.py +361 -0
  8. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/color.py +39 -39
  9. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/converter.py +51 -42
  10. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/distort.py +27 -27
  11. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/filter.py +81 -67
  12. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/input.py +6 -5
  13. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/interface.py +4 -4
  14. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/manual.py +2 -1
  15. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/matte.py +21 -20
  16. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/output.py +1 -1
  17. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/assets.py +9 -8
  18. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/attribute.py +2 -2
  19. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/color.py +2 -2
  20. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/converter.py +75 -66
  21. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/geometry.py +157 -146
  22. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/grid.py +14 -14
  23. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/input.py +1 -1
  24. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/interface.py +9 -9
  25. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/manual.py +2 -1
  26. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/texture.py +38 -35
  27. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/utilities.py +1 -1
  28. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/vector.py +88 -58
  29. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/color.py +3 -3
  30. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/converter.py +25 -17
  31. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/grid.py +9 -9
  32. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/input.py +7 -7
  33. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/manual.py +2 -1
  34. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/output.py +3 -3
  35. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/shader.py +58 -58
  36. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/texture.py +11 -10
  37. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/vector.py +27 -27
  38. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/types.py +8 -1
  39. {nodebpy-520.27.0 → nodebpy-520.29.0}/README.md +0 -0
  40. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/assets/__init__.py +0 -0
  41. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/assets/__main__.py +0 -0
  42. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/assets/_codegen.py +0 -0
  43. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/assets/_library.py +0 -0
  44. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/assets/_pipeline.py +0 -0
  45. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/__init__.py +0 -0
  46. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/_registry.py +0 -0
  47. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/_socket_order.py +0 -0
  48. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/_utils.py +0 -0
  49. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/accessor.py +0 -0
  50. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/asset.py +0 -0
  51. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/items.py +0 -0
  52. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/layout.py +0 -0
  53. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/mixins.py +0 -0
  54. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/node.py +0 -0
  55. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/builder/socket.py +0 -0
  56. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/export/__init__.py +0 -0
  57. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/export/diagram.py +0 -0
  58. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/export/parity.py +0 -0
  59. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/export/plot.py +0 -0
  60. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/export/web_render.py +0 -0
  61. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/VENDORED.md +0 -0
  62. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  63. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  64. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/balancing.py +0 -0
  65. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  66. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  67. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  68. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  69. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  70. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  71. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
  72. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
  73. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
  74. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
  75. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
  76. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/__init__.py +0 -0
  77. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/_mixins.py +0 -0
  78. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  79. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
  80. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  81. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  82. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  83. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  84. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  85. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  86. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
  87. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
  88. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/assets.py +0 -0
  89. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/group.py +0 -0
  90. {nodebpy-520.27.0 → nodebpy-520.29.0}/src/nodebpy/nodes/shader/script.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.27.0
3
+ Version: 520.29.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.27.0"
3
+ version = "520.29.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,4 +1,4 @@
1
- from . import builder, export, nodes, types
1
+ from . import builder, export, live, nodes, types
2
2
  from .builder import (
3
3
  ArrangeMethod,
4
4
  SimpleOptions,
@@ -22,6 +22,7 @@ __all__ = [
22
22
  "default_sugiyama_options",
23
23
  "export",
24
24
  "geometry",
25
+ "live",
25
26
  "nodes",
26
27
  "shader",
27
28
  "types",
@@ -1108,11 +1108,28 @@ class TreeBuilder[TreeT: NodeTree]:
1108
1108
  fake_user: bool = False,
1109
1109
  ignore_visibility: bool = False,
1110
1110
  split_inputs: bool | None = None,
1111
+ clear: bool = False,
1111
1112
  ):
1113
+ # ``clear`` rebuilds in place: the tree is emptied (nodes, links and
1114
+ # interface) before the body runs, keeping the datablock so modifiers,
1115
+ # group nodes and pinned editors that reference it stay attached. With
1116
+ # a name, an existing group of that name and tree type is reused
1117
+ # instead of creating a ``Name.001`` duplicate; a group of another
1118
+ # tree type is left alone and a new one is created as usual.
1119
+ # Tree-level properties (description, color_tag, is_tool, fake user,
1120
+ # ...) are kept: only the contents are rebuilt.
1112
1121
  if isinstance(tree, str):
1113
- self.tree = bpy.data.node_groups.new(tree, tree_type) # ty: ignore[invalid-assignment]
1122
+ existing = bpy.data.node_groups.get(tree) if clear else None
1123
+ if existing is not None and existing.bl_idname == tree_type:
1124
+ self.tree = existing
1125
+ else:
1126
+ self.tree = bpy.data.node_groups.new(tree, tree_type) # ty: ignore[invalid-assignment]
1114
1127
  else:
1115
1128
  self.tree = tree # type: ignore
1129
+ if clear:
1130
+ assert self.tree.interface is not None
1131
+ self.tree.nodes.clear()
1132
+ self.tree.interface.clear()
1116
1133
 
1117
1134
  self._menu_defaults: list[_MenuDefault] = []
1118
1135
  self._exited = False
@@ -1133,6 +1150,7 @@ class TreeBuilder[TreeT: NodeTree]:
1133
1150
  arrange: ArrangeMethod = "sugiyama",
1134
1151
  fake_user: bool = False,
1135
1152
  split_inputs: bool | None = None,
1153
+ clear: bool = False,
1136
1154
  ) -> TreeBuilder[GeometryNodeTree]:
1137
1155
  """Create a geometry node tree."""
1138
1156
  return cast(
@@ -1144,6 +1162,7 @@ class TreeBuilder[TreeT: NodeTree]:
1144
1162
  arrange=arrange,
1145
1163
  fake_user=fake_user,
1146
1164
  split_inputs=split_inputs,
1165
+ clear=clear,
1147
1166
  ),
1148
1167
  )
1149
1168
 
@@ -1156,6 +1175,7 @@ class TreeBuilder[TreeT: NodeTree]:
1156
1175
  arrange: ArrangeMethod = "sugiyama",
1157
1176
  fake_user: bool = False,
1158
1177
  split_inputs: bool | None = None,
1178
+ clear: bool = False,
1159
1179
  ) -> TreeBuilder[ShaderNodeTree]:
1160
1180
  """Create a shader node tree."""
1161
1181
  return cast(
@@ -1167,6 +1187,7 @@ class TreeBuilder[TreeT: NodeTree]:
1167
1187
  arrange=arrange,
1168
1188
  fake_user=fake_user,
1169
1189
  split_inputs=split_inputs,
1190
+ clear=clear,
1170
1191
  ),
1171
1192
  )
1172
1193
 
@@ -1179,6 +1200,7 @@ class TreeBuilder[TreeT: NodeTree]:
1179
1200
  arrange: ArrangeMethod = "sugiyama",
1180
1201
  fake_user: bool = False,
1181
1202
  split_inputs: bool | None = None,
1203
+ clear: bool = False,
1182
1204
  ) -> TreeBuilder[CompositorNodeTree]:
1183
1205
  """Create a compositor node tree."""
1184
1206
  return cast(
@@ -1190,6 +1212,7 @@ class TreeBuilder[TreeT: NodeTree]:
1190
1212
  arrange=arrange,
1191
1213
  fake_user=fake_user,
1192
1214
  split_inputs=split_inputs,
1215
+ clear=clear,
1193
1216
  ),
1194
1217
  )
1195
1218
 
@@ -1215,6 +1238,7 @@ class TreeBuilder[TreeT: NodeTree]:
1215
1238
  top_level: Literal["with", "class"] = "with",
1216
1239
  format: bool = True,
1217
1240
  nodebpy_pkg: str = "nodebpy",
1241
+ in_place: bool = False,
1218
1242
  ) -> str:
1219
1243
  """Generate Python source that recreates this tree using nodebpy.
1220
1244
 
@@ -1232,6 +1256,7 @@ class TreeBuilder[TreeT: NodeTree]:
1232
1256
  top_level=top_level,
1233
1257
  format=format,
1234
1258
  nodebpy_pkg=nodebpy_pkg,
1259
+ in_place=in_place,
1235
1260
  )
1236
1261
 
1237
1262
  def to_mermaid(self, fenced: bool = True) -> str:
@@ -0,0 +1,114 @@
1
+ """Float formatting shared by ``to_python`` export and the node-class generator.
2
+
3
+ Blender stores socket values as float32. This module renders such a value as
4
+ the shortest Python literal that rebuilds the identical float32 (``0.1`` rather
5
+ than ``0.10000000149011612``), or as a ``math.pi`` / ``math.tau`` / ``math.e``
6
+ expression for the rational multiples of those constants (``3 * math.pi / 4``).
7
+
8
+ It is a leaf: stdlib and numpy only, no ``nodebpy`` imports. The generator in
9
+ ``gen/`` loads it straight from this file (see ``gen/config.py``) so that a
10
+ broken generated tree can never block regenerating it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+
17
+ import numpy as np
18
+
19
+
20
+ def within_one_ulp(candidate: float, f32) -> bool:
21
+ """Whether ``candidate`` lands on ``f32`` or its immediate float32
22
+ neighbour. One ULP is the round-off a value picks up crossing precisions
23
+ (an authored ``0.15`` surviving as ``0.14999999``), so snapping across it
24
+ restores the authored constant without moving any genuinely different
25
+ value."""
26
+ # A candidate beyond float32 range (a huge clamp bound) overflows the
27
+ # cast to inf with a RuntimeWarning; it is never within one ULP.
28
+ with np.errstate(over="ignore"):
29
+ c32 = np.float32(candidate)
30
+ if not np.isfinite(c32):
31
+ return False
32
+ return bool(c32 == f32 or np.nextafter(f32, c32) == c32)
33
+
34
+
35
+ def math_constant_expression(f32) -> str | None:
36
+ """A ``math.pi``/``math.tau``/``math.e`` expression for a rational
37
+ multiple of one of those constants, or None.
38
+
39
+ Constants authored as expressions (``2 * math.pi``, ``pi / 3``, ``tau``,
40
+ ``e``) survive in a blend only as float32 values; matching small-fraction
41
+ multiples (within one ULP) restores the readable form. Numerators up to 48
42
+ over denominators up to 12 cover the usual turns and subdivisions while
43
+ keeping a chance coincidence with an ordinary decimal essentially
44
+ impossible. An even multiple of pi renders in tau form (``math.tau``,
45
+ ``math.tau / 3``) — with the fraction reduced, halving the numerator is
46
+ always the simpler expression.
47
+ """
48
+ magnitude = abs(float(f32))
49
+ if magnitude == 0.0:
50
+ return None
51
+ for name, const in (("pi", math.pi), ("e", math.e)):
52
+ for den in range(1, 13):
53
+ num = round(magnitude * den / const)
54
+ if not 1 <= num <= 48 or math.gcd(num, den) != 1:
55
+ continue
56
+ if not within_one_ulp(num * const / den, abs(f32)):
57
+ continue
58
+ if name == "pi" and num % 2 == 0:
59
+ name, num = "tau", num // 2
60
+ expr = f"math.{name}" if num == 1 else f"{num} * math.{name}"
61
+ if den > 1:
62
+ expr = f"{expr} / {den}"
63
+ return f"-{expr}" if float(f32) < 0 else expr
64
+ return None
65
+
66
+
67
+ def group_digits(text: str) -> str:
68
+ """Underscore-group a positional float literal's integer part when it has
69
+ five or more digits (``10000.0`` → ``10_000.0``)."""
70
+ sign = ""
71
+ if text.startswith("-"):
72
+ sign, text = "-", text[1:]
73
+ int_part, dot, frac = text.partition(".")
74
+ if len(int_part) < 5:
75
+ return sign + text
76
+ return sign + f"{int(int_part):_}" + dot + frac
77
+
78
+
79
+ def fmt_float(value: float, *, snap: bool = True) -> str:
80
+ """Shortest readable literal for a float32-backed value.
81
+
82
+ Blender stores socket values as float32, so reading them back through
83
+ Python gives noisy float64 reprs (``0.10000000149011612``); the shortest
84
+ decimal that uniquely identifies the float32 (``0.1``) rebuilds the
85
+ identical socket value. On top of that, rational multiples of pi, tau
86
+ and e render as ``math.*`` expressions, a value one ULP off a much shorter decimal
87
+ snaps to it (``0.14999999`` → ``0.15``), and long integer parts get
88
+ underscore grouping (``-10_000.0``). ``snap=False`` keeps the literal that
89
+ rebuilds exactly this float32 (a generated default must be Blender's own
90
+ value, not its prettier neighbour).
91
+ """
92
+ if not math.isfinite(value):
93
+ return repr(value)
94
+ f32 = np.float32(value)
95
+ if float(f32) != value:
96
+ return repr(value) # genuine float64 — keep full precision
97
+ const_expr = math_constant_expression(f32)
98
+ if const_expr is not None:
99
+ return const_expr
100
+ text = np.format_float_positional(f32, unique=True, trim="0")
101
+ # Snap to the shortest decimal within one ULP: fewer significant digits
102
+ # first, so ``0.14999999`` becomes ``0.15`` rather than ``0.1499999``.
103
+ digits = sum(c.isdigit() for c in text.lstrip("-0.")) if snap else 0
104
+ for sig in range(1, digits):
105
+ candidate = float(f"%.{sig}g" % float(f32))
106
+ if not within_one_ulp(candidate, f32):
107
+ continue
108
+ snapped = np.format_float_positional(
109
+ np.float32(candidate), unique=True, trim="0"
110
+ )
111
+ if len(snapped) < len(text):
112
+ text = snapped
113
+ break
114
+ return group_digits(text)
@@ -31,8 +31,13 @@ from collections.abc import Callable, Collection, Mapping
31
31
  from dataclasses import dataclass, field
32
32
  from typing import TYPE_CHECKING, Any, Literal, NamedTuple
33
33
 
34
+ import numpy as np
34
35
  from bpy.types import ID, FunctionNodeCompare, NodeTree
35
36
 
37
+ from ._floats import (
38
+ fmt_float as _fmt_float,
39
+ )
40
+
36
41
  if TYPE_CHECKING:
37
42
  from ..builder.tree import TreeBuilder
38
43
 
@@ -354,111 +359,6 @@ def _stmt_lines(
354
359
  # ---------------------------------------------------------------------------
355
360
 
356
361
 
357
- def _within_one_ulp(candidate: float, f32) -> bool:
358
- """Whether ``candidate`` lands on ``f32`` or its immediate float32
359
- neighbour. One ULP is the round-off a value picks up crossing precisions
360
- (an authored ``0.15`` surviving as ``0.14999999``), so snapping across it
361
- restores the authored constant without moving any genuinely different
362
- value."""
363
- import numpy as np
364
-
365
- # A candidate beyond float32 range (a huge clamp bound) overflows the
366
- # cast to inf with a RuntimeWarning; it is never within one ULP.
367
- with np.errstate(over="ignore"):
368
- c32 = np.float32(candidate)
369
- if not np.isfinite(c32):
370
- return False
371
- return bool(c32 == f32 or np.nextafter(f32, c32) == c32)
372
-
373
-
374
- def _math_constant_expression(f32) -> str | None:
375
- """A ``math.pi``/``math.tau``/``math.e`` expression for a rational
376
- multiple of one of those constants, or None.
377
-
378
- Constants authored as expressions (``2 * math.pi``, ``pi / 3``, ``tau``,
379
- ``e``) survive in a blend only as float32 values; matching small-fraction
380
- multiples (within one ULP) restores the readable form. Numerators up to 48
381
- over denominators up to 12 cover the usual turns and subdivisions while
382
- keeping a chance coincidence with an ordinary decimal essentially
383
- impossible. An even multiple of pi renders in tau form (``math.tau``,
384
- ``math.tau / 3``) — with the fraction reduced, halving the numerator is
385
- always the simpler expression.
386
- """
387
- import math
388
-
389
- magnitude = abs(float(f32))
390
- if magnitude == 0.0:
391
- return None
392
- for name, const in (("pi", math.pi), ("e", math.e)):
393
- for den in range(1, 13):
394
- num = round(magnitude * den / const)
395
- if not 1 <= num <= 48 or math.gcd(num, den) != 1:
396
- continue
397
- if not _within_one_ulp(num * const / den, abs(f32)):
398
- continue
399
- if name == "pi" and num % 2 == 0:
400
- name, num = "tau", num // 2
401
- expr = f"math.{name}" if num == 1 else f"{num} * math.{name}"
402
- if den > 1:
403
- expr = f"{expr} / {den}"
404
- return f"-{expr}" if float(f32) < 0 else expr
405
- return None
406
-
407
-
408
- def _group_digits(text: str) -> str:
409
- """Underscore-group a positional float literal's integer part when it has
410
- five or more digits (``10000.0`` → ``10_000.0``)."""
411
- sign = ""
412
- if text.startswith("-"):
413
- sign, text = "-", text[1:]
414
- int_part, dot, frac = text.partition(".")
415
- if len(int_part) < 5:
416
- return sign + text
417
- return sign + f"{int(int_part):_}" + dot + frac
418
-
419
-
420
- def _fmt_float(value: float) -> str:
421
- """Shortest readable literal for a float32-backed value.
422
-
423
- Blender stores socket values as float32, so reading them back through
424
- Python gives noisy float64 reprs (``0.10000000149011612``); the shortest
425
- decimal that uniquely identifies the float32 (``0.1``) rebuilds the
426
- identical socket value. On top of that, rational multiples of pi, tau
427
- and e render as ``math.*`` expressions, a value one ULP off a much shorter decimal
428
- snaps to it (``0.14999999`` → ``0.15``), and long integer parts get
429
- underscore grouping (``-10_000.0``).
430
- """
431
- import math
432
-
433
- if not math.isfinite(value):
434
- return repr(value)
435
- try:
436
- import numpy as np
437
- except ImportError: # pragma: no cover - Blender ships numpy
438
- return repr(value)
439
- f32 = np.float32(value)
440
- if float(f32) != value:
441
- return repr(value) # genuine float64 — keep full precision
442
- const_expr = _math_constant_expression(f32)
443
- if const_expr is not None:
444
- return const_expr
445
- text = np.format_float_positional(f32, unique=True, trim="0")
446
- # Snap to the shortest decimal within one ULP: fewer significant digits
447
- # first, so ``0.14999999`` becomes ``0.15`` rather than ``0.1499999``.
448
- digits = sum(c.isdigit() for c in text.lstrip("-0."))
449
- for sig in range(1, digits):
450
- candidate = float(f"%.{sig}g" % float(f32))
451
- if not _within_one_ulp(candidate, f32):
452
- continue
453
- snapped = np.format_float_positional(
454
- np.float32(candidate), unique=True, trim="0"
455
- )
456
- if len(snapped) < len(text):
457
- text = snapped
458
- break
459
- return _group_digits(text)
460
-
461
-
462
362
  def _fmt(value: Any) -> str:
463
363
  """Format a value as a Python literal using double-quoted strings."""
464
364
  if isinstance(value, bool):
@@ -507,7 +407,9 @@ def _eq(a: Any, b: Any) -> bool:
507
407
  return False
508
408
  return all(_eq(x, y) for x, y in zip(a, b))
509
409
  if isinstance(a, (int, float)) and isinstance(b, (int, float)):
510
- return float(a) == float(b)
410
+ # Sockets store float32: a Python default of ``0.05`` reads back
411
+ # as ``0.05000000074505806``, so compare at that precision.
412
+ return bool(np.float32(a) == np.float32(b))
511
413
  return a == b
512
414
  except Exception: # noqa: BLE001
513
415
  return False
@@ -3724,6 +3626,7 @@ def to_python(
3724
3626
  external_groups: Collection[str] | None = None,
3725
3627
  typed_groups: Collection[str] | None = None,
3726
3628
  root_interface: GroupInterface | None = None,
3629
+ in_place: bool = False,
3727
3630
  ) -> str:
3728
3631
  """Generate Python code that recreates the given node tree using nodebpy.
3729
3632
 
@@ -3794,6 +3697,17 @@ def to_python(
3794
3697
  Typed-interface parts (docstring, class attributes, accessors and
3795
3698
  ``__init__``) spliced into the top-level tree's class in ``class``
3796
3699
  mode, ahead of ``_build_group``.
3700
+ in_place: bool
3701
+ If True, the ``with`` header is emitted as
3702
+ ``with g.tree("Name", clear=True) as tree:`` so running the source
3703
+ rebuilds the exported tree in place — the same datablock, emptied
3704
+ first — instead of creating a ``Name.001`` copy, keeping modifiers,
3705
+ group nodes and pinned editors attached across re-runs. Only the
3706
+ top-level tree is rebuilt this way: nested groups are emitted as
3707
+ ``Custom*Group`` classes whose ``create_group()`` reuses an existing
3708
+ tree of the same name, so edits to them only take effect through
3709
+ :func:`nodebpy.live.run_source`. Ignored with ``top_level="class"``
3710
+ (a class is rebuilt by ``run_source``, not by a header).
3797
3711
 
3798
3712
  Returns
3799
3713
  -------
@@ -3876,6 +3790,8 @@ def to_python(
3876
3790
  # for either option.
3877
3791
  if snapshot_positions or keep_reroutes:
3878
3792
  ctor_args.append("arrange=None")
3793
+ if in_place:
3794
+ ctor_args.append("clear=True")
3879
3795
  lines.append(f"with {constructor}({', '.join(ctor_args)}) as tree:")
3880
3796
  lines.extend(_assemble_tree_body(emission))
3881
3797