nodebpy 520.5.0__tar.gz → 520.5.1__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 (81) hide show
  1. {nodebpy-520.5.0 → nodebpy-520.5.1}/PKG-INFO +1 -1
  2. {nodebpy-520.5.0 → nodebpy-520.5.1}/pyproject.toml +3 -1
  3. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/tree.py +17 -0
  4. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/export/codegen.py +38 -13
  5. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/assets.py +5 -5
  6. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/assets.py +5 -5
  7. nodebpy-520.5.1/src/nodebpy/web_render.py +113 -0
  8. {nodebpy-520.5.0 → nodebpy-520.5.1}/README.md +0 -0
  9. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/__init__.py +0 -0
  10. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/assets/__init__.py +0 -0
  11. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/assets/__main__.py +0 -0
  12. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/assets/_codegen.py +0 -0
  13. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/__init__.py +0 -0
  14. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/_registry.py +0 -0
  15. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/_utils.py +0 -0
  16. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/accessor.py +0 -0
  17. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/arrange.py +0 -0
  18. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/asset.py +0 -0
  19. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/items.py +0 -0
  20. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/mixins.py +0 -0
  21. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/node.py +0 -0
  22. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/builder/socket.py +0 -0
  23. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/export/__init__.py +0 -0
  24. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/export/diagram.py +0 -0
  25. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  26. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  27. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  28. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  29. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  30. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  31. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  32. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  33. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
  34. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
  35. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
  36. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/config.py +0 -0
  37. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/lib/nodearrange/utils.py +0 -0
  38. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/__init__.py +0 -0
  39. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/_mixins.py +0 -0
  40. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  41. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/color.py +0 -0
  42. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/converter.py +0 -0
  43. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/distort.py +0 -0
  44. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/filter.py +0 -0
  45. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/group.py +0 -0
  46. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/input.py +0 -0
  47. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/interface.py +0 -0
  48. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/manual.py +0 -0
  49. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/matte.py +0 -0
  50. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/output.py +0 -0
  51. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/compositor/vector.py +0 -0
  52. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  53. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/attribute.py +0 -0
  54. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/color.py +0 -0
  55. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/converter.py +0 -0
  56. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/geometry.py +0 -0
  57. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/grid.py +0 -0
  58. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/group.py +0 -0
  59. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/groups.py +0 -0
  60. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/input.py +0 -0
  61. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/interface.py +0 -0
  62. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/manual.py +0 -0
  63. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/output.py +0 -0
  64. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/texture.py +0 -0
  65. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  66. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/vector.py +0 -0
  67. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/geometry/zone.py +0 -0
  68. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/__init__.py +0 -0
  69. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/assets.py +0 -0
  70. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/color.py +0 -0
  71. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/converter.py +0 -0
  72. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/grid.py +0 -0
  73. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/group.py +0 -0
  74. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/input.py +0 -0
  75. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/manual.py +0 -0
  76. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/output.py +0 -0
  77. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/script.py +0 -0
  78. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/shader.py +0 -0
  79. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/texture.py +0 -0
  80. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/nodes/shader/vector.py +0 -0
  81. {nodebpy-520.5.0 → nodebpy-520.5.1}/src/nodebpy/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.5.0
3
+ Version: 520.5.1
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.5.0"
3
+ version = "520.5.1"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -31,6 +31,7 @@ build-backend = "uv_build"
31
31
  [dependency-groups]
32
32
  dev = [
33
33
  "ipython>=8.0.0",
34
+ "ipykernel>=6.0.0",
34
35
  "networkx>=3.6.1",
35
36
  "fake-bpy-module>=20260501",
36
37
  "jsondiff>=2.2.1",
@@ -38,6 +39,7 @@ dev = [
38
39
  "pytest-cov>=7.0.0",
39
40
  "quarto-cli>=1.9",
40
41
  "quartodoc>=0.11.1",
42
+ "tree-clipper>=0.1.6",
41
43
  "ruff>=0.14.11",
42
44
  "syrupy>=5.0.0",
43
45
  "pytest-xdist>=3.8.0",
@@ -846,6 +846,23 @@ class TreeBuilder(Generic[_TreeT]):
846
846
  print(f"Mermaid diagram generation failed: {e}")
847
847
  return None
848
848
 
849
+ def _repr_html_(self) -> str | None:
850
+ """
851
+ Return an interactive geonodes-web-render graph for Jupyter/Quarto.
852
+
853
+ This is called when the TreeBuilder is the return value of a cell. The
854
+ tree is exported to the Tree Clipper format and embedded as a Blender-
855
+ styled, pan/zoomable graph. Returning ``None`` on failure lets the
856
+ Mermaid ``_repr_markdown_`` fallback take over.
857
+ """
858
+ try:
859
+ from ..web_render import to_web_render_html
860
+
861
+ return to_web_render_html(self)
862
+ except Exception as e:
863
+ print(f"Web render generation failed: {e}")
864
+ return None
865
+
849
866
  def _input_node(self) -> Node:
850
867
  """Get or create the Group Input node."""
851
868
  try:
@@ -1057,20 +1057,25 @@ class EmitContext:
1057
1057
  self.used_aliases.add(alias)
1058
1058
 
1059
1059
  by_socket: dict[str, list[tuple[int, Expr]]] = {}
1060
+ multi_inputs: set[str] = set()
1060
1061
  for link in self.incoming.get(node.name, ()):
1061
1062
  if skip_input_id and link.to_socket.identifier == skip_input_id:
1062
1063
  continue
1063
- by_socket.setdefault(_normalize(link.to_socket.identifier), []).append(
1064
+ name = _normalize(link.to_socket.identifier)
1065
+ if getattr(link.to_socket, "is_multi_input", False):
1066
+ multi_inputs.add(name)
1067
+ by_socket.setdefault(name, []).append(
1064
1068
  (link.sort_id, self.upstream_expr(link))
1065
1069
  )
1066
1070
  socket_kwargs: dict[str, Expr] = {}
1067
1071
  for name, entries in by_socket.items():
1068
- if len(entries) == 1:
1072
+ if len(entries) == 1 and name not in multi_inputs:
1069
1073
  socket_kwargs[name] = entries[0][1]
1070
1074
  else:
1071
- # Multiple links on one multi-input socket become a tuple in
1072
- # creation order (descending sort_id) so the rebuilt tree
1073
- # gets the same multi-input ordering.
1075
+ # Links on a multi-input socket become a tuple in creation
1076
+ # order (descending sort_id) so the rebuilt tree gets the same
1077
+ # multi-input ordering — even a single link, since manual
1078
+ # classes (JoinGeometry, MeshBoolean, …) expect an iterable.
1074
1079
  entries.sort(key=lambda e: e[0], reverse=True)
1075
1080
  socket_kwargs[name] = TupleExpr([e[1] for e in entries])
1076
1081
 
@@ -1817,7 +1822,9 @@ def _matrix_spec(method: str, output: str) -> SocketMethodSpec:
1817
1822
  )
1818
1823
 
1819
1824
 
1820
- def _math_unary_spec(operation: str, method: str, *args: str) -> SocketMethodSpec:
1825
+ def _math_unary_spec(
1826
+ operation: str, method: str, *args: str, always_args: int = 0
1827
+ ) -> SocketMethodSpec:
1821
1828
  """A unary ``ShaderNodeMath`` op rendered as a float socket method —
1822
1829
  ``value.sqrt()`` / ``value.sign()``. ``receiver_socket_type="VALUE"`` keeps
1823
1830
  the round-trip faithful: the method only re-derives a float Math node when
@@ -1826,14 +1833,17 @@ def _math_unary_spec(operation: str, method: str, *args: str) -> SocketMethodSpe
1826
1833
  receiver="Value",
1827
1834
  method=method,
1828
1835
  output="Value",
1829
- params=tuple((f"Value_{int(i + 1)}", arg) for i, arg in enumerate(args)),
1836
+ params=tuple((f"Value_{i + 1:03d}", arg) for i, arg in enumerate(args)),
1830
1837
  require=(("operation", operation),),
1831
1838
  consumed_props=("operation",),
1832
1839
  receiver_socket_type="VALUE",
1840
+ always_args=always_args,
1833
1841
  )
1834
1842
 
1835
1843
 
1836
- def _int_math_unary_spec(operation: str, method: str, *args: str) -> SocketMethodSpec:
1844
+ def _int_math_unary_spec(
1845
+ operation: str, method: str, *args: str, always_args: int = 0
1846
+ ) -> SocketMethodSpec:
1837
1847
  """A unary ``FunctionNodeIntegerMath`` op rendered as an integer socket
1838
1848
  method — ``value.sign()``. ``ABSOLUTE``/``NEGATE`` stay as the ``abs(x)`` /
1839
1849
  ``-x`` lifts and so are deliberately absent here."""
@@ -1841,10 +1851,11 @@ def _int_math_unary_spec(operation: str, method: str, *args: str) -> SocketMetho
1841
1851
  receiver="Value",
1842
1852
  method=method,
1843
1853
  output="Value",
1844
- params=tuple((f"Value_{int(i + 1)}", arg) for i, arg in enumerate(args)),
1854
+ params=tuple((f"Value_{i + 1:03d}", arg) for i, arg in enumerate(args)),
1845
1855
  require=(("operation", operation),),
1846
1856
  consumed_props=("operation",),
1847
1857
  receiver_socket_type="INT",
1858
+ always_args=always_args,
1848
1859
  )
1849
1860
 
1850
1861
 
@@ -2033,12 +2044,26 @@ _SOCKET_METHODS: dict[str, list[SocketMethodSpec]] = {
2033
2044
  ("MINIMUM", "min", "value_001"),
2034
2045
  ("MAXIMUM", "max", "value_001"),
2035
2046
  ("MULTIPLY_ADD", "mul_add", "multiplier", "addend"),
2036
- ("WRAP", "wrap", "min", "max"),
2037
- ("MODULO", "modulo", "divisor"),
2038
2047
  ("PINGPONG", "ping_pong", "value"),
2039
2048
  ("LOGARITHM", "log", "base"),
2040
2049
  ("ARCTAN2", "atan2", "value"),
2041
2050
  ]
2051
+ ]
2052
+ # No float modulo lift: the float mixin's modulo() builds FLOORED_MODULO,
2053
+ # which the % operator lift already covers, and plain MODULO has no
2054
+ # faithful method equivalent so it stays a factory call.
2055
+ + [
2056
+ # wrap()'s node sockets are swapped relative to the signature: the
2057
+ # node's Value_001 is Max, Value_002 is Min.
2058
+ SocketMethodSpec(
2059
+ receiver="Value",
2060
+ method="wrap",
2061
+ output="Value",
2062
+ params=(("Value_002", "min"), ("Value_001", "max")),
2063
+ require=(("operation", "WRAP"),),
2064
+ consumed_props=("operation",),
2065
+ receiver_socket_type="VALUE",
2066
+ ),
2042
2067
  ],
2043
2068
  "FunctionNodeIntegerMath": [
2044
2069
  _int_math_unary_spec(*args)
@@ -2046,9 +2071,9 @@ _SOCKET_METHODS: dict[str, list[SocketMethodSpec]] = {
2046
2071
  ("SIGN", "sign"),
2047
2072
  # ("ABSOLUTE", "abs"),
2048
2073
  ("MULTIPLY_ADD", "mul_add", "multiplier", "addend"),
2049
- ("MODULO", "modulo", "divisor"),
2050
2074
  )
2051
- ],
2075
+ ]
2076
+ + [_int_math_unary_spec("MODULO", "modulo", "divisor", always_args=1)],
2052
2077
  "GeometryNodeListLength": [
2053
2078
  _list_spec("list_length", "Length", type_prop="data_type"),
2054
2079
  ],
@@ -144,8 +144,8 @@ class FilmGrain(AssetCompositorGroup):
144
144
  """Factor"""
145
145
  preset: MenuSocket
146
146
  """Preset"""
147
- animated_: BooleanSocket
148
- """Animated """
147
+ animated: BooleanSocket
148
+ """Animated"""
149
149
 
150
150
  class _Outputs(SocketAccessor):
151
151
  result: ColorSocket
@@ -162,15 +162,15 @@ class FilmGrain(AssetCompositorGroup):
162
162
  self,
163
163
  input: InputColor = None,
164
164
  factor: InputFloat = 1.0,
165
- preset: InputMenu = "Super 8mm",
166
- animated_: InputBoolean = False,
165
+ preset: InputMenu = "Super 8 mm",
166
+ animated: InputBoolean = False,
167
167
  ):
168
168
  super().__init__(
169
169
  **{
170
170
  "Socket_1": input,
171
171
  "Socket_7": factor,
172
172
  "Socket_64": preset,
173
- "Socket_10": animated_,
173
+ "Socket_10": animated,
174
174
  }
175
175
  )
176
176
 
@@ -200,7 +200,7 @@ class AttachmentInfo(AssetGeometryGroup):
200
200
  attachment_uv: VectorSocket
201
201
  """Attachment UV"""
202
202
  attachment_is_valid: BooleanSocket
203
- """Attachment is Valid"""
203
+ """Attachment Is Valid"""
204
204
  surface_normal: VectorSocket
205
205
  """Surface Normal"""
206
206
 
@@ -2137,8 +2137,8 @@ class ProjectWithDepth(AssetGeometryGroup):
2137
2137
  """Projection"""
2138
2138
  transform: MatrixSocket
2139
2139
  """Transform"""
2140
- cip_start: FloatSocket
2141
- """Cip Start"""
2140
+ clip_start: FloatSocket
2141
+ """Clip Start"""
2142
2142
  clip_end: FloatSocket
2143
2143
  """Clip End"""
2144
2144
 
@@ -2159,7 +2159,7 @@ class ProjectWithDepth(AssetGeometryGroup):
2159
2159
  depth: InputFloat = 1.0,
2160
2160
  projection: InputMatrix = None,
2161
2161
  transform: InputMatrix = None,
2162
- cip_start: InputFloat = 0.1,
2162
+ clip_start: InputFloat = 0.1,
2163
2163
  clip_end: InputFloat = 100.0,
2164
2164
  ):
2165
2165
  super().__init__(
@@ -2168,7 +2168,7 @@ class ProjectWithDepth(AssetGeometryGroup):
2168
2168
  "Socket_2": depth,
2169
2169
  "Socket_5": projection,
2170
2170
  "Socket_6": transform,
2171
- "Socket_0": cip_start,
2171
+ "Socket_0": clip_start,
2172
2172
  "Socket_1": clip_end,
2173
2173
  }
2174
2174
  )
@@ -0,0 +1,113 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Render node trees as interactive graphs via geonodes-web-render.
3
+
4
+ The live Blender node tree built by a :class:`~nodebpy.builder.tree.TreeBuilder`
5
+ is exported to the Tree Clipper JSON format (using the ``tree_clipper`` package)
6
+ and embedded into a small HTML snippet that mounts the ``geonodes-web-render``
7
+ web component in the browser. This is what powers the rich ``_repr_html_`` shown
8
+ for a tree in Jupyter and Quarto.
9
+
10
+ Nothing here needs network access at build time: the Tree Clipper payload is
11
+ inlined into the HTML, and only the (cached) JS/CSS are fetched from the CDN
12
+ when the page is viewed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import uuid
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from .builder.tree import TreeBuilder
23
+
24
+ #: geonodes-web-render version served from the CDN. A semver range (`"0.3"`)
25
+ #: lets esm.sh serve the latest matching patch, so web-render bug fixes ship to
26
+ #: the docs without re-pinning here. Use an exact version (e.g. "0.3.2") to lock.
27
+ DEFAULT_VERSION = "0.3"
28
+
29
+ #: Default ESM CDN. esm.sh transparently resolves the external react/react-dom.
30
+ DEFAULT_CDN = "https://esm.sh"
31
+
32
+
33
+ def to_tree_clipper_payload(builder: "TreeBuilder", *, compress: bool = True) -> str:
34
+ """Export *builder*'s node tree to a Tree Clipper payload.
35
+
36
+ With ``compress=True`` (the default) the result is the ``TreeClipper::`` +
37
+ base64(gzip(json)) string consumed directly by geonodes-web-render. With
38
+ ``compress=False`` it is indented JSON, handy for inspection.
39
+
40
+ Requires the optional ``tree_clipper`` package to be importable.
41
+ """
42
+ from tree_clipper.export_nodes import ExportIntermediate, ExportParameters
43
+ from tree_clipper.specific_handlers import BUILT_IN_EXPORTER
44
+
45
+ # A MaterialBuilder owns a bpy.types.Material; a plain TreeBuilder owns a
46
+ # node group. Tree Clipper looks the root up by name in the matching
47
+ # collection, so use the live name (Blender may have de-duplicated it).
48
+ material = getattr(builder, "material", None)
49
+ is_material = material is not None
50
+ name = material.name if is_material else builder.tree.name
51
+
52
+ export = ExportIntermediate(
53
+ parameters=ExportParameters(
54
+ is_material=is_material,
55
+ name=name,
56
+ # BUILT_IN_EXPORTER is a dict literal upstream, so ty infers its key
57
+ # type as NoneType (invariant dict) rather than the runtime `type`.
58
+ specific_handlers=BUILT_IN_EXPORTER, # ty: ignore[invalid-argument-type]
59
+ export_sub_trees=True,
60
+ debug_prints=False,
61
+ write_from_roots=False,
62
+ )
63
+ )
64
+ while export.step():
65
+ pass
66
+ export.set_external(
67
+ # get_pointee() is typed as bare bpy_struct upstream; external items are
68
+ # always ID datablocks, which do carry a `.name`.
69
+ (external_id, item.pointed_to_by.get_pointee().name) # ty: ignore[unresolved-attribute]
70
+ for external_id, item in export.get_external().items()
71
+ )
72
+ return export.export_to_str(compress=compress, json_indent=4)
73
+
74
+
75
+ def to_web_render_html(
76
+ builder: "TreeBuilder",
77
+ *,
78
+ height: str = "480px",
79
+ version: str = DEFAULT_VERSION,
80
+ cdn: str = DEFAULT_CDN,
81
+ ) -> str:
82
+ """Return a self-contained HTML snippet rendering *builder* as a graph.
83
+
84
+ The snippet mounts one geonodes-web-render instance into its own container,
85
+ so multiple trees can be shown on the same page. The stylesheet is added to
86
+ ``<head>`` only once across all snippets.
87
+ """
88
+ payload = to_tree_clipper_payload(builder, compress=True)
89
+ container_id = f"gnwr-{uuid.uuid4().hex}"
90
+ module_url = f"{cdn}/geonodes-web-render@{version}/embed"
91
+ css_url = f"{cdn}/geonodes-web-render@{version}/dist/embed.css"
92
+ # json.dumps keeps the payload a valid, safely-quoted JS string literal.
93
+ payload_js = json.dumps(payload)
94
+ css_id = "gnwr-stylesheet"
95
+
96
+ return (
97
+ f'<div id="{container_id}" '
98
+ f'style="height: {height}; width: 100%; border-radius: 8px; overflow: hidden;">'
99
+ f"</div>\n"
100
+ f'<script type="module">\n'
101
+ f'if (!document.getElementById("{css_id}")) {{\n'
102
+ f' const link = document.createElement("link");\n'
103
+ f' link.id = "{css_id}";\n'
104
+ f' link.rel = "stylesheet";\n'
105
+ f' link.href = "{css_url}";\n'
106
+ f" document.head.appendChild(link);\n"
107
+ f"}}\n"
108
+ f'import {{ mountGraphView }} from "{module_url}";\n'
109
+ f'mountGraphView(document.getElementById("{container_id}"), '
110
+ f"{{ payload: {payload_js}, showCopyButton: false, "
111
+ f"allowSelection: false }});\n"
112
+ f"</script>"
113
+ )
File without changes