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/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)