nodebpy 520.25.0__tar.gz → 520.27.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 (88) hide show
  1. {nodebpy-520.25.0 → nodebpy-520.27.0}/PKG-INFO +1 -1
  2. {nodebpy-520.25.0 → nodebpy-520.27.0}/pyproject.toml +1 -1
  3. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/__main__.py +4 -1
  4. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/_library.py +124 -33
  5. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/_pipeline.py +12 -0
  6. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/layout.py +29 -1
  7. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/plot.py +81 -32
  8. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/VENDORED.md +17 -0
  9. nodebpy-520.27.0/src/nodebpy/lib/nodearrange/arrange/balancing.py +182 -0
  10. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +66 -0
  11. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +7 -1
  12. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +4 -0
  13. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +15 -2
  14. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/config.py +17 -1
  15. {nodebpy-520.25.0 → nodebpy-520.27.0}/README.md +0 -0
  16. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/__init__.py +0 -0
  17. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/__init__.py +0 -0
  18. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/_codegen.py +0 -0
  19. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/__init__.py +0 -0
  20. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/_registry.py +0 -0
  21. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/_socket_order.py +0 -0
  22. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/_utils.py +0 -0
  23. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/accessor.py +0 -0
  24. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/asset.py +0 -0
  25. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/items.py +0 -0
  26. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/mixins.py +0 -0
  27. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/node.py +0 -0
  28. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/socket.py +0 -0
  29. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/tree.py +0 -0
  30. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/__init__.py +0 -0
  31. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/codegen.py +0 -0
  32. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/diagram.py +0 -0
  33. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/parity.py +0 -0
  34. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/web_render.py +0 -0
  35. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
  36. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
  37. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
  38. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
  39. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
  40. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
  41. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
  42. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
  43. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/__init__.py +0 -0
  44. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/_mixins.py +0 -0
  45. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
  46. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
  47. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/color.py +0 -0
  48. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
  49. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
  50. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
  51. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/group.py +0 -0
  52. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/input.py +0 -0
  53. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
  54. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
  55. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
  56. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/output.py +0 -0
  57. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
  58. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
  59. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
  60. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
  61. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/color.py +0 -0
  62. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
  63. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
  64. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
  65. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/group.py +0 -0
  66. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
  67. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/input.py +0 -0
  68. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
  69. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/manual.py +0 -0
  70. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/output.py +0 -0
  71. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
  72. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
  73. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
  74. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
  75. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
  76. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/assets.py +0 -0
  77. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/color.py +0 -0
  78. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/converter.py +0 -0
  79. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/grid.py +0 -0
  80. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/group.py +0 -0
  81. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/input.py +0 -0
  82. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/manual.py +0 -0
  83. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/output.py +0 -0
  84. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/script.py +0 -0
  85. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/shader.py +0 -0
  86. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/texture.py +0 -0
  87. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/vector.py +0 -0
  88. {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nodebpy
3
- Version: 520.25.0
3
+ Version: 520.27.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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodebpy"
3
- version = "520.25.0"
3
+ version = "520.27.0"
4
4
  description = "Build nodes trees in Blender more elegantly with code"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -90,8 +90,11 @@ def parse_args(
90
90
  ),
91
91
  epilog=(
92
92
  "subcommands:\n"
93
- " dump <blend> <output-dir> dump every node-group asset in a "
93
+ " dump <blend> <output-dir> [--names ...]\n"
94
+ " dump every node-group asset in a "
94
95
  ".blend to per-asset .py source files\n"
96
+ " (or only those matching --names, "
97
+ "wildcards supported)\n"
95
98
  " build <source-dir> <blend> rebuild the .blend asset library "
96
99
  "from dumped .py source files\n"
97
100
  " ensure <source-dir> <blend> rebuild only when the .blend or "
@@ -467,7 +467,7 @@ def dump_library(
467
467
  blend_path: str | Path,
468
468
  output_dir: str | Path,
469
469
  *,
470
- names: set[str] | None = None,
470
+ names: Iterable[str] | None = None,
471
471
  nodebpy_pkg: str = "nodebpy",
472
472
  snapshot_positions: bool = False,
473
473
  keep_reroutes: bool = False,
@@ -511,11 +511,20 @@ def dump_library(
511
511
  output_dir:
512
512
  Directory to write the per-asset modules into (created if needed).
513
513
  names:
514
- Restrict the dump to these asset (node-group or material) names;
515
- defaults to all. A full dump first clears the managed subdirectories
516
- (``geometry``/``shader``/``compositor``/``materials``) so files from
517
- renamed or deleted assets don't linger; a filtered dump leaves the
518
- other assets' files in place.
514
+ Restrict the dump to the assets (node groups or materials) matching
515
+ these exact names or :mod:`fnmatch` wildcard patterns (``"Style *"``);
516
+ defaults to all. A pattern matching nothing raises. Meant for quick
517
+ iteration on a few assets: only the selected assets' modules are
518
+ regenerated, plus the ``_shared`` helper and ``materials/`` modules
519
+ they depend on — byte-identical to what a full dump writes for them,
520
+ because the whole library is still appended to decide what is shared
521
+ (a helper also used by an unselected asset stays in ``_shared``;
522
+ an unselected nested asset stays imported from its own module).
523
+ Edits to unselected assets are not picked up; a full dump (or the
524
+ ``check`` subcommand) catches those. A full dump first clears the
525
+ managed subdirectories (``geometry``/``shader``/``compositor``/
526
+ ``materials``) so files from renamed or deleted assets don't linger;
527
+ a filtered dump leaves the other assets' files in place.
519
528
  nodebpy_pkg:
520
529
  Import anchor for nodebpy in the generated sources, as for
521
530
  :func:`nodebpy.export.to_python`.
@@ -572,13 +581,18 @@ def dump_library(
572
581
  else (output_dir, blend_path)
573
582
  )
574
583
 
575
- # Append every wanted asset in one load, so groups shared between assets
576
- # (including assets nested in other assets) arrive as single trees and the
577
- # sharing structure can be read off the session directly. Dependencies of
578
- # other kinds (materials, images, …) come along too; everything appended
579
- # is cleaned back out afterwards. The pre-append checks run inside the
580
- # load context (the load itself happens on exit, with nothing selected
581
- # when a check raises), so the .blend is opened only once.
584
+ # Append every asset in one load — even for a filtered dump — so groups
585
+ # shared between assets (including assets nested in other assets) arrive
586
+ # as single trees and the sharing structure can be read off the session
587
+ # directly: whether a helper is embedded or goes to _shared/ depends on
588
+ # how many assets reach it, and an asset outside the selection counts
589
+ # just the same. Appending is cheap next to code generation, which only
590
+ # runs for the selected roots. Dependencies of other kinds (materials,
591
+ # images, …) come along too; everything appended is cleaned back out
592
+ # afterwards. The pre-append checks run inside the load context (the
593
+ # load itself happens on exit, with nothing selected when a check
594
+ # raises), so the .blend is opened only once.
595
+ patterns = list(names) if names is not None else None
582
596
  before = {
583
597
  coll: set(getattr(bpy.data, coll).keys()) for coll in _CLEANUP_COLLECTIONS
584
598
  }
@@ -589,25 +603,28 @@ def dump_library(
589
603
  # assets_only exposes exactly the asset-marked materials: they are
590
604
  # dump roots alongside the node-group assets.
591
605
  available_materials = list(src.materials)
592
- wanted = [n for n in available if names is None or n in names]
593
- wanted_materials = [
594
- n for n in available_materials if names is None or n in names
595
- ]
596
- if names is not None and (
597
- missing := names - set(wanted) - set(wanted_materials)
598
- ):
599
- raise KeyError(f"Assets not found in {blend_path}: {sorted(missing)}")
600
- clashes = sorted(n for n in wanted if n in bpy.data.node_groups)
606
+ selected: set[str] | None = None
607
+ if patterns is not None:
608
+ roots = available + available_materials
609
+ selected = {
610
+ n for n in roots if any(fnmatch.fnmatchcase(n, p) for p in patterns)
611
+ }
612
+ unmatched = [
613
+ p for p in patterns if not any(fnmatch.fnmatchcase(n, p) for n in roots)
614
+ ]
615
+ if unmatched:
616
+ raise KeyError(f"No assets in {blend_path} match: {unmatched}")
617
+ clashes = sorted(n for n in available if n in bpy.data.node_groups)
601
618
  if clashes:
602
619
  raise RuntimeError(
603
620
  f"Node groups already exist in this session: {clashes}. "
604
621
  "Appending would rename them and corrupt the dumped sources — "
605
622
  "dump from a fresh session (e.g. python -m nodebpy.assets dump)."
606
623
  )
607
- dst.node_groups = list(wanted)
624
+ dst.node_groups = list(available)
608
625
  # A same-named material already in the session renames the appended
609
626
  # one — caught by the post-append renamed-datablock guard below.
610
- dst.materials = list(wanted_materials)
627
+ dst.materials = list(available_materials)
611
628
  added = {
612
629
  coll: [db for db in getattr(bpy.data, coll) if db.name not in before[coll]]
613
630
  for coll in _CLEANUP_COLLECTIONS
@@ -641,11 +658,12 @@ def dump_library(
641
658
  format=format,
642
659
  library_blend=anchor_blend.resolve() if typed_api else None,
643
660
  anchor_dir=anchor_dir.resolve(),
661
+ roots=selected,
644
662
  # A full dump owns the managed subdirectories: clear stale modules
645
663
  # from assets since renamed or deleted, so the next build_library
646
664
  # doesn't silently resurrect them. A filtered dump (names=...)
647
665
  # leaves the other assets' files alone.
648
- clean_stale=names is None,
666
+ clean_stale=selected is None,
649
667
  )
650
668
  finally:
651
669
  for coll in _CLEANUP_COLLECTIONS:
@@ -699,6 +717,7 @@ def _dump_appended(
699
717
  format: bool,
700
718
  library_blend: Path | None = None,
701
719
  anchor_dir: Path | None = None,
720
+ roots: set[str] | None = None,
702
721
  clean_stale: bool = False,
703
722
  ) -> dict[str, Path]:
704
723
  """Partition the appended groups (and material roots and referenced
@@ -713,7 +732,11 @@ def _dump_appended(
713
732
  typed API — see ``dump_library(typed_api=...)`` — and ``anchor_dir``
714
733
  (default: ``output_dir``) is the directory the ``PackageLibrary``
715
734
  relative paths are computed against — see
716
- ``dump_library(library_anchor=...)``. ``clean_stale`` removes
735
+ ``dump_library(library_anchor=...)``. ``roots`` restricts the modules
736
+ written to those assets (node-group or material names) plus the shared
737
+ helpers and materials they depend on — the partition itself is always
738
+ computed over every appended asset, so a filtered dump writes exactly
739
+ what a full one would for those modules. ``clean_stale`` removes
717
740
  modules under the managed subdirectories that this dump did not write
718
741
  (hand-written ``__init__.py`` files are kept, as ever).
719
742
  """
@@ -820,6 +843,24 @@ def _dump_appended(
820
843
  # and group calls to any of them use the typed parameter names.
821
844
  typed_groups = external if library_blend is not None else set()
822
845
 
846
+ # The modules this dump writes. A filtered dump writes the selected roots
847
+ # plus the modules they depend on that no other asset owns: the _shared
848
+ # helpers in their closures, the materials their footers record and those
849
+ # materials' shared helpers. Unselected assets — nested in a selected one
850
+ # or not — keep their own modules untouched and are imported as usual.
851
+ if roots is None:
852
+ write_assets = list(asset_trees)
853
+ write_materials = set(material_trees)
854
+ else:
855
+ write_assets = [t for t in asset_trees if t.name in roots]
856
+ write_materials = {k for k, m in material_trees.items() if m in roots}
857
+ for tree in write_assets:
858
+ for mat_name in module_dependencies(tree.name).get("materials", ()):
859
+ if (key := f"material:{mat_name}") in material_trees:
860
+ write_materials.add(key)
861
+ write_roots = {t.name for t in write_assets} | write_materials
862
+ write_shared = shared & set().union(*(closures[r] for r in write_roots))
863
+
823
864
  def write_module(key: str, module: str, *, kind: str) -> Path:
824
865
  group = trees[key]
825
866
  # Emitted here: the tree itself plus, for roots, its private helpers.
@@ -876,13 +917,13 @@ def _dump_appended(
876
917
  return path
877
918
 
878
919
  written: dict[str, Path] = {}
879
- for tree in asset_trees:
920
+ for tree in write_assets:
880
921
  written[tree.name] = write_module(tree.name, modules[tree.name], kind="asset")
881
922
  all_written = set(written.values())
882
- for name in sorted(shared):
923
+ for name in sorted(write_shared):
883
924
  all_written.add(write_module(name, modules[name], kind="shared"))
884
925
  asset_material_names = set(asset_materials)
885
- for key in sorted(material_trees):
926
+ for key in sorted(write_materials):
886
927
  path = write_module(key, modules[key], kind="material")
887
928
  all_written.add(path)
888
929
  # Material roots are assets the caller asked for, so they belong in
@@ -895,12 +936,14 @@ def _dump_appended(
895
936
  if library_blend is not None:
896
937
  # Typed API: each tree directory re-exports its asset classes, so
897
938
  # ``from <pkg>.<tree_dir> import <Class>`` (or an aliased module
898
- # import) works like the old single-file API.
939
+ # import) works like the old single-file API. A filtered dump only
940
+ # exports modules that exist on disk (a full dump wrote them all).
899
941
  for dirname in set(_TREE_DIRS.values()):
900
942
  exports = sorted(
901
943
  (modules[t.name].split("/")[1], class_names[t.name])
902
944
  for t in asset_trees
903
945
  if modules[t.name].startswith(f"{dirname}/")
946
+ and (output_dir / f"{modules[t.name]}.py").is_file()
904
947
  )
905
948
  if exports:
906
949
  _write_dir_exports(output_dir / dirname, exports)
@@ -1406,7 +1449,7 @@ def _add_arrangement_flags(parser, description: str) -> None: # pragma: no cove
1406
1449
  choices=["LEFT_DOWN", "RIGHT_DOWN", "LEFT_UP", "RIGHT_UP", "BALANCED"],
1407
1450
  help=(
1408
1451
  "Direction of layout — which corner nodes align towards, or "
1409
- "'balanced' to even out the four extremes (default: right_up)."
1452
+ "'balanced' to even out the four extremes (default: balanced)."
1410
1453
  ),
1411
1454
  )
1412
1455
  layout.add_argument(
@@ -1442,6 +1485,38 @@ def _add_arrangement_flags(parser, description: str) -> None: # pragma: no cove
1442
1485
  action="store_true",
1443
1486
  help="Fit the widths of collapsed nodes to their display name.",
1444
1487
  )
1488
+ layout.add_argument(
1489
+ "--no-sequential-frames",
1490
+ dest="sequential_frames",
1491
+ action="store_false",
1492
+ help=(
1493
+ "Do not rank frames as stages of the flow (by default every node "
1494
+ "of a frame comes after every node of the frame feeding it, so "
1495
+ "frames line up left to right instead of stacking)."
1496
+ ),
1497
+ )
1498
+ layout.add_argument(
1499
+ "--no-balance-heights",
1500
+ dest="balance_heights",
1501
+ action="store_false",
1502
+ help=(
1503
+ "Do not promote feeder chains into emptier columns to shorten the "
1504
+ "tallest column."
1505
+ ),
1506
+ )
1507
+ layout.add_argument(
1508
+ "--balance-aspect",
1509
+ type=float,
1510
+ help=("Width-to-height ratio the height balancing aims for (default: 1.6)."),
1511
+ )
1512
+ layout.add_argument(
1513
+ "--reroute-margin-y-fac",
1514
+ type=float,
1515
+ help=(
1516
+ "Fraction of the vertical spacing kept between consecutive "
1517
+ "reroutes in a column (default: 0.35)."
1518
+ ),
1519
+ )
1445
1520
 
1446
1521
 
1447
1522
  def _arrange_options_from_args(args) -> SugiyamaOptions | None:
@@ -1466,6 +1541,14 @@ def _arrange_options_from_args(args) -> SugiyamaOptions | None:
1466
1541
  overrides["stack_margin_y_fac"] = args.stack_margin_y_fac
1467
1542
  if args.optimize_sizes:
1468
1543
  overrides["optimize_sizes"] = True
1544
+ if not args.sequential_frames:
1545
+ overrides["sequential_frames"] = False
1546
+ if not args.balance_heights:
1547
+ overrides["balance_heights"] = False
1548
+ if args.balance_aspect is not None:
1549
+ overrides["balance_aspect"] = args.balance_aspect
1550
+ if args.reroute_margin_y_fac is not None:
1551
+ overrides["reroute_margin_y_fac"] = args.reroute_margin_y_fac
1469
1552
  return SugiyamaOptions(**overrides) if overrides else None
1470
1553
 
1471
1554
 
@@ -1634,7 +1717,15 @@ def _parse_args(argv: list[str] | None = None):
1634
1717
  dump.add_argument(
1635
1718
  "--names",
1636
1719
  nargs="+",
1637
- help="Only dump these asset (node-group or material) names (default: all).",
1720
+ metavar="NAME",
1721
+ help=(
1722
+ "Only dump the assets (node groups or materials) matching these "
1723
+ "names; fnmatch wildcards supported ('Style *', quoted to keep the "
1724
+ "shell from expanding them). Default: all. The _shared helpers "
1725
+ "and materials the selection depends on are written too, and the "
1726
+ "other assets' files are left untouched — for quick iteration on "
1727
+ "a few assets; a full dump (or 'check') catches edits elsewhere."
1728
+ ),
1638
1729
  )
1639
1730
  _add_dump_flags(dump)
1640
1731
  dump.add_argument(
@@ -1816,7 +1907,7 @@ def _dump_command(args) -> None:
1816
1907
  written = dump_library(
1817
1908
  args.blend,
1818
1909
  args.output,
1819
- names=set(args.names) if args.names else None,
1910
+ names=args.names or None,
1820
1911
  nodebpy_pkg=args.nodebpy_pkg,
1821
1912
  snapshot_positions=args.snapshot_positions,
1822
1913
  keep_reroutes=args.keep_reroutes,
@@ -97,6 +97,10 @@ _CONFIG_KEYS: dict[str, tuple[str | None, str]] = {
97
97
  "no-stack-collapsed": ("stack_collapsed", "off"),
98
98
  "stack-margin-y-fac": ("stack_margin_y_fac", "value"),
99
99
  "optimize-sizes": ("optimize_sizes", "flag"),
100
+ "no-sequential-frames": ("sequential_frames", "off"),
101
+ "no-balance-heights": ("balance_heights", "off"),
102
+ "balance-aspect": ("balance_aspect", "value"),
103
+ "reroute-margin-y-fac": ("reroute_margin_y_fac", "value"),
100
104
  }
101
105
 
102
106
  # Which dest each positional config key fills, per subcommand: the dump
@@ -132,6 +136,10 @@ _OPTION_DEFAULTS: dict[str, object] = {
132
136
  "stack_collapsed": True,
133
137
  "stack_margin_y_fac": None,
134
138
  "optimize_sizes": False,
139
+ "sequential_frames": True,
140
+ "balance_heights": True,
141
+ "balance_aspect": None,
142
+ "reroute_margin_y_fac": None,
135
143
  }
136
144
 
137
145
  # The resolved options the stamp fingerprint covers: everything, beyond the
@@ -140,6 +148,8 @@ _OPTION_DEFAULTS: dict[str, object] = {
140
148
  # it gates the session check, not the output.
141
149
  _STAMP_DESTS = (
142
150
  "add_reroutes",
151
+ "balance_aspect",
152
+ "balance_heights",
143
153
  "compress",
144
154
  "direction",
145
155
  "iterations",
@@ -149,6 +159,8 @@ _STAMP_DESTS = (
149
159
  "nodebpy_pkg",
150
160
  "on_missing",
151
161
  "optimize_sizes",
162
+ "reroute_margin_y_fac",
163
+ "sequential_frames",
152
164
  "snapshot_positions",
153
165
  "socket_alignment",
154
166
  "spacing",
@@ -871,13 +871,33 @@ class SugiyamaOptions:
871
871
  Fit the widths of collapsed nodes to their display name.
872
872
  iterations : int
873
873
  Number of crossing-minimization iterations.
874
+ sequential_frames : bool
875
+ Rank frames as stages of the flow: every node of a frame comes
876
+ after every node of the frame (or intermediate node) feeding it, so
877
+ successive frames line up left to right instead of stacking into a
878
+ staircase. Frames with no links between them (parallel branches)
879
+ still share columns and stack vertically.
880
+ balance_heights : bool
881
+ Shorten the tallest column by moving nodes whose feeders serve only
882
+ them (a private upstream chain) one column left, while that makes
883
+ the drawing smaller overall. Counters the tall sliver a node with
884
+ many inputs otherwise produces, at the price of slightly longer
885
+ links routed through reroutes / dummy nodes.
886
+ balance_aspect : float
887
+ Width-to-height ratio the balancing aims for: it keeps promoting
888
+ feeders left while the drawing's bounding box (height, or width
889
+ divided by this ratio, whichever is larger) shrinks.
890
+ reroute_margin_y_fac : float
891
+ Fraction of the vertical margin kept between consecutive reroutes
892
+ (and the dummy nodes long links are routed through) in a column;
893
+ bundles of long links pack tighter than nodes.
874
894
  """
875
895
 
876
896
  # Defaults calibrated against hand-approved node-arrange addon output
877
897
  # ("30" x/y spacing, no socket alignment, top-right node alignment).
878
898
  margin: tuple[float, float] = (30.0, 30.0)
879
899
  direction: Literal["LEFT_DOWN", "RIGHT_DOWN", "BALANCED", "LEFT_UP", "RIGHT_UP"] = (
880
- "RIGHT_UP"
900
+ "BALANCED"
881
901
  )
882
902
  socket_alignment: Literal["NONE", "MODERATE", "FULL"] = "NONE"
883
903
  add_reroutes: bool = False
@@ -886,6 +906,10 @@ class SugiyamaOptions:
886
906
  stack_margin_y_fac: float = 0.5
887
907
  optimize_sizes: bool = False
888
908
  iterations: int = 50
909
+ sequential_frames: bool = True
910
+ balance_heights: bool = True
911
+ balance_aspect: float = 1.6
912
+ reroute_margin_y_fac: float = 0.35
889
913
 
890
914
 
891
915
  type ArrangeMethod = (
@@ -959,6 +983,10 @@ def _arrange_sugiyama(tree: bpy.types.NodeTree, options: SugiyamaOptions) -> Non
959
983
  stack_collapsed=options.stack_collapsed,
960
984
  optimize_sizes=options.optimize_sizes,
961
985
  stack_margin_y_fac=options.stack_margin_y_fac,
986
+ sequential_frames=options.sequential_frames,
987
+ balance_heights=options.balance_heights,
988
+ balance_aspect=options.balance_aspect,
989
+ reroute_margin_y_fac=options.reroute_margin_y_fac,
962
990
  )
963
991
  sugiyama.sugiyama_layout(tree, settings=settings, margin=Vector(options.margin))
964
992
 
@@ -1069,31 +1069,81 @@ def _draw_frames(cv: _Canvas, tree: bpy.types.NodeTree) -> None:
1069
1069
  def _zone_members(
1070
1070
  tree: bpy.types.NodeTree, start: bpy.types.Node, end: bpy.types.Node
1071
1071
  ) -> set[bpy.types.Node]:
1072
- """Nodes on a path from *start* to *end*: forward-reachable from the zone
1073
- input and backward-reachable from the zone output."""
1072
+ """The nodes Blender draws inside a zone: the zone's input and output
1073
+ nodes and every node fed, directly or through other nodes, from the
1074
+ zone input. A node that only feeds into the zone from outside (a group
1075
+ input, a constant, a value computed elsewhere) stays outside."""
1074
1076
  forward: dict[bpy.types.Node, set[bpy.types.Node]] = {}
1075
- backward: dict[bpy.types.Node, set[bpy.types.Node]] = {}
1076
1077
  for link in tree.links:
1077
1078
  if link.from_node is None or link.to_node is None:
1078
1079
  continue
1079
1080
  forward.setdefault(link.from_node, set()).add(link.to_node)
1080
- backward.setdefault(link.to_node, set()).add(link.from_node)
1081
1081
 
1082
- def reach(root: bpy.types.Node, graph: dict) -> set[bpy.types.Node]:
1083
- seen = {root}
1084
- queue = deque([root])
1085
- while queue:
1086
- node = queue.popleft()
1087
- for nxt in graph.get(node, ()):
1088
- if nxt not in seen:
1089
- seen.add(nxt)
1090
- queue.append(nxt)
1091
- return seen
1092
-
1093
- return (reach(start, forward) & reach(end, backward)) | {start, end}
1082
+ seen = {start, end}
1083
+ queue = deque([start])
1084
+ while queue:
1085
+ node = queue.popleft()
1086
+ # bpy hands out a fresh wrapper per access, so compare by value.
1087
+ if node == end:
1088
+ continue
1089
+ for nxt in forward.get(node, ()):
1090
+ if nxt not in seen:
1091
+ seen.add(nxt)
1092
+ queue.append(nxt)
1093
+ return seen
1094
+
1095
+
1096
+ def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
1097
+ """Counter-clockwise convex hull (monotone chain)."""
1098
+ pts = sorted(set(points))
1099
+ if len(pts) <= 2:
1100
+ return pts
1101
+
1102
+ def cross(o: tuple[float, float], a: tuple[float, float], b: tuple[float, float]):
1103
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
1104
+
1105
+ lower: list[tuple[float, float]] = []
1106
+ for pt in pts:
1107
+ while len(lower) >= 2 and cross(lower[-2], lower[-1], pt) <= 0:
1108
+ lower.pop()
1109
+ lower.append(pt)
1110
+ upper: list[tuple[float, float]] = []
1111
+ for pt in reversed(pts):
1112
+ while len(upper) >= 2 and cross(upper[-2], upper[-1], pt) <= 0:
1113
+ upper.pop()
1114
+ upper.append(pt)
1115
+ return lower[:-1] + upper[:-1]
1116
+
1117
+
1118
+ def _rounded_offset(
1119
+ hull: list[tuple[float, float]], radius: float, steps: int = 6
1120
+ ) -> list[tuple[float, float]]:
1121
+ """Outline of a convex polygon grown by *radius*: its edges pushed
1122
+ outwards, its corners rounded (the Minkowski sum with a disc)."""
1123
+ n = len(hull)
1124
+ if n == 1:
1125
+ cx, cy = hull[0]
1126
+ return [
1127
+ (cx + radius * math.cos(a), cy + radius * math.sin(a))
1128
+ for a in (2 * math.pi * k / (4 * steps) for k in range(4 * steps))
1129
+ ]
1130
+ outline: list[tuple[float, float]] = []
1131
+ for i, (x, y) in enumerate(hull):
1132
+ (px, py), (nx, ny) = hull[i - 1], hull[(i + 1) % n]
1133
+ # Outward normals of the incoming and outgoing edges (hull is CCW).
1134
+ a_in = math.atan2(-(x - px), (y - py))
1135
+ a_out = math.atan2(-(nx - x), (ny - y))
1136
+ while a_out < a_in:
1137
+ a_out += 2 * math.pi
1138
+ for k in range(steps + 1):
1139
+ a = a_in + (a_out - a_in) * k / steps
1140
+ outline.append((x + radius * math.cos(a), y + radius * math.sin(a)))
1141
+ return outline
1094
1142
 
1095
1143
 
1096
1144
  def _draw_zones(cv: _Canvas, tree: bpy.types.NodeTree) -> None:
1145
+ from matplotlib.patches import Polygon
1146
+
1097
1147
  for node in tree.nodes:
1098
1148
  attr = _ZONE_THEME_ATTR.get(node.bl_idname)
1099
1149
  if attr is None:
@@ -1101,24 +1151,23 @@ def _draw_zones(cv: _Canvas, tree: bpy.types.NodeTree) -> None:
1101
1151
  paired = getattr(node, "paired_output", None)
1102
1152
  if paired is None:
1103
1153
  continue
1104
- members = _zone_members(tree, node, paired)
1105
- bounds = [_node_bounds(n) for n in members]
1106
- x0 = min(b[0] for b in bounds) - _ZONE_PADDING
1107
- y0 = min(b[1] for b in bounds) - _ZONE_PADDING
1108
- x1 = max(b[2] for b in bounds) + _ZONE_PADDING
1109
- y1 = max(b[3] for b in bounds) + _ZONE_PADDING
1154
+ corners: list[tuple[float, float]] = []
1155
+ for member in _zone_members(tree, node, paired):
1156
+ x0, y0, x1, y1 = _node_bounds(member)
1157
+ corners += [(x0, y0), (x0, y1), (x1, y0), (x1, y1)]
1158
+ outline = _rounded_offset(_convex_hull(corners), _ZONE_PADDING)
1110
1159
  color = cv.theme.zones[attr]
1111
1160
  border = (color[0], color[1], color[2], 0.9)
1112
- cv.rect(
1113
- x0,
1114
- y0,
1115
- x1 - x0,
1116
- y1 - y0,
1117
- face=color,
1118
- edge=border,
1119
- radius=10.0,
1120
- lw=1.0,
1121
- z=1.5,
1161
+ cv.ax.add_patch(
1162
+ Polygon(
1163
+ outline,
1164
+ closed=True,
1165
+ facecolor=color,
1166
+ edgecolor=border,
1167
+ lw=1.0,
1168
+ joinstyle="round",
1169
+ zorder=1.5,
1170
+ )
1122
1171
  )
1123
1172
 
1124
1173
 
@@ -44,6 +44,23 @@ break headless operation.
44
44
  working set is `list(ntree.nodes)` and the select gates are membership /
45
45
  always-true checks. Upstream patches touching `.select` need the same
46
46
  translation.
47
+ - **Layout readability additions** (nodebpy-only, each behind a `Settings`
48
+ flag mirrored on `SugiyamaOptions`):
49
+ - `ranking.add_frame_sequence_edges()` (`sequential_frames`): ranks
50
+ frames as stages of the flow by constraining every node of a frame to
51
+ come after every node of the frame (or intermediate node) feeding it, so
52
+ successive frames line up left to right instead of stacking into a
53
+ staircase.
54
+ - `balancing.balance_column_heights()` (`balance_heights`,
55
+ `balance_aspect`): after ranking, promotes nodes of the tallest columns
56
+ together with their upstream into emptier columns while the drawing
57
+ gets closer to a screen-shaped box.
58
+ - `y_coords.vertical_gap()` (`reroute_margin_y_fac`): consecutive
59
+ reroutes / dummy nodes in a column pack at a fraction of the margin.
60
+ - `sugiyama.precompute_links()` keeps `is_hidden` links (links into a
61
+ collapsed panel's sockets), which still order the nodes; and
62
+ `x_coords.assign_x_coords()` skips a column left empty by dissolved
63
+ dummies.
47
64
  - `structs.py` uses explicit `_fields_` lists (upstream builds them from
48
65
  annotations, formerly via `eval`) and additionally binds `bNode` /
49
66
  `bNodeRuntime` / `rctf`, which upstream does not have.
@@ -0,0 +1,182 @@
1
+ # SPDX-License-Identifier: GPL-2.0-or-later
2
+ """Height-balanced ranking (nodebpy addition, not in upstream node-arrange).
3
+
4
+ Network-simplex ranking minimises total edge length, so every feeder sits in
5
+ the column right before its consumer. A node with many inputs — a big group
6
+ node taking a dozen values, switches and math results — therefore gets all
7
+ of its feeders stacked in one column, which grows far taller than any other
8
+ column while the columns further left stay nearly empty: the tree becomes a
9
+ tall sliver with dead space everywhere else.
10
+
11
+ :func:`balance_column_heights` post-processes the ranks: while the tallest
12
+ column can be made shorter without making the drawing larger overall, it
13
+ moves a node of that column one column to the left together with
14
+ everything upstream of it, which is always feasible (the moved set has no
15
+ predecessor outside itself). Links from the moved set to the nodes left
16
+ behind become one column longer and are routed through dummy nodes /
17
+ reroutes, which the height estimate charges for.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections import defaultdict
23
+ from collections.abc import Iterable
24
+
25
+ import networkx as nx
26
+
27
+ from ..config import LayoutState
28
+ from ..utils import REROUTE_DIM
29
+ from .graph import FROM_SOCKET, Cluster, Kind, Node, Socket
30
+
31
+ _MAX_MOVES = 500
32
+
33
+
34
+ def _upstream(G: nx.DiGraph[Node], v: Node) -> set[Node]:
35
+ """Every node *v* depends on. Moving *v* together with its upstream one
36
+ column to the left is always feasible: the set has no predecessor
37
+ outside itself, and links from it to nodes left behind only get
38
+ longer."""
39
+ return nx.ancestors(G, v)
40
+
41
+
42
+ def _column_heights(
43
+ G: nx.DiGraph[Node], ranks: dict[Node, int], state: LayoutState
44
+ ) -> dict[int, float]:
45
+ """Estimated drawn height per rank: real nodes plus the dummy nodes of
46
+ the long edges passing through, each separated by the vertical margin.
47
+ Long edges leaving the same socket merge into one dummy chain."""
48
+ margin = state.margin.y
49
+ heights: defaultdict[int, float] = defaultdict(float)
50
+ counts: defaultdict[int, int] = defaultdict(int)
51
+ for v, r in ranks.items():
52
+ heights[r] += v.height
53
+ counts[r] += 1
54
+ crossings: defaultdict[int, set[Socket]] = defaultdict(set)
55
+ for u, w, from_socket in G.edges(data=FROM_SOCKET):
56
+ for r in range(ranks[u] + 1, ranks[w]):
57
+ crossings[r].add(from_socket)
58
+ dummy_gap = margin * state.settings.reroute_margin_y_fac
59
+ for r, sources in crossings.items():
60
+ # Dummy chains pack at the reduced reroute gap (see y_coords).
61
+ heights[r] += len(sources) * (REROUTE_DIM.y + dummy_gap)
62
+ return {r: heights[r] + margin * max(counts[r] - 1, 0) for r in heights}
63
+
64
+
65
+ def _column_widths(ranks: dict[Node, int]) -> dict[int, float]:
66
+ widths: defaultdict[int, float] = defaultdict(float)
67
+ for v, r in ranks.items():
68
+ widths[r] = max(widths[r], v.width)
69
+ return widths
70
+
71
+
72
+ def _frame_sequence_ok(
73
+ ranks: dict[Node, int],
74
+ sequence: Iterable[tuple[frozenset[Node], frozenset[Node]]],
75
+ ) -> bool:
76
+ """Whether every recorded frame-sequence constraint (all of unit *a*
77
+ before all of unit *b*) still holds under *ranks*."""
78
+ for before, after in sequence:
79
+ if max(ranks[v] for v in before) >= min(ranks[v] for v in after):
80
+ return False
81
+ return True
82
+
83
+
84
+ def _overshoot(heights: dict[int, float], target: float) -> float:
85
+ return sum(max(0.0, h - target) for h in heights.values())
86
+
87
+
88
+ def _fit_to_height(
89
+ G: nx.DiGraph[Node],
90
+ ranks: dict[Node, int],
91
+ target: float,
92
+ state: LayoutState,
93
+ upstream_of: dict[Node, set[Node]],
94
+ ) -> dict[Node, int] | None:
95
+ """Greedily move nodes (with their upstream) left until no column is
96
+ taller than *target*; None when the greedy gets stuck first."""
97
+ ranks = dict(ranks)
98
+ sequence = state.frame_sequence
99
+ for _ in range(_MAX_MOVES):
100
+ heights = _column_heights(G, ranks, state)
101
+ current = _overshoot(heights, target)
102
+ if current <= 0:
103
+ return ranks
104
+ best: tuple[float, dict[Node, int]] | None = None
105
+ for v, upstream in upstream_of.items():
106
+ if not upstream or heights.get(ranks[v], 0.0) <= target:
107
+ continue
108
+ trial = dict(ranks)
109
+ for w in upstream | {v}:
110
+ trial[w] -= 1
111
+ if not _frame_sequence_ok(trial, sequence):
112
+ continue
113
+ shoot = _overshoot(_column_heights(G, trial, state), target)
114
+ if shoot < current and (best is None or shoot < best[0]):
115
+ best = (shoot, trial)
116
+ if best is None:
117
+ return None
118
+ ranks = best[1]
119
+ return None
120
+
121
+
122
+ _TARGET_STEP = 0.9
123
+
124
+
125
+ def balance_column_heights(
126
+ G: nx.DiGraph[Node],
127
+ clusters: Iterable[Cluster],
128
+ state: LayoutState,
129
+ ) -> None:
130
+ """Shorten the tallest columns by promoting feeder chains left.
131
+
132
+ Lowers a target height step by step from the current tallest column and
133
+ for each target greedily moves nodes of over-tall columns — together
134
+ with their upstream — one column to the left until every column
135
+ fits (or gives up). Of the layouts found, the one needing the smallest
136
+ screen-shaped box (``balance_aspect`` wide for every unit of height) is
137
+ kept, so height is traded for width only while the drawing gets closer
138
+ to that shape; the descent stops once fitting fails or the box starts
139
+ growing again. Frame-sequence constraints are kept throughout.
140
+ """
141
+ nodes = [v for v in G if v.type != Kind.HORIZONTAL_BORDER]
142
+ if not nodes:
143
+ return
144
+ ranks = {v: v.rank for v in nodes}
145
+ margin_x = state.margin.x
146
+ upstream_of = {v: _upstream(G, v) for v in nodes}
147
+
148
+ aspect = state.settings.balance_aspect
149
+
150
+ def area(r: dict[Node, int]) -> float:
151
+ """Size of the drawing as the side of the screen-shaped box (of the
152
+ target aspect ratio) it needs: the taller of its height and its
153
+ width scaled down by the aspect ratio."""
154
+ heights = _column_heights(G, r, state)
155
+ widths = _column_widths(r)
156
+ width = sum(widths.values()) + margin_x * max(len(widths) - 1, 0)
157
+ return max(max(heights.values()), width / aspect)
158
+
159
+ best_ranks = ranks
160
+ best_area = area(ranks)
161
+ target = max(_column_heights(G, ranks, state).values()) * _TARGET_STEP
162
+ floor = max(v.height for v in nodes)
163
+ while target >= floor:
164
+ fitted = _fit_to_height(G, best_ranks, target, state, upstream_of)
165
+ if fitted is None:
166
+ break
167
+ fitted_area = area(fitted)
168
+ if fitted_area > best_area:
169
+ break
170
+ best_ranks, best_area = fitted, fitted_area
171
+ target = max(_column_heights(G, fitted, state).values()) * _TARGET_STEP
172
+
173
+ offset = min(best_ranks.values())
174
+ for v in nodes:
175
+ v.rank = best_ranks[v] - offset
176
+ # Frame borders follow their members (recomputed by insert_dummy_nodes);
177
+ # keep them consistent for anything reading them before that.
178
+ for c in clusters:
179
+ members = [v for v in nodes if v.cluster is c]
180
+ if members:
181
+ c.left.rank = min(v.rank for v in members) - 1
182
+ c.right.rank = max(v.rank for v in members) + 1
@@ -27,9 +27,75 @@ def get_nesting_graph(CG: ClusterGraph) -> nx.MultiDiGraph[Node]:
27
27
  else:
28
28
  H.add_edges_from(((u.left, v.left), (v.right, u.right)))
29
29
 
30
+ if CG.state.settings.sequential_frames:
31
+ add_frame_sequence_edges(CG, H)
32
+
30
33
  return H
31
34
 
32
35
 
36
+ def _top_level_unit(v: Node, root: Cluster) -> Node | Cluster:
37
+ """The outermost frame (a child of *root*) containing *v*, or *v* itself
38
+ when it sits directly in the root."""
39
+ unit: Node | Cluster = v
40
+ c = v.cluster
41
+ while c is not None and c is not root:
42
+ unit = c
43
+ c = c.cluster
44
+ return unit
45
+
46
+
47
+ def add_frame_sequence_edges(CG: ClusterGraph, H: nx.MultiDiGraph[Node]) -> None:
48
+ """Rank frames as stages of the flow (nodebpy divergence).
49
+
50
+ With plain nesting constraints a frame only has to enclose its own
51
+ members, so a downstream frame's first nodes are ranked right next to the
52
+ upstream frame's last ones and the two frames share columns — which
53
+ forces them to be stacked vertically, producing a staircase of frames
54
+ instead of a left-to-right flow. This adds, for every link between two
55
+ top-level units (a frame, or a node outside every frame), a constraint
56
+ from the source unit's right border to the target unit's left border:
57
+ every node of a frame comes after every node of the frame (or the
58
+ intermediate node) feeding it. Nodes without predecessors (inputs and
59
+ values serving one consumer) and without successors are exempt so they
60
+ stay next to their consumer / producer; units on a cycle of the quotient
61
+ graph are left to the plain nesting constraints.
62
+ """
63
+ G = CG.G
64
+ root = next(c for c in CG.S if not CG.T.pred[c])
65
+ unit_of = {v: _top_level_unit(v, root) for v in G}
66
+
67
+ Q: nx.DiGraph[Node | Cluster] = nx.DiGraph()
68
+ Q.add_nodes_from(set(unit_of.values()))
69
+ for u, v in G.edges():
70
+ a, b = unit_of[u], unit_of[v]
71
+ if a is not b:
72
+ Q.add_edge(a, b)
73
+
74
+ scc_of = {
75
+ n: i for i, comp in enumerate(nx.strongly_connected_components(Q)) for n in comp
76
+ }
77
+ for a, b in Q.edges():
78
+ if scc_of[a] == scc_of[b]:
79
+ continue
80
+ a_is_frame = isinstance(a, Cluster)
81
+ b_is_frame = isinstance(b, Cluster)
82
+ if not a_is_frame and not b_is_frame:
83
+ continue
84
+ if not a_is_frame and not G.pred[a]:
85
+ continue
86
+ if not b_is_frame and not G.succ[b]:
87
+ continue
88
+ tail = a.right if isinstance(a, Cluster) else a
89
+ head = b.left if isinstance(b, Cluster) else b
90
+ H.add_edge(tail, head)
91
+ CG.state.frame_sequence.append(
92
+ (
93
+ frozenset(v for v in G if unit_of[v] is a),
94
+ frozenset(v for v in G if unit_of[v] is b),
95
+ )
96
+ )
97
+
98
+
33
99
  @cache
34
100
  def get_adj_edges_H(H: nx.MultiDiGraph[Node], v: Node) -> tuple[MultiEdge, ...]:
35
101
  return (*H.in_edges(v, keys=True), *H.out_edges(v, keys=True))
@@ -14,6 +14,7 @@ from mathutils import Vector
14
14
 
15
15
  from ..config import LayoutState, Settings
16
16
  from ..utils import abs_loc, group_by
17
+ from .balancing import balance_column_heights
17
18
  from .graph import (
18
19
  FROM_SOCKET,
19
20
  TO_SOCKET,
@@ -102,8 +103,11 @@ def optimize_sizes(nodes: Iterable[BlenderNode]) -> None:
102
103
  def precompute_links(state: LayoutState) -> None:
103
104
  # Precompute links to ignore invalid/hidden links, and avoid `O(len(ntree.links))` time
104
105
 
106
+ # Headless divergence: links into a collapsed panel's sockets report
107
+ # ``is_hidden`` (Blender draws them to the panel header); they still
108
+ # carry data, so they still order the nodes.
105
109
  for link in state.ntree.links:
106
- if not link.is_hidden and link.is_valid:
110
+ if link.is_valid:
107
111
  assert link.from_socket
108
112
  assert link.to_socket
109
113
  state.linked_sockets[link.to_socket].add(link.from_socket)
@@ -313,6 +317,8 @@ def sugiyama_layout(
313
317
  node_stacks = contracted_node_stacks(CG)
314
318
 
315
319
  compute_ranks(CG)
320
+ if state.settings.balance_heights:
321
+ balance_column_heights(G, CG.S, state)
316
322
  CG.merge_edges()
317
323
  CG.insert_dummy_nodes()
318
324
 
@@ -60,6 +60,10 @@ def assign_x_coords(
60
60
  columns: list[list[Node]] = G.graph["columns"]
61
61
  x = 0
62
62
  for i, col in enumerate(columns):
63
+ if not col:
64
+ # nodebpy divergence: a rank whose only occupants were dummy
65
+ # nodes (dissolved when reroutes are not added) takes no space.
66
+ continue
63
67
  max_width = max([v.width for v in col])
64
68
 
65
69
  for v in col:
@@ -135,6 +135,17 @@ def inner_shift(
135
135
  w.inner_shift = fmean(inner_shifts)
136
136
 
137
137
 
138
+ def vertical_gap(u: Node, w: Node, state: LayoutState) -> float:
139
+ """Margin between two vertically adjacent nodes of a column (nodebpy
140
+ divergence): consecutive reroutes / dummy nodes — the bundles of long
141
+ links routed past a column — pack much tighter than nodes, as reroute
142
+ dots do in hand-made trees, so a fan-in of many long links no longer
143
+ costs a node's height per link."""
144
+ if u.is_reroute and w.is_reroute:
145
+ return state.margin.y * state.settings.reroute_margin_y_fac
146
+ return state.margin.y
147
+
148
+
138
149
  def place_block(v: Node, is_up: bool, state: LayoutState) -> None:
139
150
  if cast(float | None, v.y) is not None:
140
151
  return
@@ -155,7 +166,8 @@ def place_block(v: Node, is_up: bool, state: LayoutState) -> None:
155
166
  v.sink = u.sink
156
167
 
157
168
  if v.sink == u.sink:
158
- delta_l = n.height + state.margin.y if is_up else w.height + state.margin.y
169
+ gap = vertical_gap(n, w, state)
170
+ delta_l = n.height + gap if is_up else w.height + gap
159
171
  s_b = u.y + n.inner_shift - w.inner_shift + delta_l
160
172
  v.y = s_b if initial else max(v.y, s_b)
161
173
  initial = False
@@ -183,7 +195,8 @@ def vertical_compaction(G: nx.DiGraph[Node], is_up: bool, state: LayoutState) ->
183
195
  col[0].sink.shift = 0
184
196
 
185
197
  for u, v in neighborings[tuple(col)]:
186
- delta_l = u.height + state.margin.y if is_up else v.height + state.margin.y
198
+ gap = vertical_gap(u, v, state)
199
+ delta_l = u.height + gap if is_up else v.height + gap
187
200
  s_c = v.y + v.inner_shift - u.y - u.inner_shift - delta_l
188
201
  u.sink.shift = min(u.sink.shift, v.sink.shift + s_c)
189
202
 
@@ -11,7 +11,7 @@ from bpy.types import NodeSocket, NodeTree
11
11
  from mathutils import Vector
12
12
 
13
13
  if TYPE_CHECKING:
14
- from .arrange.graph import Socket
14
+ from .arrange.graph import Node, Socket
15
15
 
16
16
 
17
17
  @dataclass
@@ -30,6 +30,17 @@ class Settings:
30
30
  recenter_mode = "NODES"
31
31
  origin: Literal["CENTER", "ACTIVE_OUTPUT", "ACTIVE_NODE"] = "CENTER"
32
32
  stack_margin_y_fac: float = 0.5
33
+ # nodebpy divergence: rank frames as sequential stages (see
34
+ # ranking.add_frame_sequence_edges).
35
+ sequential_frames: bool = True
36
+ # nodebpy divergence: promote private feeder chains out of the tallest
37
+ # column (see arrange.balancing).
38
+ balance_heights: bool = True
39
+ balance_aspect: float = 1.6
40
+ # nodebpy divergence: fraction of the vertical margin kept between
41
+ # consecutive reroutes / dummy nodes in a column (see
42
+ # y_coords.vertical_gap).
43
+ reroute_margin_y_fac: float = 0.35
33
44
 
34
45
 
35
46
  DEFAULT_MARGIN = (200.0, 20.0)
@@ -55,3 +66,8 @@ class LayoutState:
55
66
  multi_input_sort_ids: defaultdict[Socket, list[tuple[Socket, int]]] = field(
56
67
  default_factory=lambda: defaultdict(list)
57
68
  )
69
+ # Frame-sequence constraints applied by ranking.add_frame_sequence_edges:
70
+ # every node of the first set is ranked before every node of the second.
71
+ frame_sequence: list[tuple[frozenset[Node], frozenset[Node]]] = field(
72
+ default_factory=list
73
+ )
File without changes