nodebpy 520.18.0__tar.gz → 520.19.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.
- {nodebpy-520.18.0 → nodebpy-520.19.0}/PKG-INFO +3 -1
- {nodebpy-520.18.0 → nodebpy-520.19.0}/pyproject.toml +6 -1
- nodebpy-520.19.0/src/nodebpy/__init__.py +26 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/__init__.py +5 -2
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/__main__.py +9 -5
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/_library.py +336 -55
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/__init__.py +14 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/_utils.py +36 -0
- nodebpy-520.18.0/src/nodebpy/builder/arrange.py → nodebpy-520.19.0/src/nodebpy/builder/layout.py +267 -33
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/tree.py +325 -42
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/__init__.py +2 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/codegen.py +91 -25
- nodebpy-520.19.0/src/nodebpy/export/plot.py +225 -0
- nodebpy-520.19.0/src/nodebpy/lib/nodearrange/VENDORED.md +79 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +38 -18
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +10 -8
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +35 -27
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +11 -11
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +45 -16
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +113 -33
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +17 -10
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +30 -25
- nodebpy-520.19.0/src/nodebpy/lib/nodearrange/config.py +57 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/utils.py +22 -20
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/manual.py +2 -1
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/manual.py +2 -1
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/manual.py +3 -2
- nodebpy-520.18.0/src/nodebpy/__init__.py +0 -14
- nodebpy-520.18.0/src/nodebpy/lib/nodearrange/config.py +0 -45
- {nodebpy-520.18.0 → nodebpy-520.19.0}/README.md +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/_codegen.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/_registry.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/accessor.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/asset.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/items.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/mixins.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/node.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/socket.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/diagram.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/parity.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/web_render.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/__init__.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/_mixins.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/color.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/group.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/input.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/output.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/color.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/group.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/input.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/output.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/assets.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/color.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/converter.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/grid.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/group.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/input.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/output.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/script.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/shader.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/texture.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/vector.py +0 -0
- {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: nodebpy
|
|
3
|
-
Version: 520.
|
|
3
|
+
Version: 520.19.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>
|
|
@@ -8,10 +8,12 @@ License: GPL-3.0-or-later
|
|
|
8
8
|
Requires-Dist: bpy==5.2.* ; extra == 'bpy'
|
|
9
9
|
Requires-Dist: ruff>=0.14.11 ; extra == 'format'
|
|
10
10
|
Requires-Dist: networkx>=3.6.1 ; extra == 'networkx'
|
|
11
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'plot'
|
|
11
12
|
Requires-Python: >=3.13
|
|
12
13
|
Provides-Extra: bpy
|
|
13
14
|
Provides-Extra: format
|
|
14
15
|
Provides-Extra: networkx
|
|
16
|
+
Provides-Extra: plot
|
|
15
17
|
Description-Content-Type: text/markdown
|
|
16
18
|
|
|
17
19
|
# nodebpy
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nodebpy"
|
|
3
|
-
version = "520.
|
|
3
|
+
version = "520.19.0"
|
|
4
4
|
description = "Build nodes trees in Blender more elegantly with code"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -16,6 +16,9 @@ nodebpy = "nodebpy:main"
|
|
|
16
16
|
networkx = [
|
|
17
17
|
"networkx>=3.6.1",
|
|
18
18
|
]
|
|
19
|
+
plot = [
|
|
20
|
+
"matplotlib>=3.8",
|
|
21
|
+
]
|
|
19
22
|
bpy = [
|
|
20
23
|
"bpy==5.2.*",
|
|
21
24
|
]
|
|
@@ -46,6 +49,7 @@ dev = [
|
|
|
46
49
|
"griffe<2.0.0",
|
|
47
50
|
"ty==0.0.74",
|
|
48
51
|
"types-networkx>=3.6.1.20260728",
|
|
52
|
+
"matplotlib>=3.11.2",
|
|
49
53
|
]
|
|
50
54
|
|
|
51
55
|
# The bpy stubs mark most collections/pointers as `| None` and cannot type
|
|
@@ -67,3 +71,4 @@ ignore = ["SIM117", "RUF059"]
|
|
|
67
71
|
[tool.ruff.lint.per-file-ignores]
|
|
68
72
|
"tests/test_codegen.py" = ["S102"]
|
|
69
73
|
"tests/test_assets.py" = ["S102"]
|
|
74
|
+
"tests/test_layout_snapshot.py" = ["S102"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from . import builder, export, nodes, types
|
|
2
|
+
from .builder import (
|
|
3
|
+
ArrangeMethod,
|
|
4
|
+
SimpleOptions,
|
|
5
|
+
SugiyamaOptions,
|
|
6
|
+
TreeBuilder,
|
|
7
|
+
arrange,
|
|
8
|
+
default_sugiyama_options,
|
|
9
|
+
)
|
|
10
|
+
from .nodes import compositor, geometry, shader
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ArrangeMethod",
|
|
14
|
+
"SimpleOptions",
|
|
15
|
+
"SugiyamaOptions",
|
|
16
|
+
"TreeBuilder",
|
|
17
|
+
"arrange",
|
|
18
|
+
"builder",
|
|
19
|
+
"compositor",
|
|
20
|
+
"default_sugiyama_options",
|
|
21
|
+
"export",
|
|
22
|
+
"geometry",
|
|
23
|
+
"nodes",
|
|
24
|
+
"shader",
|
|
25
|
+
"types",
|
|
26
|
+
]
|
|
@@ -10,12 +10,14 @@ compositor}.assets`` and are re-exported alongside the built-in nodes.
|
|
|
10
10
|
``dump_library`` and ``build_library`` round-trip a ``.blend`` asset library
|
|
11
11
|
through Python source: every asset is dumped to its own ``.py`` file (the
|
|
12
12
|
version-controlled source of truth) and the ``.blend`` is rebuilt from them
|
|
13
|
-
(``python -m nodebpy.assets dump/build``).
|
|
13
|
+
(``python -m nodebpy.assets dump/build``). ``plot_library`` renders node
|
|
14
|
+
groups from a ``.blend`` to PNG images by name or wildcard
|
|
15
|
+
(``python -m nodebpy.assets plot``), for review outside Blender.
|
|
14
16
|
"""
|
|
15
17
|
|
|
16
18
|
from ..builder import AssetLibrary, BundledLibrary, PackageLibrary
|
|
17
19
|
from ._codegen import generate_asset_api, generate_asset_modules
|
|
18
|
-
from ._library import build_library, dump_library
|
|
20
|
+
from ._library import build_library, dump_library, plot_library
|
|
19
21
|
|
|
20
22
|
__all__ = [
|
|
21
23
|
"AssetLibrary",
|
|
@@ -25,4 +27,5 @@ __all__ = [
|
|
|
25
27
|
"dump_library",
|
|
26
28
|
"generate_asset_api",
|
|
27
29
|
"generate_asset_modules",
|
|
30
|
+
"plot_library",
|
|
28
31
|
]
|
|
@@ -77,9 +77,12 @@ def parse_args() -> argparse.Namespace: # pragma: no cover - CLI wrapper
|
|
|
77
77
|
".blend to per-asset .py source files\n"
|
|
78
78
|
" build <source-dir> <blend> rebuild the .blend asset library "
|
|
79
79
|
"from dumped .py source files\n"
|
|
80
|
+
" plot <blend> <output-dir> [names ...]\n"
|
|
81
|
+
" render node groups (wildcards "
|
|
82
|
+
"supported) to PNG images\n"
|
|
80
83
|
"\n"
|
|
81
|
-
"See 'python -m nodebpy.assets dump --help'
|
|
82
|
-
"'
|
|
84
|
+
"See 'python -m nodebpy.assets dump --help', '… build --help' "
|
|
85
|
+
"and '… plot --help' for their options."
|
|
83
86
|
),
|
|
84
87
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
85
88
|
)
|
|
@@ -124,9 +127,10 @@ def parse_args() -> argparse.Namespace: # pragma: no cover - CLI wrapper
|
|
|
124
127
|
|
|
125
128
|
|
|
126
129
|
def main() -> None: # pragma: no cover - CLI wrapper
|
|
127
|
-
# The dump/build subcommands (blend ↔ .py round-trip
|
|
128
|
-
# parser; everything else keeps the original flag-based
|
|
129
|
-
|
|
130
|
+
# The dump/build/plot subcommands (blend ↔ .py round-trip, PNG renders)
|
|
131
|
+
# have their own parser; everything else keeps the original flag-based
|
|
132
|
+
# interface.
|
|
133
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("dump", "build", "plot"):
|
|
130
134
|
from ._library import main as library_main
|
|
131
135
|
|
|
132
136
|
library_main(sys.argv[1:])
|
|
@@ -56,6 +56,7 @@ rebuilding reuses same-named trees (which would bake a stale group into the
|
|
|
56
56
|
|
|
57
57
|
from __future__ import annotations
|
|
58
58
|
|
|
59
|
+
import fnmatch
|
|
59
60
|
import importlib
|
|
60
61
|
import importlib.machinery
|
|
61
62
|
import importlib.util
|
|
@@ -64,11 +65,20 @@ import re
|
|
|
64
65
|
import shutil
|
|
65
66
|
import sys
|
|
66
67
|
import uuid
|
|
68
|
+
from collections.abc import Iterable
|
|
69
|
+
from contextlib import nullcontext
|
|
70
|
+
from dataclasses import replace
|
|
67
71
|
from pathlib import Path
|
|
68
72
|
|
|
69
73
|
import bpy
|
|
70
74
|
|
|
71
|
-
from ..builder import
|
|
75
|
+
from ..builder import (
|
|
76
|
+
NodeGroupBuilder,
|
|
77
|
+
SugiyamaOptions,
|
|
78
|
+
TreeBuilder,
|
|
79
|
+
build_from_source,
|
|
80
|
+
default_sugiyama_options,
|
|
81
|
+
)
|
|
72
82
|
from ..builder._utils import normalize_name
|
|
73
83
|
from ..export.codegen import (
|
|
74
84
|
_ID_COLLECTIONS,
|
|
@@ -968,6 +978,8 @@ def build_library(
|
|
|
968
978
|
allow_existing: bool = False,
|
|
969
979
|
resources: str | Path | None = None,
|
|
970
980
|
on_missing: str = "error",
|
|
981
|
+
add_reroutes: bool = False,
|
|
982
|
+
arrange: SugiyamaOptions | None = None,
|
|
971
983
|
) -> list[str]:
|
|
972
984
|
"""Rebuild a ``.blend`` asset library from sources written by
|
|
973
985
|
:func:`dump_library`.
|
|
@@ -991,6 +1003,17 @@ def build_library(
|
|
|
991
1003
|
socket defaults empty. The default ``on_missing="error"`` raises upfront,
|
|
992
1004
|
listing everything missing.
|
|
993
1005
|
|
|
1006
|
+
``arrange`` tunes how the built trees are laid out: a
|
|
1007
|
+
:class:`~nodebpy.SugiyamaOptions` with any of its settings (spacing,
|
|
1008
|
+
crossing-reduction iterations, direction, socket alignment, ...) is
|
|
1009
|
+
scoped over the build via
|
|
1010
|
+
:func:`nodebpy.builder.default_sugiyama_options`. ``add_reroutes=True``
|
|
1011
|
+
additionally inserts reroute nodes to route long links around nodes
|
|
1012
|
+
(the node-arrange addon's behaviour); it composes with ``arrange``.
|
|
1013
|
+
Either only affects modules that leave the arrangement at its default —
|
|
1014
|
+
sources dumped with ``snapshot_positions`` disable arrangement and keep
|
|
1015
|
+
their authored layout.
|
|
1016
|
+
|
|
994
1017
|
The built trees stay in the current session afterwards. Because
|
|
995
1018
|
``create_group()`` reuses an existing tree of the same name (that is what
|
|
996
1019
|
deduplicates groups shared between asset files), the session must not
|
|
@@ -1077,61 +1100,75 @@ def build_library(
|
|
|
1077
1100
|
on_missing,
|
|
1078
1101
|
)
|
|
1079
1102
|
|
|
1080
|
-
#
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
)
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
#
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
isinstance(asset_cls, type) and issubclass(asset_cls, NodeGroupBuilder)
|
|
1106
|
-
):
|
|
1107
|
-
raise TypeError(f"{file}: ASSET is not a node-group class: {asset_cls!r}")
|
|
1108
|
-
# Merged typed-API classes are Asset*Groups that would *append* their
|
|
1109
|
-
# own .blend; building from source is the whole point here.
|
|
1110
|
-
with build_from_source():
|
|
1111
|
-
tree = asset_cls.create_group()
|
|
1112
|
-
if tree.asset_data is None:
|
|
1113
|
-
tree.asset_mark()
|
|
1114
|
-
asset_data = tree.asset_data
|
|
1115
|
-
assert asset_data is not None
|
|
1116
|
-
metadata = getattr(module, "ASSET_METADATA", {})
|
|
1117
|
-
assert isinstance(metadata, dict)
|
|
1118
|
-
for field in _METADATA_FIELDS:
|
|
1119
|
-
if field in metadata:
|
|
1103
|
+
# A dumped module's recipe leaves TreeBuilder at its default arrangement,
|
|
1104
|
+
# which resolves through this scoped default (snapshot-positions modules
|
|
1105
|
+
# disable arrangement and are unaffected).
|
|
1106
|
+
options = arrange
|
|
1107
|
+
if add_reroutes:
|
|
1108
|
+
options = replace(options or SugiyamaOptions(), add_reroutes=True)
|
|
1109
|
+
arrange_override = (
|
|
1110
|
+
default_sugiyama_options(options) if options is not None else nullcontext()
|
|
1111
|
+
)
|
|
1112
|
+
with arrange_override:
|
|
1113
|
+
# Materials first: asset trees look them up by name while building.
|
|
1114
|
+
built_materials = []
|
|
1115
|
+
for file, module in material_modules:
|
|
1116
|
+
material_cls = module.MATERIAL # ty: ignore[unresolved-attribute]
|
|
1117
|
+
if not (
|
|
1118
|
+
isinstance(material_cls, type)
|
|
1119
|
+
and issubclass(material_cls, NodeGroupBuilder)
|
|
1120
|
+
):
|
|
1121
|
+
raise TypeError(f"{file}: MATERIAL is not a node-group class")
|
|
1122
|
+
name = getattr(module, "MATERIAL_NAME", None) or material_cls._name
|
|
1123
|
+
# The material recipe may instantiate typed-API asset classes, which
|
|
1124
|
+
# must build from their _build_group source, not append.
|
|
1125
|
+
with build_from_source():
|
|
1126
|
+
material = _build_material(material_cls, name)
|
|
1127
|
+
for key, value in getattr(module, "MATERIAL_PROPERTIES", {}).items():
|
|
1120
1128
|
try:
|
|
1121
|
-
setattr(
|
|
1122
|
-
except AttributeError:
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
for
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1129
|
+
setattr(material, key, value)
|
|
1130
|
+
except AttributeError:
|
|
1131
|
+
print(f" {name}: skipping read-only material property {key!r}")
|
|
1132
|
+
built_materials.append(material)
|
|
1133
|
+
|
|
1134
|
+
trees = []
|
|
1135
|
+
for file, module in asset_modules:
|
|
1136
|
+
asset_cls = module.ASSET # ty: ignore[unresolved-attribute]
|
|
1137
|
+
if not (
|
|
1138
|
+
isinstance(asset_cls, type) and issubclass(asset_cls, NodeGroupBuilder)
|
|
1139
|
+
):
|
|
1140
|
+
raise TypeError(
|
|
1141
|
+
f"{file}: ASSET is not a node-group class: {asset_cls!r}"
|
|
1142
|
+
)
|
|
1143
|
+
# Merged typed-API classes are Asset*Groups that would *append* their
|
|
1144
|
+
# own .blend; building from source is the whole point here.
|
|
1145
|
+
with build_from_source():
|
|
1146
|
+
tree = asset_cls.create_group()
|
|
1147
|
+
if tree.asset_data is None:
|
|
1148
|
+
tree.asset_mark()
|
|
1149
|
+
asset_data = tree.asset_data
|
|
1150
|
+
assert asset_data is not None
|
|
1151
|
+
metadata = getattr(module, "ASSET_METADATA", {})
|
|
1152
|
+
assert isinstance(metadata, dict)
|
|
1153
|
+
for field in _METADATA_FIELDS:
|
|
1154
|
+
if field in metadata:
|
|
1155
|
+
try:
|
|
1156
|
+
setattr(asset_data, field, metadata[field])
|
|
1157
|
+
except AttributeError: # pragma: no cover - other Blender versions
|
|
1158
|
+
# Read-only in this Blender version (as catalog_simple_name
|
|
1159
|
+
# was) — the value is derived, not lost; keep building.
|
|
1160
|
+
print(
|
|
1161
|
+
f" {tree.name}: skipping read-only asset field {field!r}"
|
|
1162
|
+
)
|
|
1163
|
+
existing_tags = {tag.name for tag in asset_data.tags}
|
|
1164
|
+
for tag in metadata.get("tags", ()):
|
|
1165
|
+
if tag not in existing_tags:
|
|
1166
|
+
asset_data.tags.new(tag)
|
|
1167
|
+
properties = getattr(module, "TREE_PROPERTIES", {})
|
|
1168
|
+
assert isinstance(properties, dict)
|
|
1169
|
+
for key, value in properties.items():
|
|
1170
|
+
setattr(tree, key, value)
|
|
1171
|
+
trees.append(tree)
|
|
1135
1172
|
|
|
1136
1173
|
# Placeholders satisfied the lookups during the build; deleting them nulls
|
|
1137
1174
|
# the referencing socket defaults, which is exactly what "drop" means.
|
|
@@ -1149,6 +1186,187 @@ def build_library(
|
|
|
1149
1186
|
return [tree.name for tree in trees]
|
|
1150
1187
|
|
|
1151
1188
|
|
|
1189
|
+
def plot_library(
|
|
1190
|
+
blend_path: str | Path,
|
|
1191
|
+
output_dir: str | Path,
|
|
1192
|
+
names: Iterable[str] | None = None,
|
|
1193
|
+
*,
|
|
1194
|
+
dpi: int = 150,
|
|
1195
|
+
arrange: SugiyamaOptions | None = None,
|
|
1196
|
+
) -> dict[str, Path]:
|
|
1197
|
+
"""Render node groups from ``blend_path`` to PNG images under
|
|
1198
|
+
``output_dir`` — a headless look at node graphs, e.g. for posting in
|
|
1199
|
+
pull requests (``python -m nodebpy.assets plot``).
|
|
1200
|
+
|
|
1201
|
+
``names`` selects the groups to plot: exact names or :mod:`fnmatch`
|
|
1202
|
+
wildcard patterns (``"Style *"``), matched against *every* node group in
|
|
1203
|
+
the ``.blend`` (not just assets); ``None`` plots them all. A pattern
|
|
1204
|
+
matching nothing raises. Each tree is drawn with
|
|
1205
|
+
:func:`nodebpy.export.to_plot` (which needs the optional ``matplotlib``
|
|
1206
|
+
dependency) at its stored layout, or re-arranged first when ``arrange``
|
|
1207
|
+
options are given. Everything appended for plotting is removed from the
|
|
1208
|
+
session again afterwards.
|
|
1209
|
+
|
|
1210
|
+
Returns
|
|
1211
|
+
-------
|
|
1212
|
+
dict[str, Path]
|
|
1213
|
+
Mapping of group name to the image it was written to.
|
|
1214
|
+
"""
|
|
1215
|
+
blend_path = Path(blend_path)
|
|
1216
|
+
output_dir = Path(output_dir)
|
|
1217
|
+
if not blend_path.is_file():
|
|
1218
|
+
raise FileNotFoundError(f"Asset library not found: {blend_path.resolve()}")
|
|
1219
|
+
|
|
1220
|
+
from ..builder.layout import arrange as arrange_tree_nodes
|
|
1221
|
+
from ..export import to_plot
|
|
1222
|
+
|
|
1223
|
+
patterns = list(names) if names is not None else None
|
|
1224
|
+
before = {
|
|
1225
|
+
coll: set(getattr(bpy.data, coll).keys()) for coll in _CLEANUP_COLLECTIONS
|
|
1226
|
+
}
|
|
1227
|
+
with bpy.data.libraries.load( # ty: ignore[invalid-context-manager]
|
|
1228
|
+
str(blend_path), link=False
|
|
1229
|
+
) as (src, dst):
|
|
1230
|
+
available = list(src.node_groups)
|
|
1231
|
+
if patterns is None:
|
|
1232
|
+
wanted = available
|
|
1233
|
+
else:
|
|
1234
|
+
wanted = [
|
|
1235
|
+
n for n in available if any(fnmatch.fnmatchcase(n, p) for p in patterns)
|
|
1236
|
+
]
|
|
1237
|
+
unmatched = [
|
|
1238
|
+
p
|
|
1239
|
+
for p in patterns
|
|
1240
|
+
if not any(fnmatch.fnmatchcase(n, p) for n in available)
|
|
1241
|
+
]
|
|
1242
|
+
if unmatched:
|
|
1243
|
+
raise KeyError(f"No node groups in {blend_path} match: {unmatched}")
|
|
1244
|
+
clashes = sorted(n for n in wanted if n in bpy.data.node_groups)
|
|
1245
|
+
if clashes:
|
|
1246
|
+
raise RuntimeError(
|
|
1247
|
+
f"Node groups already exist in this session: {clashes}. "
|
|
1248
|
+
"Appending would rename them — plot from a fresh session "
|
|
1249
|
+
"(e.g. python -m nodebpy.assets plot)."
|
|
1250
|
+
)
|
|
1251
|
+
dst.node_groups = list(wanted)
|
|
1252
|
+
added = {
|
|
1253
|
+
coll: [db for db in getattr(bpy.data, coll) if db.name not in before[coll]]
|
|
1254
|
+
for coll in _CLEANUP_COLLECTIONS
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
try:
|
|
1258
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1259
|
+
written: dict[str, Path] = {}
|
|
1260
|
+
for tree in dst.node_groups:
|
|
1261
|
+
assert tree is not None
|
|
1262
|
+
if arrange is not None:
|
|
1263
|
+
arrange_tree_nodes(tree, arrange)
|
|
1264
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", tree.name).lstrip(".") or "tree"
|
|
1265
|
+
written[tree.name] = to_plot(tree, output_dir / f"{slug}.png", dpi=dpi)
|
|
1266
|
+
finally:
|
|
1267
|
+
for coll in _CLEANUP_COLLECTIONS:
|
|
1268
|
+
data = getattr(bpy.data, coll)
|
|
1269
|
+
for db in added[coll]:
|
|
1270
|
+
data.remove(db)
|
|
1271
|
+
return written
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _add_arrangement_flags(parser, description: str) -> None: # pragma: no cover
|
|
1275
|
+
"""Attach the shared arrangement flag group (one per SugiyamaOptions
|
|
1276
|
+
field, plus --add-reroutes) to a CLI subparser."""
|
|
1277
|
+
layout = parser.add_argument_group("arrangement", description=description)
|
|
1278
|
+
layout.add_argument(
|
|
1279
|
+
"--add-reroutes",
|
|
1280
|
+
action="store_true",
|
|
1281
|
+
help=(
|
|
1282
|
+
"Arrange with reroute nodes inserted to route long links around "
|
|
1283
|
+
"nodes (the node-arrange addon's behaviour)."
|
|
1284
|
+
),
|
|
1285
|
+
)
|
|
1286
|
+
layout.add_argument(
|
|
1287
|
+
"--spacing",
|
|
1288
|
+
nargs=2,
|
|
1289
|
+
type=float,
|
|
1290
|
+
metavar=("X", "Y"),
|
|
1291
|
+
help="Horizontal and vertical space between nodes (default: 30 30).",
|
|
1292
|
+
)
|
|
1293
|
+
layout.add_argument(
|
|
1294
|
+
"--iterations",
|
|
1295
|
+
type=int,
|
|
1296
|
+
help=(
|
|
1297
|
+
"Number of iterations spent reducing crossings between links "
|
|
1298
|
+
"(higher gives fewer crossings, but is slower; default: 50)."
|
|
1299
|
+
),
|
|
1300
|
+
)
|
|
1301
|
+
layout.add_argument(
|
|
1302
|
+
"--direction",
|
|
1303
|
+
type=str.upper,
|
|
1304
|
+
choices=["LEFT_DOWN", "RIGHT_DOWN", "LEFT_UP", "RIGHT_UP", "BALANCED"],
|
|
1305
|
+
help=(
|
|
1306
|
+
"Direction of layout — which corner nodes align towards, or "
|
|
1307
|
+
"'balanced' to even out the four extremes (default: right_up)."
|
|
1308
|
+
),
|
|
1309
|
+
)
|
|
1310
|
+
layout.add_argument(
|
|
1311
|
+
"--socket-alignment",
|
|
1312
|
+
type=str.upper,
|
|
1313
|
+
choices=["NONE", "MODERATE", "FULL"],
|
|
1314
|
+
help=(
|
|
1315
|
+
"How aggressively links are straightened by aligning the sockets "
|
|
1316
|
+
"they connect (default: none)."
|
|
1317
|
+
),
|
|
1318
|
+
)
|
|
1319
|
+
layout.add_argument(
|
|
1320
|
+
"--keep-reroutes-outside-frames",
|
|
1321
|
+
action="store_true",
|
|
1322
|
+
help="Do not place added reroutes inside frames.",
|
|
1323
|
+
)
|
|
1324
|
+
layout.add_argument(
|
|
1325
|
+
"--no-stack-collapsed",
|
|
1326
|
+
dest="stack_collapsed",
|
|
1327
|
+
action="store_false",
|
|
1328
|
+
help="Do not stack consecutive collapsed nodes tightly.",
|
|
1329
|
+
)
|
|
1330
|
+
layout.add_argument(
|
|
1331
|
+
"--stack-margin-y-fac",
|
|
1332
|
+
type=float,
|
|
1333
|
+
help=(
|
|
1334
|
+
"Fraction of the vertical spacing used between stacked collapsed "
|
|
1335
|
+
"nodes (default: 0.5)."
|
|
1336
|
+
),
|
|
1337
|
+
)
|
|
1338
|
+
layout.add_argument(
|
|
1339
|
+
"--optimize-sizes",
|
|
1340
|
+
action="store_true",
|
|
1341
|
+
help="Fit the widths of collapsed nodes to their display name.",
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _arrange_options_from_args(args) -> SugiyamaOptions | None:
|
|
1346
|
+
"""The build CLI's arrangement override: a :class:`SugiyamaOptions` with
|
|
1347
|
+
every provided layout flag applied, or None when all are left unset (so
|
|
1348
|
+
the build runs with the plain defaults, overridable further in-process).
|
|
1349
|
+
``--add-reroutes`` is composed separately by :func:`build_library`."""
|
|
1350
|
+
overrides: dict = {}
|
|
1351
|
+
if args.spacing is not None:
|
|
1352
|
+
overrides["margin"] = tuple(args.spacing)
|
|
1353
|
+
if args.iterations is not None:
|
|
1354
|
+
overrides["iterations"] = args.iterations
|
|
1355
|
+
if args.direction is not None:
|
|
1356
|
+
overrides["direction"] = args.direction
|
|
1357
|
+
if args.socket_alignment is not None:
|
|
1358
|
+
overrides["socket_alignment"] = args.socket_alignment
|
|
1359
|
+
if args.keep_reroutes_outside_frames:
|
|
1360
|
+
overrides["keep_reroutes_outside_frames"] = True
|
|
1361
|
+
if not args.stack_collapsed:
|
|
1362
|
+
overrides["stack_collapsed"] = False
|
|
1363
|
+
if args.stack_margin_y_fac is not None:
|
|
1364
|
+
overrides["stack_margin_y_fac"] = args.stack_margin_y_fac
|
|
1365
|
+
if args.optimize_sizes:
|
|
1366
|
+
overrides["optimize_sizes"] = True
|
|
1367
|
+
return SugiyamaOptions(**overrides) if overrides else None
|
|
1368
|
+
|
|
1369
|
+
|
|
1152
1370
|
def main(argv: list[str] | None = None) -> None: # pragma: no cover - CLI wrapper
|
|
1153
1371
|
"""CLI entry point for the ``dump`` and ``build`` subcommands."""
|
|
1154
1372
|
import argparse
|
|
@@ -1262,6 +1480,12 @@ def main(argv: list[str] | None = None) -> None: # pragma: no cover - CLI wrapp
|
|
|
1262
1480
|
"objects, …) from, by name, before building."
|
|
1263
1481
|
),
|
|
1264
1482
|
)
|
|
1483
|
+
_add_arrangement_flags(
|
|
1484
|
+
build,
|
|
1485
|
+
"Tune the automatic layout of the built trees (the SugiyamaOptions "
|
|
1486
|
+
"defaults apply where unset). Sources dumped with "
|
|
1487
|
+
"--snapshot-positions keep their authored layout regardless.",
|
|
1488
|
+
)
|
|
1265
1489
|
build.add_argument(
|
|
1266
1490
|
"--drop-missing",
|
|
1267
1491
|
dest="on_missing",
|
|
@@ -1276,6 +1500,44 @@ def main(argv: list[str] | None = None) -> None: # pragma: no cover - CLI wrapp
|
|
|
1276
1500
|
),
|
|
1277
1501
|
)
|
|
1278
1502
|
|
|
1503
|
+
plot = sub.add_parser(
|
|
1504
|
+
"plot",
|
|
1505
|
+
help="Render node groups from a .blend to PNG images.",
|
|
1506
|
+
description=(
|
|
1507
|
+
"Render node groups from a .blend to PNG images — a headless "
|
|
1508
|
+
"look at node graphs, e.g. for posting in pull requests. Selects "
|
|
1509
|
+
"groups by exact name or fnmatch wildcard ('Style *'), matched "
|
|
1510
|
+
"against every node group in the file (not just assets); with no "
|
|
1511
|
+
"names, every group is plotted. Trees are drawn at their stored "
|
|
1512
|
+
"layout unless --arrange (or any arrangement flag) is given. "
|
|
1513
|
+
"Requires matplotlib (pip install nodebpy[plot])."
|
|
1514
|
+
),
|
|
1515
|
+
)
|
|
1516
|
+
plot.add_argument("blend", type=Path, help="The .blend holding the node groups.")
|
|
1517
|
+
plot.add_argument("output", type=Path, help="Directory to write the .png files to.")
|
|
1518
|
+
plot.add_argument(
|
|
1519
|
+
"names",
|
|
1520
|
+
nargs="*",
|
|
1521
|
+
help=(
|
|
1522
|
+
"Group names to plot; fnmatch wildcards supported ('Style *', "
|
|
1523
|
+
"quoted to keep the shell from expanding them). Default: all."
|
|
1524
|
+
),
|
|
1525
|
+
)
|
|
1526
|
+
plot.add_argument("--dpi", type=int, default=150, help="Image DPI (default: 150).")
|
|
1527
|
+
plot.add_argument(
|
|
1528
|
+
"--arrange",
|
|
1529
|
+
action="store_true",
|
|
1530
|
+
help=(
|
|
1531
|
+
"Re-arrange each tree before plotting instead of drawing its "
|
|
1532
|
+
"stored layout (implied by any arrangement flag below)."
|
|
1533
|
+
),
|
|
1534
|
+
)
|
|
1535
|
+
_add_arrangement_flags(
|
|
1536
|
+
plot,
|
|
1537
|
+
"Tune the re-arrangement applied before plotting; passing any of "
|
|
1538
|
+
"these implies --arrange.",
|
|
1539
|
+
)
|
|
1540
|
+
|
|
1279
1541
|
args = parser.parse_args(argv)
|
|
1280
1542
|
if args.command == "dump":
|
|
1281
1543
|
written = dump_library(
|
|
@@ -1291,6 +1553,23 @@ def main(argv: list[str] | None = None) -> None: # pragma: no cover - CLI wrapp
|
|
|
1291
1553
|
for name, path in written.items():
|
|
1292
1554
|
print(f" {name}: {path}")
|
|
1293
1555
|
print(f"Dumped {len(written)} assets to {args.output}")
|
|
1556
|
+
elif args.command == "plot":
|
|
1557
|
+
options = _arrange_options_from_args(args)
|
|
1558
|
+
method: SugiyamaOptions | None = None
|
|
1559
|
+
if args.arrange or args.add_reroutes or options is not None:
|
|
1560
|
+
method = options or SugiyamaOptions()
|
|
1561
|
+
if args.add_reroutes:
|
|
1562
|
+
method = replace(method, add_reroutes=True)
|
|
1563
|
+
written = plot_library(
|
|
1564
|
+
args.blend,
|
|
1565
|
+
args.output,
|
|
1566
|
+
args.names or None,
|
|
1567
|
+
dpi=args.dpi,
|
|
1568
|
+
arrange=method,
|
|
1569
|
+
)
|
|
1570
|
+
for name, path in written.items():
|
|
1571
|
+
print(f" {name}: {path}")
|
|
1572
|
+
print(f"Plotted {len(written)} node groups to {args.output}")
|
|
1294
1573
|
else:
|
|
1295
1574
|
names = build_library(
|
|
1296
1575
|
args.source,
|
|
@@ -1299,6 +1578,8 @@ def main(argv: list[str] | None = None) -> None: # pragma: no cover - CLI wrapp
|
|
|
1299
1578
|
allow_existing=args.allow_existing,
|
|
1300
1579
|
resources=args.resources,
|
|
1301
1580
|
on_missing=args.on_missing,
|
|
1581
|
+
add_reroutes=args.add_reroutes,
|
|
1582
|
+
arrange=_arrange_options_from_args(args),
|
|
1302
1583
|
)
|
|
1303
1584
|
print(f"Built {args.blend} with {len(names)} assets: {', '.join(names)}")
|
|
1304
1585
|
|
|
@@ -11,6 +11,14 @@ from .asset import (
|
|
|
11
11
|
build_from_source,
|
|
12
12
|
)
|
|
13
13
|
from .items import Item, ItemsMixin, MenuItem
|
|
14
|
+
from .layout import (
|
|
15
|
+
ArrangeMethod,
|
|
16
|
+
SimpleOptions,
|
|
17
|
+
SugiyamaOptions,
|
|
18
|
+
arrange,
|
|
19
|
+
arrange_tree,
|
|
20
|
+
default_sugiyama_options,
|
|
21
|
+
)
|
|
14
22
|
from .mixins import LinkingMixin, OperatorMixin
|
|
15
23
|
from .node import (
|
|
16
24
|
BaseNode,
|
|
@@ -76,6 +84,7 @@ from .tree import (
|
|
|
76
84
|
)
|
|
77
85
|
|
|
78
86
|
__all__ = [
|
|
87
|
+
"ArrangeMethod",
|
|
79
88
|
"AssetCompositorGroup",
|
|
80
89
|
"AssetGeometryGroup",
|
|
81
90
|
"AssetLibrary",
|
|
@@ -133,6 +142,7 @@ __all__ = [
|
|
|
133
142
|
"RotationSocketList",
|
|
134
143
|
"ShaderSocket",
|
|
135
144
|
"ShaderSocketList",
|
|
145
|
+
"SimpleOptions",
|
|
136
146
|
"Socket",
|
|
137
147
|
"SocketAccessor",
|
|
138
148
|
"SocketContext",
|
|
@@ -141,12 +151,16 @@ __all__ = [
|
|
|
141
151
|
"SoundSocketList",
|
|
142
152
|
"StringSocket",
|
|
143
153
|
"StringSocketList",
|
|
154
|
+
"SugiyamaOptions",
|
|
144
155
|
"TreeBuilder",
|
|
145
156
|
"VectorSocket",
|
|
146
157
|
"VectorSocketGrid",
|
|
147
158
|
"VectorSocketList",
|
|
159
|
+
"arrange",
|
|
160
|
+
"arrange_tree",
|
|
148
161
|
"asset_group_base",
|
|
149
162
|
"build_from_source",
|
|
163
|
+
"default_sugiyama_options",
|
|
150
164
|
"denormalize_name",
|
|
151
165
|
"normalize_name",
|
|
152
166
|
]
|