nodebpy 520.35.1__tar.gz → 520.37.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 (117) hide show
  1. {nodebpy-520.35.1 → nodebpy-520.37.0}/PKG-INFO +17 -1
  2. {nodebpy-520.35.1 → nodebpy-520.37.0}/README.md +16 -0
  3. {nodebpy-520.35.1 → nodebpy-520.37.0}/pyproject.toml +5 -1
  4. nodebpy-520.37.0/src/nodebpy/_lookup.py +215 -0
  5. nodebpy-520.37.0/src/nodebpy/_skill.py +53 -0
  6. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/assets/_library.py +15 -3
  7. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/__init__.py +2 -0
  8. nodebpy-520.37.0/src/nodebpy/builder/_combine.py +80 -0
  9. nodebpy-520.37.0/src/nodebpy/builder/_infer.py +139 -0
  10. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/_utils.py +62 -2
  11. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/items.py +3 -0
  12. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/node.py +33 -2
  13. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/tree.py +82 -3
  14. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/codegen.py +220 -70
  15. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/DESIGN.md +4 -0
  16. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/balancing.py +24 -18
  17. nodebpy-520.37.0/src/nodebpy/layout/feeders.py +93 -0
  18. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/pipeline.py +6 -0
  19. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/placement.py +9 -3
  20. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/sugiyama.py +4 -0
  21. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/_mixins.py +263 -1
  22. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/color.py +97 -63
  23. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/converter.py +189 -336
  24. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/distort.py +14 -11
  25. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/filter.py +201 -126
  26. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/input.py +25 -19
  27. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/interface.py +1 -1
  28. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/manual.py +101 -3
  29. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/matte.py +48 -36
  30. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/output.py +2 -1
  31. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/attribute.py +2 -2
  32. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/color.py +9 -6
  33. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/converter.py +124 -128
  34. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/geometry.py +120 -110
  35. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/grid.py +16 -16
  36. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/input.py +27 -25
  37. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/interface.py +5 -5
  38. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/manual.py +121 -91
  39. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/output.py +3 -2
  40. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/texture.py +26 -26
  41. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/vector.py +11 -8
  42. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/zone.py +1 -1
  43. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/__init__.py +5 -1
  44. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/color.py +25 -16
  45. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/converter.py +6 -6
  46. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/grid.py +2 -2
  47. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/input.py +101 -7
  48. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/manual.py +29 -84
  49. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/output.py +5 -5
  50. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/script.py +1 -1
  51. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/shader.py +44 -34
  52. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/texture.py +9 -9
  53. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/vector.py +9 -9
  54. nodebpy-520.37.0/src/nodebpy/skills/nodebpy/SKILL.md +167 -0
  55. nodebpy-520.37.0/src/nodebpy/skills/nodebpy/references/existing-trees.md +156 -0
  56. nodebpy-520.37.0/src/nodebpy/skills/nodebpy/references/shader-compositor.md +99 -0
  57. nodebpy-520.37.0/src/nodebpy/skills/nodebpy/references/verifying.md +86 -0
  58. nodebpy-520.37.0/src/nodebpy/skills/nodebpy/references/writing-trees.md +148 -0
  59. nodebpy-520.37.0/src/nodebpy/skills/nodebpy/references/zones-and-items.md +121 -0
  60. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/__init__.py +0 -0
  61. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/__main__.py +0 -0
  62. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/assets/__init__.py +0 -0
  63. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/assets/__main__.py +0 -0
  64. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/assets/_codegen.py +0 -0
  65. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/assets/_pipeline.py +0 -0
  66. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/assets/_textconv.py +0 -0
  67. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/_registry.py +0 -0
  68. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/_socket_order.py +0 -0
  69. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/accessor.py +0 -0
  70. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/asset.py +0 -0
  71. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/mixins.py +0 -0
  72. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/builder/socket.py +0 -0
  73. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/__init__.py +0 -0
  74. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/_floats.py +0 -0
  75. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/diagram.py +0 -0
  76. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/parity.py +0 -0
  77. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/plot.py +0 -0
  78. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/export/web_render.py +0 -0
  79. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/__init__.py +0 -0
  80. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/api.py +0 -0
  81. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/apply.py +0 -0
  82. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/build.py +0 -0
  83. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/common.py +0 -0
  84. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/config.py +0 -0
  85. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/digraph.py +0 -0
  86. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/dna.py +0 -0
  87. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/edits.py +0 -0
  88. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/extract.py +0 -0
  89. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/long_links.py +0 -0
  90. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/model.py +0 -0
  91. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/node_size.py +0 -0
  92. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/ordering.py +0 -0
  93. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/packing.py +0 -0
  94. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/priority.py +0 -0
  95. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/ranking.py +0 -0
  96. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/realize.py +0 -0
  97. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/reroutes.py +0 -0
  98. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/routing.py +0 -0
  99. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/snapping.py +0 -0
  100. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/spacing.py +0 -0
  101. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/stacking.py +0 -0
  102. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/layout/zones.py +0 -0
  103. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/live.py +0 -0
  104. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/__init__.py +0 -0
  105. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/__init__.py +1 -1
  106. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
  107. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  108. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  109. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  110. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
  111. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  112. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  113. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  114. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/assets.py +0 -0
  115. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/nodes/shader/group.py +0 -0
  116. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/py.typed +0 -0
  117. {nodebpy-520.35.1 → nodebpy-520.37.0}/src/nodebpy/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.35.1
3
+ Version: 520.37.0
4
4
  Summary: Build nodes trees in Blender more elegantly with code
5
5
  Author: Brady Johnston
6
6
  Author-email: Brady Johnston <brady.johnston@me.com>
@@ -60,6 +60,22 @@ with g.tree("AnotherTree", collapse=True, is_modifier=True) as tree:
60
60
  - Python's arithmetic, comparison and boolean operators create the matching `Math` / `VectorMath` / `Compare` / `BooleanMath` nodes: `g.Value(1.0) * 2 + (0, 0, 1)`.
61
61
  - Existing node trees — including ones wired up by hand in the GUI — can be exported back to `nodebpy` source with `to_python()`.
62
62
 
63
+ ## Agent skill
64
+
65
+ nodebpy ships an [Agent Skill](https://agentskills.io) that teaches AI coding agents to
66
+ write, check and port node trees with it. Install it into any skills-aware agent
67
+ (Claude Code, Codex, Cursor, Copilot, OpenCode and others) from the repository:
68
+
69
+ ```sh
70
+ npx skills add BradyAJohnston/nodebpy
71
+ ```
72
+
73
+ Claude Code can also take it as a plugin (`/plugin marketplace add BradyAJohnston/nodebpy`,
74
+ then `/plugin install nodebpy@nodebpy`), and the installed package carries the same
75
+ skill, matched to its version: `nodebpy skill install` copies it into `~/.claude/skills`
76
+ and `nodebpy skill path` prints where it is. `nodebpy lookup search|show|socket` is the
77
+ lookup the skill uses to find classes, sockets and options.
78
+
63
79
  ## Documentation
64
80
 
65
81
  Guides and the full node reference live at [bradyajohnston.github.io/nodebpy](https://bradyajohnston.github.io/nodebpy):
@@ -43,6 +43,22 @@ with g.tree("AnotherTree", collapse=True, is_modifier=True) as tree:
43
43
  - Python's arithmetic, comparison and boolean operators create the matching `Math` / `VectorMath` / `Compare` / `BooleanMath` nodes: `g.Value(1.0) * 2 + (0, 0, 1)`.
44
44
  - Existing node trees — including ones wired up by hand in the GUI — can be exported back to `nodebpy` source with `to_python()`.
45
45
 
46
+ ## Agent skill
47
+
48
+ nodebpy ships an [Agent Skill](https://agentskills.io) that teaches AI coding agents to
49
+ write, check and port node trees with it. Install it into any skills-aware agent
50
+ (Claude Code, Codex, Cursor, Copilot, OpenCode and others) from the repository:
51
+
52
+ ```sh
53
+ npx skills add BradyAJohnston/nodebpy
54
+ ```
55
+
56
+ Claude Code can also take it as a plugin (`/plugin marketplace add BradyAJohnston/nodebpy`,
57
+ then `/plugin install nodebpy@nodebpy`), and the installed package carries the same
58
+ skill, matched to its version: `nodebpy skill install` copies it into `~/.claude/skills`
59
+ and `nodebpy skill path` prints where it is. `nodebpy lookup search|show|socket` is the
60
+ lookup the skill uses to find classes, sockets and options.
61
+
46
62
  ## Documentation
47
63
 
48
64
  Guides and the full node reference live at [bradyajohnston.github.io/nodebpy](https://bradyajohnston.github.io/nodebpy):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodebpy"
3
- version = "520.35.1"
3
+ version = "520.37.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -65,6 +65,10 @@ not-subscriptable = "ignore"
65
65
  invalid-assignment = "ignore"
66
66
  invalid-argument-type = "ignore"
67
67
 
68
+ [tool.ruff]
69
+ # The agent skill's Markdown keeps its examples compact and aligned by hand.
70
+ extend-exclude = ["src/nodebpy/skills", "skills"]
71
+
68
72
  [tool.ruff.lint]
69
73
  ignore = ["SIM117", "RUF059"]
70
74
 
@@ -0,0 +1,215 @@
1
+ """``nodebpy lookup``: find node classes and show their API compactly.
2
+
3
+ ``search <term>`` matches the term (case-insensitive, words may be separated
4
+ by anything) against class names, Blender ``bl_idname``s and the first
5
+ docstring line. ``show <ClassName>`` prints the constructor parameters, enum
6
+ options, class-method variants and output sockets without the long Union
7
+ types ``inspect.signature`` prints. ``socket [Type]`` lists the methods and
8
+ properties on a socket type (what ``node.o.<name>`` or
9
+ ``tree.inputs.<type>(...)`` returns), or the socket types themselves.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import inspect
16
+ import re
17
+ import typing
18
+ from collections.abc import Iterator
19
+
20
+ _PREFIX = {"geometry": "g", "shader": "s", "compositor": "c"}
21
+
22
+
23
+ def _modules() -> dict[str, object]:
24
+ import nodebpy # noqa: F401 # before bpy: the project's own copy wins
25
+ from nodebpy import compositor, geometry, shader
26
+
27
+ return {"geometry": geometry, "shader": shader, "compositor": compositor}
28
+
29
+
30
+ def _classes(module) -> Iterator[tuple[str, type]]:
31
+ for name in dir(module):
32
+ obj = getattr(module, name)
33
+ if name[0].isupper() and inspect.isclass(obj) and hasattr(obj, "_bl_idname"):
34
+ yield name, obj
35
+
36
+
37
+ def _summary(cls) -> str:
38
+ doc = inspect.getdoc(cls) or ""
39
+ return doc.strip().splitlines()[0] if doc.strip() else ""
40
+
41
+
42
+ def search(term: str, trees: list[str]) -> list[str]:
43
+ words = re.split(r"[\s_]+", term.strip())
44
+ pattern = re.compile(".*".join(re.escape(w) for w in words), re.IGNORECASE)
45
+ modules = _modules()
46
+ lines = []
47
+ for tree in trees:
48
+ for name, cls in _classes(modules[tree]):
49
+ idname = getattr(cls, "_bl_idname", "")
50
+ summary = _summary(cls)
51
+ if (
52
+ pattern.search(name)
53
+ or pattern.search(idname)
54
+ or pattern.search(summary)
55
+ ):
56
+ lines.append(f"{_PREFIX[tree]}.{name:32} {idname:40} {summary[:70]}")
57
+ return lines or [f"no node class matches {term!r}"]
58
+
59
+
60
+ def _short_type(annotation) -> str:
61
+ if typing.get_origin(annotation) is typing.Literal:
62
+ return " | ".join(repr(a) for a in typing.get_args(annotation))
63
+ text = str(annotation)
64
+ match = re.search(r"ForwardRef\('(\w+Socket)'\)", text)
65
+ return match.group(1) if match else text.replace("typing.", "")[:40]
66
+
67
+
68
+ def _signature_lines(fn, names_only: bool = False) -> list[str]:
69
+ """One entry per parameter, with a ``*`` marker before keyword-only ones."""
70
+ try:
71
+ sig = inspect.signature(fn)
72
+ except (TypeError, ValueError): # pragma: no cover - C-implemented callables
73
+ return []
74
+ hints: dict = {}
75
+ if not names_only:
76
+ try:
77
+ hints = typing.get_type_hints(fn)
78
+ except Exception: # noqa: BLE001 - forward references that do not resolve
79
+ hints = {}
80
+ entries, keyword_only = [], False
81
+ for param in sig.parameters.values():
82
+ if param.name == "self":
83
+ continue
84
+ if param.kind is param.KEYWORD_ONLY and not keyword_only:
85
+ entries.append("*")
86
+ keyword_only = True
87
+ if param.kind in (param.VAR_KEYWORD, param.VAR_POSITIONAL):
88
+ entries.append(
89
+ f"{'**' if param.kind is param.VAR_KEYWORD else '*'}{param.name}"
90
+ )
91
+ continue
92
+ if names_only:
93
+ entries.append(param.name)
94
+ continue
95
+ default = "" if param.default is param.empty else f" = {param.default!r}"
96
+ kind = _short_type(hints.get(param.name, param.annotation))
97
+ entries.append(f"{param.name}: {kind}{default}")
98
+ return entries
99
+
100
+
101
+ def show(name: str, trees: list[str]) -> list[str]:
102
+ modules = _modules()
103
+ for tree in trees:
104
+ cls = getattr(modules[tree], name, None)
105
+ if cls is None:
106
+ continue
107
+ lines = [
108
+ f"{tree}.{name} ({getattr(cls, '_bl_idname', '?')})",
109
+ f" {_summary(cls)}",
110
+ "",
111
+ " constructor:",
112
+ ]
113
+ for entry in _signature_lines(cls.__init__):
114
+ lines.append(
115
+ " * (keyword-only properties)" if entry == "*" else f" {entry}"
116
+ )
117
+ methods, factories = [], {}
118
+ for attr in sorted(dir(cls)):
119
+ if attr.startswith("_") or not attr.islower():
120
+ continue
121
+ static = inspect.getattr_static(cls, attr, None)
122
+ if isinstance(static, classmethod) and attr != "create_group":
123
+ methods.append(attr)
124
+ elif not isinstance(static, (property, staticmethod)) and not callable(
125
+ static
126
+ ):
127
+ # factory objects: g.Compare.float.less_than(), g.StoreNamedAttribute.point.float()
128
+ subs = tuple(s for s in dir(static) if not s.startswith("_"))
129
+ if subs:
130
+ factories.setdefault(subs, []).append(attr)
131
+ if methods:
132
+ lines.append("")
133
+ lines.append(" variants:")
134
+ for m in methods:
135
+ params = ", ".join(_signature_lines(getattr(cls, m), names_only=True))
136
+ lines.append(f" {name}.{m}({params})")
137
+ for subs, attrs in factories.items():
138
+ left = attrs[0] if len(attrs) == 1 else "{" + ",".join(attrs) + "}"
139
+ lines.append("")
140
+ lines.append(f" variants: {name}.{left}.{{{','.join(subs)}}}()")
141
+ doc = inspect.getdoc(cls) or ""
142
+ outputs = doc.split("Outputs\n-------", 1)
143
+ if len(outputs) == 2:
144
+ names = [
145
+ ln.strip() for ln in outputs[1].splitlines() if ln.startswith("o.")
146
+ ]
147
+ lines.append("")
148
+ lines.append(" outputs: " + ", ".join(names))
149
+ return lines
150
+ return [f"no class {name!r}; try: nodebpy lookup search {name}"]
151
+
152
+
153
+ def socket(kind: str | None) -> list[str]:
154
+ from nodebpy.builder import socket as sockets
155
+
156
+ base = set(dir(sockets.Socket))
157
+ classes = {
158
+ n.removesuffix("Socket"): c
159
+ for n, c in vars(sockets).items()
160
+ if inspect.isclass(c)
161
+ and n.endswith("Socket")
162
+ and n not in ("Socket", "BaseSocket")
163
+ }
164
+ if not kind:
165
+ return ["socket types: " + ", ".join(sorted(classes))]
166
+ wanted = kind.lower().removesuffix("socket")
167
+ key = next((k for k in classes if k.lower() == wanted), None)
168
+ if key is None:
169
+ return [f"no socket type {kind!r}; types: {', '.join(sorted(classes))}"]
170
+ cls = classes[key]
171
+ lines = [f"{key}Socket"]
172
+ for attr in sorted(set(dir(cls)) - base):
173
+ if attr.startswith("_"):
174
+ continue
175
+ static = inspect.getattr_static(cls, attr, None)
176
+ doc = (inspect.getdoc(static) or "").strip().splitlines()
177
+ first = doc[0] if doc else ""
178
+ if isinstance(static, property):
179
+ lines.append(f" .{attr:22} {first[:80]}")
180
+ elif callable(static) or isinstance(static, (classmethod, staticmethod)):
181
+ params = _signature_lines(getattr(cls, attr), names_only=True)
182
+ lines.append(f" .{attr}({', '.join(params)})".ljust(40) + f" {first[:70]}")
183
+ else: # pragma: no cover - a plain factory object on a socket class
184
+ subs = [x for x in dir(static) if not x.startswith("_")]
185
+ lines.append(f" .{attr}.{{{','.join(subs)}}}()"[:110])
186
+ return lines
187
+
188
+
189
+ def add_parser(sub) -> None:
190
+ parser = sub.add_parser(
191
+ "lookup",
192
+ help="Find node classes and show their API: search, show, socket.",
193
+ description=__doc__,
194
+ formatter_class=argparse.RawDescriptionHelpFormatter,
195
+ )
196
+ parser.add_argument("what", choices=["search", "show", "socket"])
197
+ parser.add_argument(
198
+ "term", nargs="?", help="search term, class name or socket type"
199
+ )
200
+ parser.add_argument(
201
+ "--tree", choices=list(_PREFIX), default=None, help="limit to one tree type"
202
+ )
203
+
204
+
205
+ def run(args: argparse.Namespace) -> None:
206
+ trees = [args.tree] if args.tree else list(_PREFIX)
207
+ if args.what == "socket":
208
+ lines = socket(args.term)
209
+ elif not args.term:
210
+ raise SystemExit(f"nodebpy lookup {args.what} needs a term")
211
+ elif args.what == "search":
212
+ lines = search(args.term, trees)
213
+ else:
214
+ lines = show(args.term, trees)
215
+ print("\n".join(lines))
@@ -0,0 +1,53 @@
1
+ """``nodebpy skill``: the agent skill bundled with the package.
2
+
3
+ The skill (``nodebpy/skills/nodebpy/SKILL.md`` and its references) teaches
4
+ AI coding agents to write node trees with the installed nodebpy, so it
5
+ always matches the installed version. ``path`` prints its directory;
6
+ ``install`` copies it into an agent's skills directory (Claude Code's
7
+ ``~/.claude/skills`` by default). Other agents are covered by
8
+ ``npx skills add BradyAJohnston/nodebpy``, which reads the same folder from
9
+ the repository.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import shutil
16
+ from pathlib import Path
17
+
18
+ SKILL_DIR = Path(__file__).parent / "skills" / "nodebpy"
19
+
20
+
21
+ def install(target: Path) -> Path:
22
+ """Copy the skill into ``target`` (a skills directory), replacing an
23
+ existing ``nodebpy`` skill there, and return the installed directory."""
24
+ destination = Path(target).expanduser() / SKILL_DIR.name
25
+ if destination.exists():
26
+ shutil.rmtree(destination)
27
+ shutil.copytree(
28
+ SKILL_DIR, destination, ignore=shutil.ignore_patterns("__pycache__")
29
+ )
30
+ return destination
31
+
32
+
33
+ def add_parser(sub) -> None:
34
+ parser = sub.add_parser(
35
+ "skill",
36
+ help="Locate or install the bundled agent skill.",
37
+ description=__doc__,
38
+ formatter_class=argparse.RawDescriptionHelpFormatter,
39
+ )
40
+ parser.add_argument("what", choices=["path", "install"])
41
+ parser.add_argument(
42
+ "--to",
43
+ type=Path,
44
+ default=Path("~/.claude/skills"),
45
+ help="skills directory to install into (default: ~/.claude/skills)",
46
+ )
47
+
48
+
49
+ def run(args: argparse.Namespace) -> None:
50
+ if args.what == "path":
51
+ print(SKILL_DIR)
52
+ else:
53
+ print(f"installed {install(args.to)}")
@@ -1773,6 +1773,10 @@ def _parse_args(argv: list[str] | None = None):
1773
1773
  ),
1774
1774
  )
1775
1775
  sub = parser.add_subparsers(dest="command", required=True)
1776
+ from nodebpy import _lookup, _skill
1777
+
1778
+ _lookup.add_parser(sub)
1779
+ _skill.add_parser(sub)
1776
1780
 
1777
1781
  dump = sub.add_parser(
1778
1782
  "dump",
@@ -2020,7 +2024,7 @@ def _parse_args(argv: list[str] | None = None):
2020
2024
  )
2021
2025
 
2022
2026
  args = parser.parse_args(argv)
2023
- if args.command not in ("plot", "textconv"):
2027
+ if args.command not in ("plot", "textconv", "lookup", "skill"):
2024
2028
  # Fill positionals and flags from the nearest pyproject's
2025
2029
  # [tool.nodebpy.assets] table; explicit arguments always win.
2026
2030
  apply_config(args)
@@ -2073,9 +2077,17 @@ def _build_command(args) -> None:
2073
2077
 
2074
2078
  def main(argv: list[str] | None = None) -> None: # pragma: no cover - CLI wrapper
2075
2079
  """CLI entry point for the ``dump``, ``build``, ``ensure``, ``check``,
2076
- ``plot`` and ``textconv`` subcommands."""
2080
+ ``plot``, ``textconv``, ``lookup`` and ``skill`` subcommands."""
2077
2081
  args = _parse_args(argv)
2078
- if args.command == "dump":
2082
+ if args.command == "lookup":
2083
+ from nodebpy import _lookup
2084
+
2085
+ _lookup.run(args)
2086
+ elif args.command == "skill":
2087
+ from nodebpy import _skill
2088
+
2089
+ _skill.run(args)
2090
+ elif args.command == "dump":
2079
2091
  _dump_command(args)
2080
2092
  elif args.command == "ensure":
2081
2093
  if is_stale(args.blend, args.source, args.resources, stamp_options(args)):
@@ -81,6 +81,7 @@ from .tree import (
81
81
  PanelContext,
82
82
  SocketContext,
83
83
  TreeBuilder,
84
+ WorldBuilder,
84
85
  default_split_inputs,
85
86
  )
86
87
 
@@ -157,6 +158,7 @@ __all__ = [
157
158
  "VectorSocket",
158
159
  "VectorSocketGrid",
159
160
  "VectorSocketList",
161
+ "WorldBuilder",
160
162
  "arrange",
161
163
  "asset_group_base",
162
164
  "build_from_source",
@@ -0,0 +1,80 @@
1
+ """Feed a vector, rotation or color input from a tuple that holds sockets.
2
+
3
+ ``g.Cube(size=(1, 1, height))`` can't store ``height`` in the socket's
4
+ default, since it is only known when the tree runs, so the tuple is built into
5
+ a Combine XYZ (or Combine Color) node whose output is linked in instead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from bpy.types import NodeSocket
13
+
14
+ from ._infer import _source_socket
15
+ from ._utils import SocketError
16
+
17
+ if TYPE_CHECKING:
18
+ from .tree import TreeBuilder
19
+
20
+
21
+ def _show(values: list[Any]) -> str:
22
+ """The tuple as written, with sockets named by their node."""
23
+ parts = []
24
+ for v in values:
25
+ socket = _source_socket(v)
26
+ node = getattr(socket, "node", None)
27
+ parts.append(f"{node.name}'s {socket.name}" if socket and node else repr(v))
28
+ return f"({', '.join(parts)})"
29
+
30
+
31
+ def holds_sockets(value: Any) -> bool:
32
+ """Whether ``value`` is a tuple or list with a socket or node in it."""
33
+ return isinstance(value, (tuple, list)) and any(
34
+ _source_socket(v) is not None for v in value
35
+ )
36
+
37
+
38
+ def link_combined(tree: TreeBuilder[Any], socket: NodeSocket, value: Any) -> None:
39
+ """Link a Combine XYZ / Combine Color built from ``value`` into ``socket``."""
40
+ from ..nodes import compositor, geometry, shader
41
+
42
+ nodes: Any = {"ShaderNodeTree": shader, "CompositorNodeTree": compositor}.get(
43
+ tree.tree.bl_idname, geometry
44
+ )
45
+ values = list(value)
46
+ if socket.type in ("VECTOR", "ROTATION"):
47
+ # a 2D vector socket takes (x, y); an Euler rotation is converted
48
+ size = len(getattr(socket, "default_value", ())) or 3
49
+ if socket.type == "ROTATION" or size not in (2, 3):
50
+ size = 3
51
+ if len(values) != size:
52
+ raise SocketError(
53
+ f"'{socket.name}' takes {size} components, not {len(values)}: "
54
+ f"{_show(values)}."
55
+ )
56
+ combined = nodes.CombineXYZ(*values)
57
+ elif socket.type == "RGBA":
58
+ if len(values) not in (3, 4):
59
+ raise SocketError(
60
+ f"'{socket.name}' takes a color of 3 or 4 components, not "
61
+ f"{len(values)}: {_show(values)}."
62
+ )
63
+ red, green, blue, *alpha = values
64
+ kwargs: dict[str, Any] = {"red": red, "green": green, "blue": blue}
65
+ if alpha and "alpha" in nodes.CombineColor.__init__.__code__.co_varnames:
66
+ kwargs["alpha"] = alpha[0]
67
+ elif alpha and not (isinstance(alpha[0], (int, float)) and alpha[0] == 1):
68
+ # shader colors carry no alpha through links (and ``==`` on a
69
+ # socket would build a Compare node rather than test it)
70
+ raise SocketError(
71
+ f"Shader colors have no alpha to set from {_show(alpha)[1:-1]}; "
72
+ f"pass (red, green, blue) to '{socket.name}'."
73
+ )
74
+ combined = nodes.CombineColor(**kwargs)
75
+ else:
76
+ raise SocketError(
77
+ f"'{socket.name}' is a {socket.type} input, which a tuple of sockets "
78
+ "can't feed."
79
+ )
80
+ tree.link(combined._default_output_socket, socket)
@@ -0,0 +1,139 @@
1
+ """Pick a multi-type node's data type from the values it is given.
2
+
3
+ Switch, Index Switch and Menu Switch need their type set before anything is
4
+ linked. Left at the default (Float), a node passed in links whichever of its
5
+ outputs converts to a float, so ``g.Switch(cond, g.Cube(), g.UVSphere())``
6
+ silently switches between the two UV Maps instead of the meshes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from typing import Any, cast
13
+
14
+ from bpy.types import Node, NodeSocket
15
+
16
+ from ._utils import SocketError
17
+
18
+ # the data_type / input_type spelling of a socket type, where they differ
19
+ _DATA_TYPE = {"VALUE": "FLOAT"}
20
+
21
+
22
+ def _source_socket(value: Any) -> NodeSocket | None:
23
+ """The socket ``value`` would link from, or ``None`` for a plain value."""
24
+ if value is None or value is ...:
25
+ return None
26
+ if isinstance(value, NodeSocket):
27
+ return value
28
+ if isinstance(value, Node):
29
+ return next((s for s in value.outputs if s.enabled), None)
30
+ socket = getattr(value, "socket", None)
31
+ if isinstance(socket, NodeSocket):
32
+ return socket
33
+ try:
34
+ # nodes and item handles link from their default output
35
+ default = getattr(value, "_default_output_socket", None)
36
+ except (IndexError, ValueError, AttributeError):
37
+ return None
38
+ return default if isinstance(default, NodeSocket) else None
39
+
40
+
41
+ def _literal_type(value: Any) -> str | None:
42
+ if isinstance(value, bool):
43
+ return "BOOLEAN"
44
+ if isinstance(value, int):
45
+ return "INT"
46
+ if isinstance(value, float):
47
+ return "FLOAT"
48
+ if isinstance(value, str):
49
+ return "STRING"
50
+ if isinstance(value, (tuple, list)) and all(
51
+ isinstance(v, (int, float)) for v in value
52
+ ):
53
+ return {3: "VECTOR", 4: "RGBA"}.get(len(value))
54
+ return None
55
+
56
+
57
+ def _describe(socket: NodeSocket) -> str:
58
+ return f"{getattr(socket.node, 'name', '?')}'s {socket.name}"
59
+
60
+
61
+ def infer_node_type(
62
+ node: Node, prop: str, values: Iterable[Any], *, default: str = "FLOAT"
63
+ ) -> str:
64
+ """``prop``'s value for ``node`` switching between ``values``, set on it.
65
+
66
+ The types a switch takes depend on the tree (Integer Vector only in the
67
+ compositor), which only the node itself knows, so the inferred type is
68
+ tried on the node and the error lists the ones it accepts.
69
+ """
70
+ name = cast(Any, node.bl_rna).name
71
+ data_type = infer_data_type(name, values, keyword=prop, default=default)
72
+ try:
73
+ setattr(node, prop, data_type)
74
+ except TypeError:
75
+ raise SocketError(
76
+ f"{name} can't switch {data_type} values in this tree; it takes "
77
+ f"{', '.join(_accepted(node, prop))}."
78
+ ) from None
79
+ return data_type
80
+
81
+
82
+ def _accepted(node: Node, prop: str) -> list[str]:
83
+ """The values of ``prop`` that ``node`` accepts where it is."""
84
+ current = getattr(node, prop)
85
+ accepted = []
86
+ for item in cast(Any, node.bl_rna).properties[prop].enum_items:
87
+ try:
88
+ setattr(node, prop, item.identifier)
89
+ except TypeError:
90
+ continue
91
+ accepted.append(item.identifier)
92
+ setattr(node, prop, current)
93
+ return accepted
94
+
95
+
96
+ def infer_data_type(
97
+ node_name: str,
98
+ values: Iterable[Any],
99
+ *,
100
+ keyword: str,
101
+ default: str = "FLOAT",
102
+ ) -> str:
103
+ """The data type for a node switching between ``values``.
104
+
105
+ Sockets and nodes decide it: each links from one socket, and they must all
106
+ agree. Plain Python values only decide when nothing is linked (``1`` and
107
+ ``2.5`` together make a float). Raises :class:`SocketError` when the linked
108
+ values disagree.
109
+ """
110
+ values = list(values)
111
+ sources = [s for s in (_source_socket(v) for v in values) if s is not None]
112
+
113
+ if not sources:
114
+ literals = {t for t in map(_literal_type, values) if t is not None}
115
+ if literals <= {"INT", "FLOAT"} and len(literals) == 2:
116
+ literals = {"FLOAT"}
117
+ return literals.pop() if len(literals) == 1 else default
118
+
119
+ found: dict[str, list[str]] = {}
120
+ for socket in sources:
121
+ data_type = _DATA_TYPE.get(socket.type, socket.type)
122
+ found.setdefault(data_type, []).append(_describe(socket))
123
+ if len(found) > 1:
124
+ listing = "; ".join(
125
+ f"{data_type} from {', '.join(names)}" for data_type, names in found.items()
126
+ )
127
+ raise SocketError(
128
+ f"Can't tell which type of {node_name} to build: the values are "
129
+ f"{listing}. Use a typed variant (e.g. "
130
+ f"{node_name.replace(' ', '')}.{_variant(next(iter(found)))}(...)) "
131
+ f"or pass {keyword}=."
132
+ )
133
+ (data_type,) = found
134
+ return data_type
135
+
136
+
137
+ def _variant(data_type: str) -> str:
138
+ """The class-method name for a data type (``RGBA`` is ``color``)."""
139
+ return {"RGBA": "color", "INT": "integer"}.get(data_type, data_type.lower())