nodebpy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nodebpy/__init__.py +12 -0
- nodebpy/arrange.py +362 -0
- nodebpy/builder.py +931 -0
- nodebpy/nodes/__init__.py +12 -0
- nodebpy/nodes/attribute.py +580 -0
- nodebpy/nodes/curve.py +2006 -0
- nodebpy/nodes/geometry.py +7304 -0
- nodebpy/nodes/input.py +762 -0
- nodebpy/nodes/manually_specified.py +1356 -0
- nodebpy/nodes/mesh.py +1408 -0
- nodebpy/nodes/types.py +119 -0
- nodebpy/nodes/utilities.py +2344 -0
- nodebpy/screenshot.py +531 -0
- nodebpy/screenshot_subprocess.py +422 -0
- nodebpy/sockets.py +46 -0
- nodebpy-0.1.0.dist-info/METADATA +160 -0
- nodebpy-0.1.0.dist-info/RECORD +19 -0
- nodebpy-0.1.0.dist-info/WHEEL +4 -0
- nodebpy-0.1.0.dist-info/entry_points.txt +3 -0
nodebpy/screenshot.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Programmatic node tree screenshot capture.
|
|
3
|
+
|
|
4
|
+
This module provides functions to capture screenshots of Blender node trees
|
|
5
|
+
without UI interaction. Screenshots can be returned as PIL Images or numpy arrays
|
|
6
|
+
for use in Jupyter notebooks or other contexts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import bpy
|
|
16
|
+
import numpy as np
|
|
17
|
+
from mathutils import Vector
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from PIL import Image as PILImage
|
|
21
|
+
|
|
22
|
+
# Mermaid diagram generation (no subprocess needed)
|
|
23
|
+
|
|
24
|
+
# Margin for node bounds to ensure sockets and links are included.
|
|
25
|
+
NODE_MARGIN = 30
|
|
26
|
+
# Node height isn't very accurate and needs more margin
|
|
27
|
+
NODE_EXTRA_HEIGHT = 30
|
|
28
|
+
# Margin for regions to hide unwanted UI parts (scrollbars, dividers, sidebar buttons).
|
|
29
|
+
REGION_MARGIN = 20
|
|
30
|
+
# Image output settings
|
|
31
|
+
IMAGE_FILE_FORMAT = "TIFF"
|
|
32
|
+
IMAGE_COLOR_MODE = "RGB"
|
|
33
|
+
IMAGE_COLOR_DEPTH = "8"
|
|
34
|
+
IMAGE_TIFF_CODEC = "DEFLATE"
|
|
35
|
+
IMAGE_EXTENSION = ".tif"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compute_node_bounds(context, margin: float) -> tuple[Vector, Vector]:
|
|
39
|
+
"""
|
|
40
|
+
Compute the extent (in View2D space) of all nodes in a node tree.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
context: Blender context
|
|
44
|
+
margin: Margin to add around nodes
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Tuple of (min, max) vectors of the node bounds
|
|
48
|
+
"""
|
|
49
|
+
ui_scale = context.preferences.system.ui_scale
|
|
50
|
+
space = context.space_data
|
|
51
|
+
node_tree = space.edit_tree
|
|
52
|
+
if not node_tree:
|
|
53
|
+
return Vector((0.0, 0.0)), Vector((0.0, 0.0))
|
|
54
|
+
|
|
55
|
+
bmin = Vector((1.0e8, 1.0e8))
|
|
56
|
+
bmax = Vector((-1.0e8, -1.0e8))
|
|
57
|
+
for node in node_tree.nodes:
|
|
58
|
+
node_view_min = (
|
|
59
|
+
Vector(
|
|
60
|
+
(
|
|
61
|
+
node.location_absolute[0],
|
|
62
|
+
node.location_absolute[1] - node.height - NODE_EXTRA_HEIGHT,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
* ui_scale
|
|
66
|
+
)
|
|
67
|
+
node_view_max = (
|
|
68
|
+
Vector((node.location_absolute[0] + node.width, node.location_absolute[1]))
|
|
69
|
+
* ui_scale
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
bmin = Vector((min(bmin.x, node_view_min.x), min(bmin.y, node_view_min.y)))
|
|
73
|
+
bmax = Vector((max(bmax.x, node_view_max.x), max(bmax.y, node_view_max.y)))
|
|
74
|
+
|
|
75
|
+
return bmin - Vector((margin, margin)), bmax + Vector((margin, margin))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def clean_node_window_region(context):
|
|
80
|
+
"""
|
|
81
|
+
Creates a safe context for executing screenshots
|
|
82
|
+
and ensures the region properties are reset afterwards.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
# Remember image format settings
|
|
86
|
+
img_settings = context.scene.render.image_settings
|
|
87
|
+
file_format = img_settings.file_format
|
|
88
|
+
color_mode = img_settings.color_mode
|
|
89
|
+
color_depth = img_settings.color_depth
|
|
90
|
+
tiff_codec = img_settings.tiff_codec
|
|
91
|
+
|
|
92
|
+
# Set image format for screenshots
|
|
93
|
+
img_settings.file_format = IMAGE_FILE_FORMAT
|
|
94
|
+
img_settings.color_mode = IMAGE_COLOR_MODE
|
|
95
|
+
img_settings.color_depth = IMAGE_COLOR_DEPTH
|
|
96
|
+
img_settings.tiff_codec = IMAGE_TIFF_CODEC
|
|
97
|
+
|
|
98
|
+
space = context.space_data
|
|
99
|
+
show_region_header = space.show_region_header
|
|
100
|
+
show_context_path = space.overlay.show_context_path
|
|
101
|
+
|
|
102
|
+
space.show_region_header = False
|
|
103
|
+
space.overlay.show_context_path = False
|
|
104
|
+
|
|
105
|
+
yield context
|
|
106
|
+
|
|
107
|
+
finally:
|
|
108
|
+
img_settings.file_format = file_format
|
|
109
|
+
img_settings.color_mode = color_mode
|
|
110
|
+
img_settings.color_depth = color_depth
|
|
111
|
+
img_settings.tiff_codec = tiff_codec
|
|
112
|
+
|
|
113
|
+
space.show_region_header = show_region_header
|
|
114
|
+
space.overlay.show_context_path = show_context_path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TileInfo:
|
|
118
|
+
"""Information about tiling strategy for large node trees."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, context, region):
|
|
121
|
+
v2d = region.view2d
|
|
122
|
+
|
|
123
|
+
self.nodes_min, self.nodes_max = compute_node_bounds(context, NODE_MARGIN)
|
|
124
|
+
|
|
125
|
+
# Min/Max points of the region considered usable for screenshots.
|
|
126
|
+
# The margin excludes some bits that can't be hidden (dividers, scrollbars, sidebar buttons).
|
|
127
|
+
usable_region_min = Vector((REGION_MARGIN, REGION_MARGIN))
|
|
128
|
+
usable_region_max = Vector(
|
|
129
|
+
(region.width - REGION_MARGIN, region.height - REGION_MARGIN)
|
|
130
|
+
)
|
|
131
|
+
self.tile_margin = REGION_MARGIN
|
|
132
|
+
self.tile_size = (
|
|
133
|
+
int(usable_region_max.x - usable_region_min.x),
|
|
134
|
+
int(usable_region_max.y - usable_region_min.y),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
self.orig_view_min = Vector(
|
|
138
|
+
v2d.region_to_view(usable_region_min.x, usable_region_min.y)
|
|
139
|
+
)
|
|
140
|
+
self.orig_view_max = Vector(
|
|
141
|
+
v2d.region_to_view(usable_region_max.x, usable_region_max.y)
|
|
142
|
+
)
|
|
143
|
+
self.image_num = (
|
|
144
|
+
int(self.nodes_size.x / self.view_size.x) + 1,
|
|
145
|
+
int(self.nodes_size.y / self.view_size.y) + 1,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def view_size(self) -> Vector:
|
|
150
|
+
return self.orig_view_max - self.orig_view_min
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def nodes_size(self) -> Vector:
|
|
154
|
+
return self.nodes_max - self.nodes_min
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def full_size(self) -> tuple[int, int]:
|
|
158
|
+
return (int(self.nodes_size[0]), int(self.nodes_size[1]))
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def tile_num(self) -> int:
|
|
162
|
+
return self.image_num[0] * self.image_num[1]
|
|
163
|
+
|
|
164
|
+
def tile_boxes(
|
|
165
|
+
self, tile_index: tuple[int, int]
|
|
166
|
+
) -> tuple[tuple[int, int, int, int], tuple[int, int, int, int]]:
|
|
167
|
+
"""Calculate input and output boxes for a tile."""
|
|
168
|
+
in_start = (self.tile_margin, self.tile_margin)
|
|
169
|
+
out_start = (
|
|
170
|
+
tile_index[0] * self.tile_size[0],
|
|
171
|
+
tile_index[1] * self.tile_size[1],
|
|
172
|
+
)
|
|
173
|
+
tile_size_clamped = (
|
|
174
|
+
min(out_start[0] + self.tile_size[0], self.full_size[0]) - out_start[0],
|
|
175
|
+
min(out_start[1] + self.tile_size[1], self.full_size[1]) - out_start[1],
|
|
176
|
+
)
|
|
177
|
+
in_end = (
|
|
178
|
+
in_start[0] + tile_size_clamped[0],
|
|
179
|
+
in_start[1] + tile_size_clamped[1],
|
|
180
|
+
)
|
|
181
|
+
out_end = (
|
|
182
|
+
out_start[0] + tile_size_clamped[0],
|
|
183
|
+
out_start[1] + tile_size_clamped[1],
|
|
184
|
+
)
|
|
185
|
+
return (*in_start, *in_end), (*out_start, *out_end)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_node_editor_window_region(context):
|
|
189
|
+
"""Find the window region in a node editor area."""
|
|
190
|
+
for region in context.area.regions:
|
|
191
|
+
if region.type == "WINDOW":
|
|
192
|
+
return region
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def capture_tiles(
|
|
197
|
+
context, region, tile_info: TileInfo, area=None, window=None, screen=None
|
|
198
|
+
) -> dict[tuple[int, int], str]:
|
|
199
|
+
"""
|
|
200
|
+
Capture individual screenshot tiles of the node tree.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
context: Blender context
|
|
204
|
+
region: Node editor window region
|
|
205
|
+
tile_info: Tiling information
|
|
206
|
+
area: Node editor area (optional, extracted from context if None)
|
|
207
|
+
window: Window context (optional, extracted from context if None)
|
|
208
|
+
screen: Screen context (optional, extracted from context if None)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Dictionary mapping tile indices to temporary file paths
|
|
212
|
+
"""
|
|
213
|
+
context_override = context.copy()
|
|
214
|
+
context_override["region"] = region
|
|
215
|
+
if area is not None:
|
|
216
|
+
context_override["area"] = area
|
|
217
|
+
if window is not None:
|
|
218
|
+
context_override["window"] = window
|
|
219
|
+
if screen is not None:
|
|
220
|
+
context_override["screen"] = screen
|
|
221
|
+
render_settings = context.scene.render
|
|
222
|
+
|
|
223
|
+
# View2D only supports relative panning, this provides a "goto" function.
|
|
224
|
+
current_view_min = tile_info.orig_view_min
|
|
225
|
+
|
|
226
|
+
def pan_to_view(view_min):
|
|
227
|
+
nonlocal current_view_min
|
|
228
|
+
delta = view_min - current_view_min
|
|
229
|
+
with context.temp_override(**context_override):
|
|
230
|
+
bpy.ops.view2d.pan(deltax=int(delta.x), deltay=int(delta.y))
|
|
231
|
+
current_view_min = view_min
|
|
232
|
+
|
|
233
|
+
image_files = {}
|
|
234
|
+
for i in range(tile_info.image_num[0]):
|
|
235
|
+
for j in range(tile_info.image_num[1]):
|
|
236
|
+
pan_to_view(tile_info.nodes_min + Vector((i, j)) * tile_info.view_size)
|
|
237
|
+
|
|
238
|
+
tmp_filepath = os.path.join(
|
|
239
|
+
bpy.app.tempdir,
|
|
240
|
+
f"node_tree_screenshot_tile_{i}_{j}{render_settings.file_extension}",
|
|
241
|
+
)
|
|
242
|
+
with context.temp_override(**context_override):
|
|
243
|
+
bpy.ops.screen.screenshot_area(filepath=tmp_filepath)
|
|
244
|
+
image_files[(i, j)] = tmp_filepath
|
|
245
|
+
|
|
246
|
+
# Reset view.
|
|
247
|
+
pan_to_view(tile_info.orig_view_min)
|
|
248
|
+
|
|
249
|
+
return image_files
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def stitch_tiles_numpy(
|
|
253
|
+
context, tile_info: TileInfo, image_files: dict[tuple[int, int], str]
|
|
254
|
+
) -> np.ndarray:
|
|
255
|
+
"""
|
|
256
|
+
Stitch tiles into a single numpy array.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
context: Blender context
|
|
260
|
+
tile_info: Tiling information
|
|
261
|
+
image_files: Dictionary of tile files
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Numpy array with shape (height, width, 4) containing RGBA data
|
|
265
|
+
"""
|
|
266
|
+
if not image_files:
|
|
267
|
+
raise ValueError("No image files to stitch")
|
|
268
|
+
|
|
269
|
+
# NOTE: NumPy pixel arrays are declared with shape (HEIGHT, WIDTH, CHANNELS)
|
|
270
|
+
pixels_out = np.zeros(
|
|
271
|
+
(tile_info.full_size[1], tile_info.full_size[0], 4), dtype=float
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
for tile_index, tile_filepath in image_files.items():
|
|
275
|
+
tile_image = context.blend_data.images.load(tile_filepath)
|
|
276
|
+
assert tile_image.channels == 4, "Tile images should have 4 channels"
|
|
277
|
+
|
|
278
|
+
in_box, out_box = tile_info.tile_boxes(tile_index)
|
|
279
|
+
|
|
280
|
+
pixels_flat = np.fromiter(
|
|
281
|
+
tile_image.pixels,
|
|
282
|
+
dtype=float,
|
|
283
|
+
count=tile_image.size[0] * tile_image.size[1] * 4,
|
|
284
|
+
)
|
|
285
|
+
pixels_in = np.reshape(pixels_flat, (tile_image.size[1], tile_image.size[0], 4))
|
|
286
|
+
pixels_out[out_box[1] : out_box[3], out_box[0] : out_box[2], :] = pixels_in[
|
|
287
|
+
in_box[1] : in_box[3], in_box[0] : in_box[2], :
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
context.blend_data.images.remove(tile_image)
|
|
291
|
+
|
|
292
|
+
return pixels_out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def stitch_tiles_pil(
|
|
296
|
+
context, tile_info: TileInfo, image_files: dict[tuple[int, int], str]
|
|
297
|
+
) -> PILImage.Image:
|
|
298
|
+
"""
|
|
299
|
+
Stitch tiles into a single PIL Image.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
context: Blender context
|
|
303
|
+
tile_info: Tiling information
|
|
304
|
+
image_files: Dictionary of tile files
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
PIL Image object
|
|
308
|
+
"""
|
|
309
|
+
from PIL import Image
|
|
310
|
+
|
|
311
|
+
if not image_files:
|
|
312
|
+
raise ValueError("No image files to stitch")
|
|
313
|
+
|
|
314
|
+
full_image = Image.new("RGB", tile_info.full_size)
|
|
315
|
+
|
|
316
|
+
for tile_index, tile_filepath in image_files.items():
|
|
317
|
+
with Image.open(tile_filepath) as tile_image:
|
|
318
|
+
in_box, out_box = tile_info.tile_boxes(tile_index)
|
|
319
|
+
|
|
320
|
+
# Note: Pillow library uses upper-left corner as (0, 0), subtract Y coordinate from height!
|
|
321
|
+
pil_in_box = (
|
|
322
|
+
in_box[0],
|
|
323
|
+
tile_image.height - in_box[3],
|
|
324
|
+
in_box[2],
|
|
325
|
+
tile_image.height - in_box[1],
|
|
326
|
+
)
|
|
327
|
+
pil_out_box = (
|
|
328
|
+
out_box[0],
|
|
329
|
+
full_image.height - out_box[3],
|
|
330
|
+
out_box[2],
|
|
331
|
+
full_image.height - out_box[1],
|
|
332
|
+
)
|
|
333
|
+
tile_cropped = tile_image.crop(pil_in_box)
|
|
334
|
+
full_image.paste(tile_cropped, pil_out_box)
|
|
335
|
+
|
|
336
|
+
return full_image
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def generate_mermaid_diagram(tree) -> str:
|
|
340
|
+
"""
|
|
341
|
+
Generate a Mermaid diagram from a node tree with color coding based on node types.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
tree: TreeBuilder or GeometryNodeTree to create diagram for
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Mermaid diagram as markdown string with CSS styling
|
|
348
|
+
|
|
349
|
+
Example:
|
|
350
|
+
>>> from nodebpy import TreeBuilder
|
|
351
|
+
>>> from nodebpy.screenshot import generate_mermaid_diagram
|
|
352
|
+
>>> with TreeBuilder("MyTree") as tree:
|
|
353
|
+
... # build your tree
|
|
354
|
+
... pass
|
|
355
|
+
>>> mermaid = generate_mermaid_diagram(tree)
|
|
356
|
+
>>> print(mermaid)
|
|
357
|
+
"""
|
|
358
|
+
# Get the actual node tree object
|
|
359
|
+
if hasattr(tree, "tree"):
|
|
360
|
+
node_tree = tree.tree
|
|
361
|
+
else:
|
|
362
|
+
node_tree = tree
|
|
363
|
+
|
|
364
|
+
mermaid_lines = ["```{mermaid}", "graph LR"]
|
|
365
|
+
|
|
366
|
+
# Define color mappings for different node types
|
|
367
|
+
color_class_map = {
|
|
368
|
+
"GEOMETRY": "geometry-node",
|
|
369
|
+
"CONVERTER": "converter-node",
|
|
370
|
+
"VECTOR": "vector-node",
|
|
371
|
+
"TEXTURE": "texture-node",
|
|
372
|
+
"SHADER": "shader-node",
|
|
373
|
+
"INPUT": "input-node",
|
|
374
|
+
"OUTPUT": "output-node",
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
# Enhanced sorting to better match visual flow in Blender
|
|
378
|
+
# First, try to identify input/output nodes for special handling
|
|
379
|
+
input_nodes = [n for n in node_tree.nodes if "GroupInput" in n.bl_idname]
|
|
380
|
+
output_nodes = [n for n in node_tree.nodes if "GroupOutput" in n.bl_idname]
|
|
381
|
+
regular_nodes = [n for n in node_tree.nodes if n not in input_nodes + output_nodes]
|
|
382
|
+
|
|
383
|
+
# Sort regular nodes primarily by X position (left to right flow), then by Y position
|
|
384
|
+
sorted_regular = sorted(
|
|
385
|
+
regular_nodes, key=lambda n: (n.location[0], -n.location[1])
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Combine: inputs first, then regular nodes, then outputs
|
|
389
|
+
sorted_nodes = input_nodes + sorted_regular + output_nodes
|
|
390
|
+
|
|
391
|
+
# Create node definitions in vertical order
|
|
392
|
+
node_map = {}
|
|
393
|
+
for i, node in enumerate(sorted_nodes):
|
|
394
|
+
node_id = f"N{i}"
|
|
395
|
+
node_map[node.name] = node_id
|
|
396
|
+
|
|
397
|
+
# Clean up the node type name for display - use just the type, not the full name
|
|
398
|
+
node_type = (
|
|
399
|
+
node.bl_idname.replace("GeometryNode", "")
|
|
400
|
+
.replace("ShaderNode", "")
|
|
401
|
+
.replace("FunctionNode", "")
|
|
402
|
+
)
|
|
403
|
+
node_type_clean = node_type.replace('"', "'")
|
|
404
|
+
|
|
405
|
+
# Only show the most critical non-default values
|
|
406
|
+
key_params = []
|
|
407
|
+
|
|
408
|
+
# Get only the most important input parameters that differ from defaults
|
|
409
|
+
for input_socket in node.inputs:
|
|
410
|
+
if input_socket.is_linked:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
socket_name = input_socket.name
|
|
414
|
+
|
|
415
|
+
if hasattr(input_socket, "default_value"):
|
|
416
|
+
try:
|
|
417
|
+
value = input_socket.default_value
|
|
418
|
+
|
|
419
|
+
# Only show very specific important parameters
|
|
420
|
+
if socket_name.lower() in ["seed"]:
|
|
421
|
+
if isinstance(value, (int, float)) and value != 0:
|
|
422
|
+
key_params.append(f"seed:{int(value)}")
|
|
423
|
+
elif socket_name.lower() in ["scale"] and isinstance(
|
|
424
|
+
value, (int, float)
|
|
425
|
+
):
|
|
426
|
+
if value != 1:
|
|
427
|
+
key_params.append(f"×{value:.1g}")
|
|
428
|
+
elif socket_name.lower() in ["offset"] and hasattr(
|
|
429
|
+
value, "__len__"
|
|
430
|
+
):
|
|
431
|
+
if not all(v == 0 for v in value):
|
|
432
|
+
formatted = ",".join(f"{v:.1g}" for v in value)
|
|
433
|
+
key_params.append(f"+({formatted})")
|
|
434
|
+
elif hasattr(value, "__len__") and len(value) == 3:
|
|
435
|
+
# Show non-zero vectors compactly
|
|
436
|
+
if not all(v == 0 for v in value) and not all(
|
|
437
|
+
v == 1 for v in value
|
|
438
|
+
):
|
|
439
|
+
formatted = ",".join(f"{v:.1g}" for v in value)
|
|
440
|
+
key_params.append(f"({formatted})")
|
|
441
|
+
except:
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
# Build minimal node label
|
|
445
|
+
node_label = node_type_clean
|
|
446
|
+
|
|
447
|
+
# Only add parameters if there are any significant ones
|
|
448
|
+
if key_params:
|
|
449
|
+
params_str = " ".join(key_params[:2]) # Max 2 parameters
|
|
450
|
+
node_label += f"<br/><small>{params_str}</small>"
|
|
451
|
+
|
|
452
|
+
# Escape quotes for Mermaid
|
|
453
|
+
node_label = node_label.replace('"', "'")
|
|
454
|
+
|
|
455
|
+
# Apply color class based on node color_tag
|
|
456
|
+
color_tag = getattr(node, "color_tag", "GEOMETRY")
|
|
457
|
+
css_class = color_class_map.get(color_tag, "default-node")
|
|
458
|
+
|
|
459
|
+
mermaid_lines.append(f' {node_id}("{node_label}"):::{css_class}')
|
|
460
|
+
|
|
461
|
+
# Create connections with socket labels
|
|
462
|
+
for link in node_tree.links:
|
|
463
|
+
from_node_id = node_map[link.from_node.name]
|
|
464
|
+
to_node_id = node_map[link.to_node.name]
|
|
465
|
+
|
|
466
|
+
# Get socket names
|
|
467
|
+
from_socket = link.from_socket.name if hasattr(link.from_socket, "name") else ""
|
|
468
|
+
to_socket = link.to_socket.name if hasattr(link.to_socket, "name") else ""
|
|
469
|
+
|
|
470
|
+
# Create socket label with full names
|
|
471
|
+
if from_socket and to_socket:
|
|
472
|
+
# Always show from >> to format with full socket names
|
|
473
|
+
label = f"{from_socket}>>{to_socket}"
|
|
474
|
+
|
|
475
|
+
mermaid_lines.append(f' {from_node_id} -->|"{label}"| {to_node_id}')
|
|
476
|
+
else:
|
|
477
|
+
mermaid_lines.append(f" {from_node_id} --> {to_node_id}")
|
|
478
|
+
|
|
479
|
+
# Add CSS styling for node colors (lighter tints for subtlety)
|
|
480
|
+
# Mermaid doesn't support gradients, so using light tints as a compromise
|
|
481
|
+
mermaid_lines.extend(
|
|
482
|
+
[
|
|
483
|
+
"",
|
|
484
|
+
" classDef geometry-node fill:#e8f5f1,stroke:#3a7c49,stroke-width:2px",
|
|
485
|
+
" classDef converter-node fill:#e6f1f7,stroke:#246283,stroke-width:2px",
|
|
486
|
+
" classDef vector-node fill:#e9e9f5,stroke:#3C3C83,stroke-width:2px",
|
|
487
|
+
" classDef texture-node fill:#fef3e6,stroke:#E66800,stroke-width:2px",
|
|
488
|
+
" classDef shader-node fill:#fef0eb,stroke:#e67c52,stroke-width:2px",
|
|
489
|
+
" classDef input-node fill:#f1f8ed,stroke:#7fb069,stroke-width:2px",
|
|
490
|
+
" classDef output-node fill:#faf0ed,stroke:#c97659,stroke-width:2px",
|
|
491
|
+
" classDef default-node fill:#f0f0f0,stroke:#5a5a5a,stroke-width:2px",
|
|
492
|
+
]
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Close the mermaid block
|
|
496
|
+
mermaid_lines.append("```")
|
|
497
|
+
|
|
498
|
+
# Join into a single diagram
|
|
499
|
+
return "\n".join(mermaid_lines)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def save_mermaid_diagram(filepath: str, tree, format: str = "md") -> None:
|
|
503
|
+
"""
|
|
504
|
+
Save a Mermaid diagram of the node tree to a file.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
filepath: Path to save the diagram
|
|
508
|
+
tree: TreeBuilder or GeometryNodeTree to create diagram for
|
|
509
|
+
format: Output format ('md' for markdown, 'mmd' for raw mermaid)
|
|
510
|
+
|
|
511
|
+
Example:
|
|
512
|
+
>>> from nodebpy.screenshot import save_mermaid_diagram
|
|
513
|
+
>>> save_mermaid_diagram('/tmp/my_node_tree.md', tree=my_tree)
|
|
514
|
+
"""
|
|
515
|
+
mermaid_diagram = generate_mermaid_diagram(tree)
|
|
516
|
+
|
|
517
|
+
with open(filepath, "w") as f:
|
|
518
|
+
if format.lower() == "md":
|
|
519
|
+
# Write as full markdown
|
|
520
|
+
tree_name = tree.tree.name if hasattr(tree, "tree") else "NodeTree"
|
|
521
|
+
node_count = len(tree.tree.nodes) if hasattr(tree, "tree") else 0
|
|
522
|
+
link_count = len(tree.tree.links) if hasattr(tree, "tree") else 0
|
|
523
|
+
|
|
524
|
+
f.write(f"# Node Tree: {tree_name}\n\n")
|
|
525
|
+
f.write(f"**{node_count} nodes, {link_count} connections**\n\n")
|
|
526
|
+
f.write(mermaid_diagram)
|
|
527
|
+
else:
|
|
528
|
+
# Write raw mermaid (remove markdown wrapper)
|
|
529
|
+
lines = mermaid_diagram.split("\n")
|
|
530
|
+
mermaid_content = "\n".join(lines[1:-1]) # Remove ```mermaid and ``` lines
|
|
531
|
+
f.write(mermaid_content)
|