nodebpy 0.2.1__tar.gz → 0.3.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 (25) hide show
  1. {nodebpy-0.2.1 → nodebpy-0.3.0}/PKG-INFO +4 -4
  2. {nodebpy-0.2.1 → nodebpy-0.3.0}/README.md +3 -3
  3. {nodebpy-0.2.1 → nodebpy-0.3.0}/pyproject.toml +1 -1
  4. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/builder.py +175 -399
  5. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/manual.py +40 -11
  6. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/zone.py +81 -112
  7. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/types.py +1 -1
  8. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/__init__.py +0 -0
  9. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/arrange.py +0 -0
  10. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/__init__.py +0 -0
  11. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/attribute.py +0 -0
  12. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/color.py +0 -0
  13. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/converter.py +0 -0
  14. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/experimental.py +0 -0
  15. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/geometry.py +0 -0
  16. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/grid.py +0 -0
  17. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/group.py +0 -0
  18. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/input.py +0 -0
  19. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/interface.py +0 -0
  20. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/output.py +0 -0
  21. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/texture.py +0 -0
  22. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/nodes/vector.py +0 -0
  23. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/screenshot.py +0 -0
  24. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/screenshot_subprocess.py +0 -0
  25. {nodebpy-0.2.1 → nodebpy-0.3.0}/src/nodebpy/sockets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Build nodes in Blender with code
5
5
  Author: Brady Johnston
6
6
  Author-email: Brady Johnston <brady.johnston@me.com>
@@ -94,8 +94,8 @@ tree
94
94
  graph LR
95
95
  N0("NodeGroupInput"):::default-node
96
96
  N1("RandomValue<br/><small>(-1,-1,-1) seed:2</small>"):::converter-node
97
- N2("RandomValue<br/><small>(-1,-1,-1) seed:1</small>"):::converter-node
98
- N3("AlignRotationToVector<br/><small>(0,0,1)</small>"):::converter-node
97
+ N2("RandomValue<br/><small>(-1,-1,-1)</small>"):::converter-node
98
+ N3("AlignRotationToVector"):::converter-node
99
99
  N4("AxisAngleToRotation<br/><small>(0,0,1)</small>"):::converter-node
100
100
  N5("InputPosition"):::input-node
101
101
  N6("Points"):::geometry-node
@@ -109,7 +109,7 @@ graph LR
109
109
  N14("RealizeInstances"):::geometry-node
110
110
  N15("InstanceOnPoints"):::geometry-node
111
111
  N16("NodeGroupOutput"):::default-node
112
- N1 -->|"Value>>Rotation"| N3
112
+ N1 -->|"Value>>Vector"| N3
113
113
  N4 -->|"Rotation>>Rotate By"| N8
114
114
  N3 -->|"Rotation>>Rotation"| N8
115
115
  N2 -->|"Value>>Position"| N6
@@ -70,8 +70,8 @@ tree
70
70
  graph LR
71
71
  N0("NodeGroupInput"):::default-node
72
72
  N1("RandomValue<br/><small>(-1,-1,-1) seed:2</small>"):::converter-node
73
- N2("RandomValue<br/><small>(-1,-1,-1) seed:1</small>"):::converter-node
74
- N3("AlignRotationToVector<br/><small>(0,0,1)</small>"):::converter-node
73
+ N2("RandomValue<br/><small>(-1,-1,-1)</small>"):::converter-node
74
+ N3("AlignRotationToVector"):::converter-node
75
75
  N4("AxisAngleToRotation<br/><small>(0,0,1)</small>"):::converter-node
76
76
  N5("InputPosition"):::input-node
77
77
  N6("Points"):::geometry-node
@@ -85,7 +85,7 @@ graph LR
85
85
  N14("RealizeInstances"):::geometry-node
86
86
  N15("InstanceOnPoints"):::geometry-node
87
87
  N16("NodeGroupOutput"):::default-node
88
- N1 -->|"Value>>Rotation"| N3
88
+ N1 -->|"Value>>Vector"| N3
89
89
  N4 -->|"Rotation>>Rotate By"| N8
90
90
  N3 -->|"Rotation>>Rotation"| N8
91
91
  N2 -->|"Value>>Position"| N6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodebpy"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Build nodes in Blender with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,7 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from ast import Return
3
4
  from typing import TYPE_CHECKING, Any, ClassVar, Literal
4
5
 
6
+ from arrangebpy.arrange.routing import Socket
7
+ from numpy import isin
8
+
5
9
  if TYPE_CHECKING:
6
10
  from .nodes import Math, VectorMath
7
11
 
@@ -21,6 +25,7 @@ from .types import (
21
25
  TYPE_INPUT_ALL,
22
26
  FloatInterfaceSubtypes,
23
27
  IntegerInterfaceSubtypes,
28
+ NodeBooleanMathItems,
24
29
  StringInterfaceSubtypes,
25
30
  VectorInterfaceSubtypes,
26
31
  _AttributeDomains,
@@ -52,6 +57,10 @@ def denormalize_name(attr_name: str) -> str:
52
57
  return attr_name.replace("_", " ").title()
53
58
 
54
59
 
60
+ class SocketError(Exception):
61
+ """Raised when a socket operation fails."""
62
+
63
+
55
64
  class SocketContext:
56
65
  _direction: Literal["INPUT", "OUTPUT"] | None
57
66
  _active_context: SocketContext | None = None
@@ -190,6 +199,14 @@ class TreeBuilder:
190
199
  if isinstance(socket2, SocketLinker):
191
200
  socket2 = socket2.socket
192
201
 
202
+ if (
203
+ socket1.type not in SOCKET_COMPATIBILITY.get(socket2.type, ())
204
+ and socket2.type != "CUSTOM"
205
+ ):
206
+ raise SocketError(
207
+ f"Incompatible socket types, {socket1.type} and {socket2.type}"
208
+ )
209
+
193
210
  link = self.tree.links.new(socket1, socket2, handle_dynamic_sockets=True)
194
211
 
195
212
  if any(socket.is_inactive for socket in [socket1, socket2]):
@@ -224,7 +241,6 @@ class NodeBuilder:
224
241
  _from_socket: NodeSocket | None = None
225
242
  _default_input_id: str | None = None
226
243
  _default_output_id: str | None = None
227
- _socket_data_types = tuple(SOCKET_COMPATIBILITY.keys())
228
244
 
229
245
  def __init__(self):
230
246
  # Get active tree from context manager
@@ -276,7 +292,6 @@ class NodeBuilder:
276
292
  counter = 0
277
293
  socket = self.node.outputs[counter]
278
294
  while not socket.is_icon_visible:
279
- print(f"skipping inactive socket {socket.name}")
280
295
  counter += 1
281
296
  socket = self.node.outputs[counter]
282
297
  return socket
@@ -303,286 +318,67 @@ class NodeBuilder:
303
318
  else:
304
319
  raise TypeError(f"Unsupported type: {type(node)}")
305
320
 
306
- def _find_compatible_output_socket(self, linkable: "NodeBuilder") -> NodeSocket:
307
- """Find a compatible output socket from the linkable node that matches our accepted socket types."""
308
- # First try the default output socket
309
- default_output = linkable._default_output_socket
310
- for comp in SOCKET_COMPATIBILITY[default_output.type]:
311
- if comp in self._socket_data_types:
312
- return default_output
313
-
314
- # If default doesn't work, try all other output sockets
315
- for output_socket in linkable.node.outputs:
316
- for comp in SOCKET_COMPATIBILITY[output_socket.type]:
317
- if comp in self._socket_data_types:
318
- return output_socket
319
-
320
- # No compatible socket found
321
- raise ValueError(
322
- f"No compatible output socket found on {linkable.node.name} for {self.__class__.__name__}. "
323
- f"Available output types: {[s.type for s in linkable.node.outputs]}, "
324
- f"Accepted input types: {self._socket_data_types}"
325
- )
326
-
327
- def _find_compatible_source_socket(
328
- self, source_node: "NodeBuilder", target_socket: NodeSocket
329
- ) -> NodeSocket:
330
- """Find a compatible output socket from source node that can link to the target input socket."""
331
- target_type = target_socket.type
332
- compatible_types = SOCKET_COMPATIBILITY.get(target_type, ())
333
-
334
- # Collect all compatible sockets with their compatibility priority
335
- compatible_sockets = []
336
- for output_socket in source_node.node.outputs:
337
- if output_socket.type in compatible_types:
338
- # Priority is the index in the compatibility list (0 = highest priority)
339
- priority = compatible_types.index(output_socket.type)
340
- compatible_sockets.append((priority, output_socket))
341
-
342
- if not compatible_sockets:
343
- # No compatible socket found
344
- raise ValueError(
345
- f"No compatible output socket found on {source_node.node.name} for target socket {target_socket.name} of type {target_type}. "
346
- f"Available output types: {[s.type for s in source_node.node.outputs]}, "
347
- f"Compatible types: {compatible_types}"
348
- )
349
-
350
- # Sort by priority (lowest number = highest priority) and return the best match
351
- compatible_sockets.sort(key=lambda x: x[0])
352
- return compatible_sockets[0][1]
321
+ @property
322
+ def _available_outputs(self) -> list[NodeSocket]:
323
+ return [socket for socket in self.node.outputs if socket.is_icon_visible]
353
324
 
354
- def _find_best_socket_pair(
355
- self, target_node: "NodeBuilder"
356
- ) -> tuple[NodeSocket, NodeSocket]:
357
- """Find the best compatible socket pair between this node (source) and target node."""
358
- # First try to connect default output to default input
359
- default_output = self._default_output_socket
360
- default_input = target_node._default_input_socket
361
-
362
- # Handle zone outputs that don't have inputs yet
363
- if default_input is None and hasattr(target_node, "_add_socket"):
364
- # Target is a zone without inputs - create compatible socket
365
- source_type = default_output.type
366
- compatible_types = SOCKET_COMPATIBILITY.get(source_type, [source_type])
367
- best_type = compatible_types[0] if compatible_types else source_type
368
-
369
- # Create socket on target zone
370
- default_input = target_node._add_socket(
371
- name=best_type.title(), type=best_type
372
- )
373
- return default_output, default_input
374
-
375
- # Check if default sockets are compatible
376
- if default_input is not None:
377
- output_compatibles = SOCKET_COMPATIBILITY.get(default_output.type, ())
378
- if default_input.type in output_compatibles and (
379
- not default_input.links or default_input.is_multi_input
380
- ):
381
- return default_output, default_input
382
-
383
- # If defaults don't work, try all combinations with priority-based matching
384
- best_match = None
385
- best_priority = float("inf")
386
-
387
- for output_socket in self.node.outputs:
388
- output_compatibles = SOCKET_COMPATIBILITY.get(output_socket.type, ())
389
- for input_socket in target_node.node.inputs:
390
- # Skip if socket already has a link and isn't multi-input
391
- if input_socket.links and not input_socket.is_multi_input:
392
- continue
393
-
394
- if input_socket.type in output_compatibles:
395
- # Calculate priority as the index in the compatibility list
396
- priority = output_compatibles.index(input_socket.type)
397
- if priority < best_priority:
398
- best_priority = priority
399
- best_match = (output_socket, input_socket)
400
-
401
- if best_match:
402
- return best_match
403
-
404
- # No compatible pair found
405
- available_outputs = [s.type for s in self.node.outputs]
406
- available_inputs = [
407
- s.type for s in target_node.node.inputs if not s.links or s.is_multi_input
325
+ @property
326
+ def _available_inputs(self) -> list[NodeSocket]:
327
+ return [
328
+ socket
329
+ for socket in self.node.inputs
330
+ # only sockets that are available, don't have a link already (unless multi-input)
331
+ if not socket.is_inactive
332
+ and socket.is_icon_visible
333
+ and (not socket.links or socket.is_multi_input)
408
334
  ]
409
- raise RuntimeError(
410
- f"Cannot link any output from {self.node.name} to any input of {target_node.node.name}. "
411
- f"Available output types: {available_outputs}, "
412
- f"Available input types: {available_inputs}"
413
- )
414
-
415
- def _socket_type_from_linkable(self, linkable: LINKABLE):
416
- assert linkable, "Linkable cannot be None"
417
- # If it's a NodeBuilder, try to find a compatible output socket
418
- if hasattr(linkable, "node") and hasattr(linkable, "_default_output_socket"):
419
- compatible_socket = self._find_compatible_output_socket(linkable)
420
- socket_type = compatible_socket.type
421
- for comp in SOCKET_COMPATIBILITY[socket_type]:
422
- if comp in self._socket_data_types:
423
- return comp if comp != "VALUE" else "FLOAT"
424
-
425
- # Fallback to original logic for other types
426
- for comp in SOCKET_COMPATIBILITY[linkable.type]:
427
- if comp in self._socket_data_types:
428
- return comp if comp != "VALUE" else "FLOAT"
429
- raise ValueError(
430
- f"Unsupported socket type for linking: {linkable}, type: {linkable.type=}"
431
- )
432
-
433
- def _add_inputs(self, *args, **kwargs) -> dict[str, LINKABLE]:
434
- """Dictionary with {new_socket.name: from_linkable} for link creation"""
435
- new_sockets = {}
436
- items = {}
437
- for arg in args:
438
- if isinstance(arg, bpy.types.NodeSocket):
439
- name = arg.name
440
- items[name] = arg
441
- else:
442
- items[arg._default_output_socket.name] = arg
443
- items.update(kwargs)
444
- for key, value in items.items():
445
- # For NodeBuilder objects, find the best compatible output socket
446
- if hasattr(value, "node") and hasattr(value, "_default_output_socket"):
447
- compatible_socket = self._find_compatible_output_socket(value)
448
- # Create a SocketLinker to represent the specific socket we want to link from
449
- # from . import SocketLinker
450
- socket_linker = SocketLinker(compatible_socket)
451
- type = self._socket_type_from_linkable(value)
452
- socket = self._add_socket(name=key, type=type)
453
- new_sockets[socket.name] = socket_linker
454
- else:
455
- type = self._socket_type_from_linkable(value)
456
- socket = self._add_socket(name=key, type=type)
457
- new_sockets[socket.name] = value
458
-
459
- return new_sockets
460
335
 
461
- def _add_socket(
462
- self, name: str, type: str, default_value: Any | None = None
463
- ) -> NodeSocket:
464
- raise NotImplementedError
465
-
466
- def _find_or_create_compatible_output_socket(
467
- self, target_type: str
468
- ) -> NodeSocket | None:
469
- """Find an existing compatible output socket or create a new one if this node supports it.
470
-
471
- Args:
472
- target_type: The socket type needed for compatibility
473
-
474
- Returns:
475
- Compatible output socket if found/created, None if not possible
476
- """
477
- if not hasattr(self, "_add_socket"):
478
- return None
336
+ def _best_output_socket(self, type: str) -> NodeSocket:
337
+ compatible = SOCKET_COMPATIBILITY.get(type, ())
338
+ possible = [
339
+ socket for socket in self._available_outputs if socket.type in compatible
340
+ ]
341
+ if possible:
342
+ possible.sort(key=lambda x: compatible.index(x.type))
343
+ return possible[0]
479
344
 
480
- # Check if we already have a compatible output socket
481
- if hasattr(self, "outputs"):
482
- for name, socket_linker in self.outputs.items():
483
- socket_compatibles = SOCKET_COMPATIBILITY.get(
484
- socket_linker.socket.type, []
485
- )
486
- if target_type in socket_compatibles:
487
- return socket_linker.socket
345
+ raise SocketError("No compatible output sockets found")
488
346
 
489
- # No existing compatible socket found, try to create one
490
- try:
491
- # Check if this node type supports the target socket type
492
- # by examining the type signature of _add_socket
493
- import inspect
494
-
495
- sig = inspect.signature(self._add_socket)
496
- type_param = sig.parameters.get("type")
497
-
498
- # If there's a type annotation that limits the allowed types, check it
499
- if type_param and hasattr(type_param.annotation, "__args__"):
500
- # This is a Literal type with specific allowed values
501
- allowed_types = list(type_param.annotation.__args__)
502
- if target_type not in allowed_types:
503
- # Try to find a compatible type that this node can create
504
- for allowed_type in allowed_types:
505
- if target_type in SOCKET_COMPATIBILITY.get(allowed_type, []):
506
- # Create the allowed type instead
507
- self._add_socket(
508
- name=allowed_type.title(), type=allowed_type
509
- )
510
- break
511
- else:
512
- # No compatible type found
513
- return None
514
- else:
515
- # Target type is directly supported
516
- self._add_socket(name=target_type.title(), type=target_type)
517
- else:
518
- # No type restrictions, try to create the target type
519
- self._add_socket(name=target_type.title(), type=target_type)
520
-
521
- # Find the newly created output socket
522
- if hasattr(self, "outputs"):
523
- for name, socket_linker in self.outputs.items():
524
- socket_compatibles = SOCKET_COMPATIBILITY.get(
525
- socket_linker.socket.type, []
526
- )
527
- if target_type in socket_compatibles:
528
- return socket_linker.socket
529
-
530
- # Fallback: try to get the socket directly from the node
531
- if hasattr(self.node, "outputs"):
532
- for output_socket in self.node.outputs:
533
- socket_compatibles = SOCKET_COMPATIBILITY.get(
534
- output_socket.type, []
347
+ @staticmethod
348
+ def _find_best_socket_pair(
349
+ source: "NodeBuilder | NodeSocket", target: "NodeBuilder | NodeSocket"
350
+ ) -> tuple[NodeSocket, NodeSocket]:
351
+ """Find the best possible compatible pair of sockets between two nodes, looking only at the
352
+ the currently available outputs from the source and the inputs from the target"""
353
+ possible_combos = []
354
+ if isinstance(source, NodeBuilder):
355
+ outputs = source._available_outputs
356
+ else:
357
+ outputs = [source]
358
+ if isinstance(target, NodeBuilder):
359
+ inputs = target._available_inputs
360
+ else:
361
+ inputs = [target]
362
+ for output in outputs:
363
+ compat_sockets = SOCKET_COMPATIBILITY.get(output.type, ())
364
+ for input in inputs:
365
+ if input.type == output.type:
366
+ return input, output
367
+
368
+ if input.type in compat_sockets:
369
+ possible_combos.append(
370
+ (compat_sockets.index(input.type), (input, output))
535
371
  )
536
- if target_type in socket_compatibles:
537
- return output_socket
538
- except (NotImplementedError, AttributeError, RuntimeError):
539
- # Node doesn't support dynamic socket creation or the type is not supported
540
- pass
541
-
542
- return None
543
-
544
- def _smart_link_to(self, target_node: "NodeBuilder") -> "NodeBuilder":
545
- """Smart linking that creates compatible sockets when needed.
546
372
 
547
- This method checks if we have a compatible output socket for the target node's input,
548
- and creates one if this node supports dynamic socket creation.
373
+ if possible_combos:
374
+ # sort by distance between compatible sockets and return the best match
375
+ return sorted(possible_combos, key=lambda x: x[0])[0][1]
549
376
 
550
- Args:
551
- target_node: The node to link to
552
-
553
- Returns:
554
- The target node (for chaining)
555
- """
556
- if not hasattr(target_node, "_default_input_socket"):
557
- # Fall back to regular linking
558
- return target_node
559
-
560
- target_socket = target_node._default_input_socket
561
- if not target_socket:
562
- # Target has no input socket - can't link
563
- return target_node
564
-
565
- # Check if our default output is compatible
566
- source_socket = self._default_output_socket
567
- if source_socket:
568
- source_compatibles = SOCKET_COMPATIBILITY.get(source_socket.type, [])
569
- if target_socket.type in source_compatibles:
570
- # Compatible - use normal linking
571
- self.tree.link(source_socket, target_socket)
572
- return target_node
573
-
574
- # Not compatible - try to find/create a compatible output socket
575
- compatible_socket = self._find_or_create_compatible_output_socket(
576
- target_socket.type
377
+ raise SocketError(
378
+ f"Cannot link any output from {source.node.name} to any input of {target.node.name}. "
379
+ f"Available output types: {[f'{o.name}:{o.type}' for o in outputs]}, "
380
+ f"Available input types: {[f'{i.name}:{i.type}' for i in inputs]}"
577
381
  )
578
- if compatible_socket:
579
- self.tree.link(compatible_socket, target_socket)
580
- return target_node
581
-
582
- # Fall back to regular linking (may create reroute nodes)
583
- if source_socket:
584
- self.tree.link(source_socket, target_socket)
585
- return target_node
586
382
 
587
383
  def _input_idx(self, identifier: str) -> int:
588
384
  # currently there is a Blender bug that is preventing the lookup of sockets from identifiers on some
@@ -621,28 +417,15 @@ class NodeBuilder:
621
417
  def _link(
622
418
  self, source: LINKABLE | SocketLinker | NodeSocket, target: LINKABLE
623
419
  ) -> bpy.types.NodeLink:
624
- return self.tree.link(self._source_socket(source), self._target_socket(target))
625
-
626
- def _link_to(self, target: LINKABLE) -> bpy.types.NodeLink:
627
- return self.tree.link(self._default_output_socket, self._target_socket(target))
420
+ source_socket = self._source_socket(source)
421
+ target_socket = self._target_socket(target)
422
+ return self.tree.link(source_socket, target_socket)
628
423
 
629
424
  def _link_from(
630
425
  self,
631
426
  source: LINKABLE,
632
427
  input: LINKABLE | str,
633
428
  ):
634
- # Special handling for dynamic socket nodes (zones, bake, capture attribute, etc.)
635
- # These nodes have an 'outputs' property that returns a dict based on their items
636
- if (
637
- hasattr(source, "_add_socket")
638
- and hasattr(source, "_smart_link_to")
639
- and hasattr(source.__class__, "outputs")
640
- and isinstance(getattr(source.__class__, "outputs"), property)
641
- and not isinstance(input, str)
642
- ):
643
- # Use smart linking that can create compatible sockets
644
- return source._smart_link_to(input)
645
-
646
429
  if isinstance(input, str):
647
430
  try:
648
431
  self._link(source, self.node.inputs[input])
@@ -651,39 +434,6 @@ class NodeBuilder:
651
434
  else:
652
435
  self._link(source, input)
653
436
 
654
- def _smart_link_from(
655
- self,
656
- source: LINKABLE,
657
- input_name: str,
658
- ):
659
- """Smart linking that finds compatible sockets when default fails."""
660
- # Get the target input socket
661
- try:
662
- target_socket = self.node.inputs[input_name]
663
- except KeyError:
664
- target_socket = self.node.inputs[self._input_idx(input_name)]
665
-
666
- # If source is a NodeBuilder, find the best compatible output socket
667
- if isinstance(source, NodeBuilder):
668
- # Search for compatible output sockets - don't try default first as it might be wrong type
669
- try:
670
- compatible_output = self._find_compatible_source_socket(
671
- source, target_socket
672
- )
673
- self._link(compatible_output, target_socket)
674
- return
675
- except ValueError:
676
- # No compatible socket found - this is an error, don't fall back
677
- raise ValueError(
678
- f"Cannot link {source.node.name} to {self.node.name}.{input_name}: "
679
- f"No compatible sockets. Available output types: {[s.type for s in source.node.outputs]}, "
680
- f"Target socket type: {target_socket.type}, "
681
- f"Compatible types: {SOCKET_COMPATIBILITY.get(target_socket.type, ())}"
682
- )
683
- else:
684
- # For other types, use original link_from behavior
685
- self._link_from(source, input_name)
686
-
687
437
  def _set_input_default_value(self, input, value):
688
438
  """Set the default value for an input socket, handling type conversions."""
689
439
  if (
@@ -695,62 +445,28 @@ class NodeBuilder:
695
445
  else:
696
446
  input.default_value = value
697
447
 
698
- def _find_best_compatible_socket(
699
- self, target_node: "NodeBuilder", output_socket: NodeSocket
700
- ) -> NodeSocket:
701
- """Find the best compatible input socket on target node for the given output socket."""
702
- output_type = output_socket.type
703
- compatible_types = SOCKET_COMPATIBILITY.get(output_type, ())
704
-
705
- # Collect all compatible input sockets with their priority
706
- compatible_inputs = []
707
- for input_socket in target_node.node.inputs:
708
- # Skip if socket already has a link and isn't multi-input
709
- if input_socket.links and not input_socket.is_multi_input:
710
- continue
711
-
712
- if input_socket.type in compatible_types:
713
- # Priority is the index in the compatibility list (0 = highest priority)
714
- priority = compatible_types.index(input_socket.type)
715
- compatible_inputs.append((priority, input_socket))
716
-
717
- if not compatible_inputs:
718
- # No compatible socket found
719
- available_types = [
720
- socket.type
721
- for socket in target_node.node.inputs
722
- if not socket.links or socket.is_multi_input
723
- ]
724
- raise RuntimeError(
725
- f"Cannot link {output_type} output to {target_node.node.name}. "
726
- f"Compatible types: {compatible_types}, "
727
- f"Available input types: {available_types}"
728
- )
729
-
730
- # Sort by priority (lowest number = highest priority) and return the best match
731
- compatible_inputs.sort(key=lambda x: x[0])
732
- return compatible_inputs[0][1]
733
-
734
448
  def _establish_links(self, **kwargs: TYPE_INPUT_ALL):
735
449
  input_ids = [input.identifier for input in self.node.inputs]
736
450
  for name, value in kwargs.items():
737
451
  if value is None:
738
452
  continue
453
+ if isinstance(value, Node):
454
+ node = NodeBuilder()
455
+ node.node = value
456
+ value = node
739
457
 
740
458
  if value is ...:
741
459
  # Ellipsis indicates this input should receive links from >> operator
742
460
  # which can potentially target multiple inputs on the new node
743
461
  if self._from_socket is not None:
744
- self._link(
745
- self._from_socket, self.node.inputs[self._input_idx(name)]
746
- )
462
+ self._link_from(self._from_socket, name)
747
463
 
748
464
  elif isinstance(value, SocketLinker):
749
- self._link(value, self.node.inputs[self._input_idx(name)])
750
- # we can also provide just a default value for the socket to take if we aren't
751
- # providing a socket to link with
752
- elif isinstance(value, (NodeBuilder, NodeSocket, Node)):
753
- self._smart_link_from(value, name)
465
+ self._link_from(value, name)
466
+ elif isinstance(value, NodeSocket):
467
+ self._link_from(value, name)
468
+ elif isinstance(value, NodeBuilder):
469
+ self._link_from(value._best_output_socket(self._input(name).type), name)
754
470
  else:
755
471
  if name in input_ids:
756
472
  input = self.node.inputs[input_ids.index(name)]
@@ -772,31 +488,16 @@ class NodeBuilder:
772
488
  Returns the right-hand node to enable continued chaining.
773
489
  """
774
490
  if isinstance(other, SocketLinker):
775
- # Direct socket linking - use default output
776
- socket_out = self._default_output_socket
777
- socket_in = other.socket
778
- other._from_socket = socket_out
491
+ source = self._default_output_socket
492
+ target = other.socket
493
+ other._from_socket = source
779
494
  else:
780
- # Standard NodeBuilder linking - need to find compatible sockets
781
- if other._link_target is not None:
782
- # Target socket is specified
783
- socket_in = self._get_input_socket_by_name(other, other._link_target)
784
- socket_out = self._default_output_socket
785
-
786
- # Try to find a better source socket if default doesn't work
787
- try:
788
- socket_out = self._find_compatible_source_socket(self, socket_in)
789
- except ValueError:
790
- # If no compatible socket found, use default and let the link fail with a clear error
791
- pass
792
-
793
- other._from_socket = socket_out
794
- else:
795
- # No target specified - find best compatible socket pair
796
- socket_out, socket_in = self._find_best_socket_pair(other)
797
- other._from_socket = socket_out
495
+ try:
496
+ source, target = self._find_best_socket_pair(self, other)
497
+ except SocketError:
498
+ source, target = other._find_best_socket_pair(self, other)
798
499
 
799
- self.tree.link(socket_out, socket_in)
500
+ self.tree.link(source, target)
800
501
  return other
801
502
 
802
503
  def _get_input_socket_by_name(self, node: "NodeBuilder", name: str) -> NodeSocket:
@@ -819,14 +520,14 @@ class NodeBuilder:
819
520
  else (other, self._default_output_socket)
820
521
  )
821
522
 
822
- # Determine if either operand is a vector type
823
- self_is_vector = self._default_output_socket.type == "VECTOR"
824
- other_is_vector = False
825
- if isinstance(other, NodeBuilder):
826
- other_is_vector = other._default_output_socket.type == "VECTOR"
523
+ component_is_vector = False
524
+ for value in values:
525
+ if getattr(value, "type", None) == "VECTOR":
526
+ component_is_vector = True
527
+ break
827
528
 
828
529
  # Use VectorMath if either operand is a vector
829
- if self_is_vector or other_is_vector:
530
+ if component_is_vector:
830
531
  if operation == "multiply":
831
532
  # Handle special cases for vector multiplication where we might scale instead
832
533
  # of using the multiply method
@@ -838,7 +539,7 @@ class NodeBuilder:
838
539
  return VectorMath.multiply(*values)
839
540
  else:
840
541
  raise TypeError(
841
- f"Unsupported type for {operation} with VECTOR socket: {type(other)}"
542
+ f"Unsupported type for {operation} with VECTOR socket: {type(other)}, {other=}"
842
543
  )
843
544
  else:
844
545
  vector_method = getattr(VectorMath, operation)
@@ -858,11 +559,10 @@ class NodeBuilder:
858
559
  raise TypeError(
859
560
  f"Unsupported type for {operation} with VECTOR operand: {type(other)}"
860
561
  )
861
- else:
862
- # Both operands are scalar types, use regular Math
562
+ else: # Both operands are scalar types, use regular Math
863
563
  from .nodes.converter import IntegerMath, Math
864
564
 
865
- if isinstance(other, int):
565
+ if isinstance(other, int) and self._default_output_socket.type == "INT":
866
566
  return getattr(IntegerMath, operation)(*values)
867
567
  else:
868
568
  return getattr(Math, operation)(*values)
@@ -892,6 +592,78 @@ class NodeBuilder:
892
592
  return self._apply_math_operation(other, "subtract", reverse=True)
893
593
 
894
594
 
595
+ class DynamicInputsMixin:
596
+ _socket_data_types: tuple[str]
597
+ _type_map: dict[str, str] = {}
598
+
599
+ def _match_compatible_data(self, *sockets: NodeSocket) -> tuple[NodeSocket, str]:
600
+ possible = []
601
+ for socket in sockets:
602
+ compatible = SOCKET_COMPATIBILITY.get(socket.type, ())
603
+ for type in self._socket_data_types:
604
+ if type in compatible:
605
+ possible.append((socket, type, compatible.index(type)))
606
+
607
+ if len(possible) > 0:
608
+ possible.sort(key=lambda x: x[2])
609
+ best_value = possible[0]
610
+ return best_value[:2]
611
+
612
+ raise SocketError("No compatible socket found")
613
+
614
+ def _find_best_socket_pair(
615
+ self, source: NodeBuilder, target: NodeBuilder
616
+ ) -> tuple[NodeSocket, NodeSocket] | None:
617
+ try:
618
+ return super()._find_best_socket_pair(source, target)
619
+ except SocketError:
620
+ if target == self:
621
+ target_name, source_socket = list(target._add_inputs(source).items())[0]
622
+ return (source_socket, target.inputs[target_name].socket)
623
+ else:
624
+ target_name, source_socket = list(
625
+ source._add_inputs(*target.node.inputs).items()
626
+ )[0]
627
+ return (
628
+ source.outputs[target_name].socket,
629
+ target.inputs[target_name].socket,
630
+ )
631
+
632
+ for target_name, source_socket in new_sockets.items():
633
+ target_socket = target.inputs[target_name].socket
634
+ return (source_socket, target_socket)
635
+
636
+ # def _best_output_socket(self, type: str) -> NodeSocket:
637
+ # # compatible = SOCKET_COMPATIBILITY.get(type, ())
638
+ # # possible = [
639
+ # # socket for socket in self._available_outputs if socket.type in compatible
640
+ # # ]
641
+ # # if possible:
642
+ # # return sorted(possible, key=lambda x: compatible.index(x.type))[0]
643
+
644
+ # raise SocketError("No compatible output sockets found")
645
+
646
+ def _add_inputs(self, *args, **kwargs) -> dict[str, LINKABLE]:
647
+ """Dictionary with {new_socket.name: from_linkable} for link creation"""
648
+ new_sockets = {}
649
+ items = {}
650
+ for arg in args:
651
+ if isinstance(arg, bpy.types.NodeSocket):
652
+ name = arg.name
653
+ items[name] = arg
654
+ else:
655
+ items[arg._default_output_socket.name] = arg
656
+ items.update(kwargs)
657
+ for key, source in items.items():
658
+ socket_source, type = self._match_compatible_data(*source.node.outputs)
659
+ if type in self._type_map:
660
+ type = self._type_map[type]
661
+ socket = self._add_socket(name=key, type=type)
662
+ new_sockets[socket.name] = socket_source
663
+
664
+ return new_sockets
665
+
666
+
895
667
  class SocketLinker(NodeBuilder):
896
668
  def __init__(self, socket: NodeSocket):
897
669
  assert socket.node is not None
@@ -900,6 +672,10 @@ class SocketLinker(NodeBuilder):
900
672
  self._default_output_id = socket.identifier
901
673
  self._tree = TreeBuilder(socket.node.id_data) # type: ignore
902
674
 
675
+ @property
676
+ def _available_outputs(self) -> list[NodeSocket]:
677
+ return [self.socket]
678
+
903
679
  @property
904
680
  def type(self) -> SOCKET_TYPES:
905
681
  return self.socket.type # type: ignore
@@ -2,7 +2,13 @@ from typing import Any, Literal
2
2
 
3
3
  import bpy
4
4
 
5
- from ..builder import NodeBuilder, NodeSocket, SocketLinker
5
+ from ..builder import (
6
+ DynamicInputsMixin,
7
+ NodeBuilder,
8
+ NodeSocket,
9
+ SocketError,
10
+ SocketLinker,
11
+ )
6
12
  from ..types import (
7
13
  LINKABLE,
8
14
  SOCKET_TYPES,
@@ -77,7 +83,7 @@ __all__ = (
77
83
  )
78
84
 
79
85
 
80
- class Bake(NodeBuilder):
86
+ class Bake(NodeBuilder, DynamicInputsMixin):
81
87
  """Cache the incoming data so that it can be used without recomputation
82
88
 
83
89
  TODO: properly handle Animation / Still bake opations and ability to bake to a file
@@ -171,12 +177,15 @@ class Value(NodeBuilder):
171
177
  return self._output("Value")
172
178
 
173
179
 
174
- class FormatString(NodeBuilder):
180
+ class FormatString(NodeBuilder, DynamicInputsMixin):
175
181
  """Insert values into a string using a Python and path template compatible formatting syntax"""
176
182
 
177
183
  _bl_idname = "FunctionNodeFormatString"
178
184
  node: bpy.types.FunctionNodeFormatString
179
185
  _socket_data_types = ("VALUE", "INT", "STRING")
186
+ _type_map = {
187
+ "VALUE": "FLOAT",
188
+ }
180
189
 
181
190
  def __init__(
182
191
  self,
@@ -396,7 +405,10 @@ class JoinGeometry(NodeBuilder):
396
405
  def __init__(self, *args: LINKABLE):
397
406
  super().__init__()
398
407
  for source in reversed(args):
399
- self._link_from(source, self)
408
+ try:
409
+ self._link(*self._find_best_socket_pair(source, self))
410
+ except SocketError:
411
+ self._link(*source._find_best_socket_pair(source, self))
400
412
 
401
413
  @property
402
414
  def i_geometry(self) -> SocketLinker:
@@ -769,11 +781,27 @@ def _domain_capture_attribute(domain: _AttributeDomains):
769
781
  return method
770
782
 
771
783
 
772
- class CaptureAttribute(NodeBuilder):
784
+ class CaptureAttribute(NodeBuilder, DynamicInputsMixin):
773
785
  """Store the result of a field on a geometry and output the data as a node socket. Allows remembering or interpolating data as the geometry changes, such as positions before deformation"""
774
786
 
775
787
  _bl_idname = "GeometryNodeCaptureAttribute"
776
788
  node: bpy.types.GeometryNodeCaptureAttribute
789
+ _socket_data_types = (
790
+ "VALUE",
791
+ "INT",
792
+ "BOOLEAN",
793
+ "VECTOR",
794
+ "RGBA",
795
+ "ROTATION",
796
+ "MATRIX",
797
+ )
798
+ _type_map = {
799
+ "VALUE": "FLOAT",
800
+ # "VECTOR": "FLOAT_VECTOR",
801
+ "RGBA": "FLOAT_COLOR",
802
+ "ROTATION": "QUATERNION",
803
+ "MATRIX": "FLOAT4X4",
804
+ }
777
805
  point = _domain_capture_attribute("POINT")
778
806
  edge = _domain_capture_attribute("EDGE")
779
807
  face = _domain_capture_attribute("FACE")
@@ -851,7 +879,7 @@ class CaptureAttribute(NodeBuilder):
851
879
  self.node.domain = value
852
880
 
853
881
 
854
- class FieldToGrid(NodeBuilder):
882
+ class FieldToGrid(DynamicInputsMixin, NodeBuilder):
855
883
  """Create new grids by evaluating new values on an existing volume grid topology
856
884
 
857
885
  New socket items for field evaluation are first created from *args then **kwargs to give specific names to the items.
@@ -873,7 +901,8 @@ class FieldToGrid(NodeBuilder):
873
901
 
874
902
  _bl_idname = "GeometryNodeFieldToGrid"
875
903
  node: bpy.types.GeometryNodeFieldToGrid
876
- _socket_data_types = ("FLOAT", "VALUE", "INT", "VECTOR", "BOOLEAN")
904
+ _socket_data_types = ("VALUE", "INT", "VECTOR", "BOOLEAN")
905
+ _type_map = {"VALUE": "FLOAT"}
877
906
  _default_input_id = "Topology"
878
907
 
879
908
  def __init__(
@@ -988,7 +1017,7 @@ class SDFGridBoolean(NodeBuilder):
988
1017
  for arg in args:
989
1018
  if arg is None:
990
1019
  continue
991
- node._link_from(arg, "Grid 2")
1020
+ node._link_from(*node._find_best_socket_pair(arg, node._input("Grid 2")))
992
1021
  return node
993
1022
 
994
1023
  @classmethod
@@ -1000,7 +1029,7 @@ class SDFGridBoolean(NodeBuilder):
1000
1029
  for arg in args:
1001
1030
  if arg is None:
1002
1031
  continue
1003
- node._link_from(arg, "Grid 2")
1032
+ node._link_from(*node._find_best_socket_pair(arg, node._input("Grid 2")))
1004
1033
  return node
1005
1034
 
1006
1035
  @classmethod
@@ -1011,11 +1040,11 @@ class SDFGridBoolean(NodeBuilder):
1011
1040
  ) -> "SDFGridBoolean":
1012
1041
  """Create SDF Grid Boolean with operation 'Difference'."""
1013
1042
  node = cls(operation="DIFFERENCE")
1014
- node._link_from(grid_1, "Grid 1")
1043
+ node._link_from(*node._find_best_socket_pair(grid_1, node._input("Grid 1")))
1015
1044
  for arg in args:
1016
1045
  if arg is None:
1017
1046
  continue
1018
- node._link_from(arg, "Grid 2")
1047
+ node._link_from(*node._find_best_socket_pair(arg, node._input("Grid 2")))
1019
1048
  return node
1020
1049
 
1021
1050
  @property
@@ -2,9 +2,8 @@ from abc import ABC, abstractmethod
2
2
  from typing import Literal
3
3
 
4
4
  import bpy
5
- from bpy.types import NodeSocket
6
5
 
7
- from nodebpy.builder import NodeBuilder, SocketLinker
6
+ from nodebpy.builder import DynamicInputsMixin, NodeBuilder, SocketLinker
8
7
 
9
8
  from ..types import (
10
9
  LINKABLE,
@@ -16,7 +15,7 @@ from ..types import (
16
15
  )
17
16
 
18
17
 
19
- class BaseZone(NodeBuilder, ABC):
18
+ class BaseZone(DynamicInputsMixin, NodeBuilder, ABC):
20
19
  _items_attribute: Literal["state_items", "repeat_items"]
21
20
 
22
21
  @property
@@ -81,30 +80,6 @@ class BaseZoneInput(BaseZone, NodeBuilder, ABC):
81
80
  item = self.items.new(type, name)
82
81
  return self.inputs[item.name]
83
82
 
84
- def __rshift__(self, other):
85
- """Custom zone input linking that creates sockets as needed"""
86
- # Check if target is a zone output without inputs
87
- if (
88
- hasattr(other, "_default_input_socket")
89
- and other._default_input_socket is None
90
- ):
91
- # Target zone needs a socket - create one based on our output
92
- from ..builder import SOCKET_COMPATIBILITY
93
-
94
- source_socket = self._default_output_socket
95
- source_type = source_socket.type
96
-
97
- compatible_types = SOCKET_COMPATIBILITY.get(source_type, [source_type])
98
- best_type = compatible_types[0] if compatible_types else source_type
99
-
100
- # Create socket on target zone
101
- target_socket = other._add_socket(name=best_type.title(), type=best_type)
102
- self.tree.link(source_socket, target_socket)
103
- return other
104
- else:
105
- # Use the general smart linking approach
106
- return self._smart_link_to(other)
107
-
108
83
 
109
84
  class BaseZoneOutput(BaseZone, NodeBuilder, ABC):
110
85
  """Base class for zone output nodes"""
@@ -122,69 +97,22 @@ class BaseZoneOutput(BaseZone, NodeBuilder, ABC):
122
97
  item = self.items.new(type, name)
123
98
  return self.node.inputs[item.name]
124
99
 
125
- def __rshift__(self, other):
126
- """Custom zone output linking that creates sockets as needed"""
127
- from ..builder import SOCKET_COMPATIBILITY
128
-
129
- # Get the source socket type
130
- source_socket = self._default_output_socket
131
- source_type = source_socket.type
132
-
133
- # Check if target has compatible inputs
134
- if hasattr(other, "_default_input_socket") and other._default_input_socket:
135
- # Normal linking
136
- return super().__rshift__(other)
137
- elif hasattr(other, "_add_socket"):
138
- # Target is also a zone - create compatible socket
139
- compatible_types = SOCKET_COMPATIBILITY.get(source_type, [source_type])
140
- best_type = compatible_types[0] if compatible_types else source_type
141
-
142
- # Create socket on target zone
143
- target_socket = other._add_socket(name=best_type.title(), type=best_type)
144
- self.tree.link(source_socket, target_socket)
145
- return other
146
- else:
147
- # Normal NodeBuilder
148
- return super().__rshift__(other)
149
-
150
- @property
151
- def _default_input_socket(self) -> NodeSocket:
152
- """Get default input socket, avoiding skip-type sockets"""
153
- inputs = list(self.inputs.values())
154
- if inputs:
155
- return inputs[0].socket
156
- else:
157
- # No socket exists - this should be handled by zone-specific __rshift__ logic
158
- # Return None to signal that a socket needs to be created
159
- return None
160
-
161
-
162
- class SimulationZone:
163
- def __init__(self, *args: LINKABLE, **kwargs: LINKABLE):
164
- self.input = SimulationInput()
165
- self.output = SimulationOutput()
166
- self.input.node.pair_with_output(self.output.node)
167
-
168
- self.output.node.state_items.clear()
169
- socket_lookup = self.output._add_inputs(*args, **kwargs)
170
- for name, source in socket_lookup.items():
171
- self.input._link_from(source, name)
172
-
173
- def delta_time(self) -> SocketLinker:
174
- return self.input.o_delta_time
175
-
176
- def __getitem__(self, index: int):
177
- match index:
178
- case 0:
179
- return self.input
180
- case 1:
181
- return self.output
182
- case _:
183
- raise IndexError("SimulationZone has only two items")
184
-
185
100
 
186
101
  class BaseSimulationZone(BaseZone):
187
102
  _items_attribute = "state_items"
103
+ _socket_data_types = (
104
+ "VALUE",
105
+ "INT",
106
+ "BOOLEAN",
107
+ "VECTOR",
108
+ "RGBA",
109
+ "ROTATION",
110
+ "MATRIX",
111
+ "STRING",
112
+ "GEOMETRY",
113
+ "BUNDLE",
114
+ )
115
+ _type_map = {"VALUE": "FLOAT"}
188
116
 
189
117
  @property
190
118
  def items(self) -> bpy.types.NodeGeometrySimulationOutputItems:
@@ -215,39 +143,49 @@ class SimulationOutput(BaseSimulationZone, BaseZoneOutput):
215
143
  return self._input("Skip")
216
144
 
217
145
 
218
- class RepeatZone:
219
- """Wrapper that supports both direct unpacking and iteration"""
220
-
221
- def __init__(
222
- self, iterations: TYPE_INPUT_INT = 1, *args: LINKABLE, **kwargs: LINKABLE
223
- ):
224
- self.input = RepeatInput(iterations)
225
- self.output = RepeatOutput()
146
+ class SimulationZone:
147
+ def __init__(self, *args: LINKABLE, **kwargs: LINKABLE):
148
+ self.input = SimulationInput()
149
+ self.output = SimulationOutput()
226
150
  self.input.node.pair_with_output(self.output.node)
227
151
 
228
- self.output.node.repeat_items.clear()
229
- self.input._establish_links(**self.input._add_inputs(*args, **kwargs))
230
-
231
- @property
232
- def i(self) -> SocketLinker:
233
- """Input socket: Skip simluation frame"""
234
- return self.input.o_iteration
152
+ self.output.node.state_items.clear()
153
+ socket_lookup = self.output._add_inputs(*args, **kwargs)
154
+ for name, source in socket_lookup.items():
155
+ self.input._link_from(source, name)
235
156
 
236
- def __iter__(self):
237
- """Support for loop: for i, input, output in RepeatZone(...)"""
238
- self._index = 0
239
- return self
157
+ def delta_time(self) -> SocketLinker:
158
+ return self.input.o_delta_time
240
159
 
241
- def __next__(self):
242
- """Support for iteration: next(RepeatZone)"""
243
- if self._index > 0:
244
- raise StopIteration
245
- self._index += 1
246
- return self.i, self.input, self.output
160
+ def __getitem__(self, index: int):
161
+ match index:
162
+ case 0:
163
+ return self.input
164
+ case 1:
165
+ return self.output
166
+ case _:
167
+ raise IndexError("SimulationZone has only two items")
247
168
 
248
169
 
249
170
  class BaseRepeatZone(BaseZone):
250
171
  _items_attribute = "repeat_items"
172
+ _socket_data_types = (
173
+ "FLOAT",
174
+ "INT",
175
+ "BOOLEAN",
176
+ "VECTOR",
177
+ "RGBA",
178
+ "ROTATION",
179
+ "MATRIX",
180
+ "STRING",
181
+ "OBJECT",
182
+ "IMAGE",
183
+ "GEOMETRY",
184
+ "COLLECTION",
185
+ "MATERIAL",
186
+ "BUNDLE",
187
+ "CLOSURE",
188
+ )
251
189
 
252
190
  @property
253
191
  def items(self) -> bpy.types.NodeGeometryRepeatOutputItems:
@@ -284,6 +222,37 @@ class RepeatOutput(BaseRepeatZone, BaseZoneOutput):
284
222
  node: bpy.types.GeometryNodeRepeatOutput
285
223
 
286
224
 
225
+ class RepeatZone:
226
+ """Wrapper that supports both direct unpacking and iteration"""
227
+
228
+ def __init__(
229
+ self, iterations: TYPE_INPUT_INT = 1, *args: LINKABLE, **kwargs: LINKABLE
230
+ ):
231
+ self.input = RepeatInput(iterations)
232
+ self.output = RepeatOutput()
233
+ self.input.node.pair_with_output(self.output.node)
234
+
235
+ self.output.node.repeat_items.clear()
236
+ self.input._establish_links(**self.input._add_inputs(*args, **kwargs))
237
+
238
+ @property
239
+ def i(self) -> SocketLinker:
240
+ """Input socket: Skip simluation frame"""
241
+ return self.input.o_iteration
242
+
243
+ def __iter__(self):
244
+ """Support for loop: for i, input, output in RepeatZone(...)"""
245
+ self._index = 0
246
+ return self
247
+
248
+ def __next__(self):
249
+ """Support for iteration: next(RepeatZone)"""
250
+ if self._index > 0:
251
+ raise StopIteration
252
+ self._index += 1
253
+ return self.i, self.input, self.output
254
+
255
+
287
256
  class ForEachGeometryElementInput(NodeBuilder):
288
257
  """For Each Geometry Element Input node"""
289
258
 
@@ -280,7 +280,7 @@ SOCKET_TYPES = Literal[
280
280
  "CLOSURE",
281
281
  ]
282
282
 
283
- SOCKET_COMPATIBILITY = {
283
+ SOCKET_COMPATIBILITY: dict[str, tuple[str]] = {
284
284
  "VALUE": (
285
285
  "VALUE",
286
286
  "VECTOR",
File without changes
File without changes
File without changes