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.
Files changed (87) hide show
  1. {nodebpy-520.18.0 → nodebpy-520.19.0}/PKG-INFO +3 -1
  2. {nodebpy-520.18.0 → nodebpy-520.19.0}/pyproject.toml +6 -1
  3. nodebpy-520.19.0/src/nodebpy/__init__.py +26 -0
  4. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/__init__.py +5 -2
  5. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/__main__.py +9 -5
  6. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/_library.py +336 -55
  7. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/__init__.py +14 -0
  8. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/_utils.py +36 -0
  9. nodebpy-520.18.0/src/nodebpy/builder/arrange.py → nodebpy-520.19.0/src/nodebpy/builder/layout.py +267 -33
  10. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/tree.py +325 -42
  11. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/__init__.py +2 -0
  12. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/codegen.py +91 -25
  13. nodebpy-520.19.0/src/nodebpy/export/plot.py +225 -0
  14. nodebpy-520.19.0/src/nodebpy/lib/nodearrange/VENDORED.md +79 -0
  15. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +38 -18
  16. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +10 -8
  17. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +35 -27
  18. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +11 -11
  19. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +45 -16
  20. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +113 -33
  21. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +17 -10
  22. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +30 -25
  23. nodebpy-520.19.0/src/nodebpy/lib/nodearrange/config.py +57 -0
  24. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/utils.py +22 -20
  25. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/manual.py +2 -1
  26. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/manual.py +2 -1
  27. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/manual.py +3 -2
  28. nodebpy-520.18.0/src/nodebpy/__init__.py +0 -14
  29. nodebpy-520.18.0/src/nodebpy/lib/nodearrange/config.py +0 -45
  30. {nodebpy-520.18.0 → nodebpy-520.19.0}/README.md +0 -0
  31. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/assets/_codegen.py +0 -0
  32. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/_registry.py +0 -0
  33. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/accessor.py +0 -0
  34. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/asset.py +0 -0
  35. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/items.py +0 -0
  36. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/mixins.py +0 -0
  37. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/node.py +0 -0
  38. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/builder/socket.py +0 -0
  39. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/diagram.py +0 -0
  40. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/parity.py +0 -0
  41. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/export/web_render.py +0 -0
  42. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  43. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  44. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
  45. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/__init__.py +0 -0
  46. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/_mixins.py +0 -0
  47. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  48. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
  49. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  50. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
  51. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  52. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  53. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  54. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  55. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
  56. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  57. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  58. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  59. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  60. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
  61. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
  62. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  63. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
  64. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
  65. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
  66. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  67. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  68. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/input.py +0 -0
  69. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
  70. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  71. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  72. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  73. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  74. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
  75. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
  76. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/assets.py +0 -0
  77. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/color.py +0 -0
  78. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/converter.py +0 -0
  79. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  80. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/group.py +0 -0
  81. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/input.py +0 -0
  82. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/output.py +0 -0
  83. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/script.py +0 -0
  84. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/shader.py +0 -0
  85. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  86. {nodebpy-520.18.0 → nodebpy-520.19.0}/src/nodebpy/nodes/shader/vector.py +0 -0
  87. {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.18.0
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.18.0"
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' and "
82
- "'python -m nodebpy.assets build --help' for their options."
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) have their own
128
- # parser; everything else keeps the original flag-based interface.
129
- if len(sys.argv) > 1 and sys.argv[1] in ("dump", "build"):
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 NodeGroupBuilder, TreeBuilder, build_from_source
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
- # Materials first: asset trees look them up by name while building.
1081
- built_materials = []
1082
- for file, module in material_modules:
1083
- material_cls = module.MATERIAL # ty: ignore[unresolved-attribute]
1084
- if not (
1085
- isinstance(material_cls, type)
1086
- and issubclass(material_cls, NodeGroupBuilder)
1087
- ):
1088
- raise TypeError(f"{file}: MATERIAL is not a node-group class")
1089
- name = getattr(module, "MATERIAL_NAME", None) or material_cls._name
1090
- # The material recipe may instantiate typed-API asset classes, which
1091
- # must build from their _build_group source, not append.
1092
- with build_from_source():
1093
- material = _build_material(material_cls, name)
1094
- for key, value in getattr(module, "MATERIAL_PROPERTIES", {}).items():
1095
- try:
1096
- setattr(material, key, value)
1097
- except AttributeError:
1098
- print(f" {name}: skipping read-only material property {key!r}")
1099
- built_materials.append(material)
1100
-
1101
- trees = []
1102
- for file, module in asset_modules:
1103
- asset_cls = module.ASSET # ty: ignore[unresolved-attribute]
1104
- if not (
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(asset_data, field, metadata[field])
1122
- except AttributeError: # pragma: no cover - other Blender versions
1123
- # Read-only in this Blender version (as catalog_simple_name
1124
- # was) — the value is derived, not lost; keep building.
1125
- print(f" {tree.name}: skipping read-only asset field {field!r}")
1126
- existing_tags = {tag.name for tag in asset_data.tags}
1127
- for tag in metadata.get("tags", ()):
1128
- if tag not in existing_tags:
1129
- asset_data.tags.new(tag)
1130
- properties = getattr(module, "TREE_PROPERTIES", {})
1131
- assert isinstance(properties, dict)
1132
- for key, value in properties.items():
1133
- setattr(tree, key, value)
1134
- trees.append(tree)
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
  ]