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.
- {nodebpy-520.25.0 → nodebpy-520.27.0}/PKG-INFO +1 -1
- {nodebpy-520.25.0 → nodebpy-520.27.0}/pyproject.toml +1 -1
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/__main__.py +4 -1
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/_library.py +124 -33
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/_pipeline.py +12 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/layout.py +29 -1
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/plot.py +81 -32
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/VENDORED.md +17 -0
- nodebpy-520.27.0/src/nodebpy/lib/nodearrange/arrange/balancing.py +182 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +66 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +7 -1
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +4 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +15 -2
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/config.py +17 -1
- {nodebpy-520.25.0 → nodebpy-520.27.0}/README.md +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/assets/_codegen.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/_registry.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/_socket_order.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/_utils.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/accessor.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/asset.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/items.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/mixins.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/node.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/socket.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/builder/tree.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/codegen.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/diagram.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/parity.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/export/web_render.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/_mixins.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/color.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/group.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/input.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/manual.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/output.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/color.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/group.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/input.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/manual.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/output.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/assets.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/color.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/converter.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/grid.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/group.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/input.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/manual.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/output.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/script.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/shader.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/texture.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/nodes/shader/vector.py +0 -0
- {nodebpy-520.25.0 → nodebpy-520.27.0}/src/nodebpy/types.py +0 -0
|
@@ -90,8 +90,11 @@ def parse_args(
|
|
|
90
90
|
),
|
|
91
91
|
epilog=(
|
|
92
92
|
"subcommands:\n"
|
|
93
|
-
" dump <blend> <output-dir>
|
|
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:
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
576
|
-
# (including assets nested in other assets) arrive
|
|
577
|
-
# sharing structure can be read off the session
|
|
578
|
-
#
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
#
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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(
|
|
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(
|
|
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=
|
|
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=...)``. ``
|
|
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
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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=
|
|
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
|
-
"
|
|
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
|
-
"""
|
|
1073
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
return
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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.
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|