nodebpy 520.26.0__tar.gz → 520.28.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.26.0 → nodebpy-520.28.0}/PKG-INFO +1 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/pyproject.toml +1 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/__init__.py +2 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/assets/__main__.py +4 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/assets/_library.py +83 -32
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/tree.py +26 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/export/codegen.py +14 -0
- nodebpy-520.28.0/src/nodebpy/live.py +361 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/manual.py +2 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/manual.py +2 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/manual.py +2 -1
- {nodebpy-520.26.0 → nodebpy-520.28.0}/README.md +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/assets/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/assets/_codegen.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/assets/_pipeline.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/_registry.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/_socket_order.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/_utils.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/accessor.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/asset.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/items.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/layout.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/mixins.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/node.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/builder/socket.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/export/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/export/diagram.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/export/parity.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/export/plot.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/export/web_render.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/VENDORED.md +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/balancing.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/graph.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/ordering.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/ranking.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/realize.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/stacking.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/structs.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/sugiyama.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/x_coords.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/arrange/y_coords.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/config.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/lib/nodearrange/utils.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/_mixins.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/assets.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/color.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/converter.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/distort.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/filter.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/group.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/input.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/interface.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/matte.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/output.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/compositor/vector.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/assets.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/attribute.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/color.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/converter.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/geometry.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/grid.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/group.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/groups.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/input.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/interface.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/output.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/texture.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/utilities.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/vector.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/geometry/zone.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/__init__.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/assets.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/color.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/converter.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/grid.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/group.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/input.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/output.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/script.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/shader.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/texture.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/nodes/shader/vector.py +0 -0
- {nodebpy-520.26.0 → nodebpy-520.28.0}/src/nodebpy/types.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from . import builder, export, nodes, types
|
|
1
|
+
from . import builder, export, live, nodes, types
|
|
2
2
|
from .builder import (
|
|
3
3
|
ArrangeMethod,
|
|
4
4
|
SimpleOptions,
|
|
@@ -22,6 +22,7 @@ __all__ = [
|
|
|
22
22
|
"default_sugiyama_options",
|
|
23
23
|
"export",
|
|
24
24
|
"geometry",
|
|
25
|
+
"live",
|
|
25
26
|
"nodes",
|
|
26
27
|
"shader",
|
|
27
28
|
"types",
|
|
@@ -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)
|
|
@@ -1674,7 +1717,15 @@ def _parse_args(argv: list[str] | None = None):
|
|
|
1674
1717
|
dump.add_argument(
|
|
1675
1718
|
"--names",
|
|
1676
1719
|
nargs="+",
|
|
1677
|
-
|
|
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
|
+
),
|
|
1678
1729
|
)
|
|
1679
1730
|
_add_dump_flags(dump)
|
|
1680
1731
|
dump.add_argument(
|
|
@@ -1856,7 +1907,7 @@ def _dump_command(args) -> None:
|
|
|
1856
1907
|
written = dump_library(
|
|
1857
1908
|
args.blend,
|
|
1858
1909
|
args.output,
|
|
1859
|
-
names=
|
|
1910
|
+
names=args.names or None,
|
|
1860
1911
|
nodebpy_pkg=args.nodebpy_pkg,
|
|
1861
1912
|
snapshot_positions=args.snapshot_positions,
|
|
1862
1913
|
keep_reroutes=args.keep_reroutes,
|
|
@@ -1108,11 +1108,28 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1108
1108
|
fake_user: bool = False,
|
|
1109
1109
|
ignore_visibility: bool = False,
|
|
1110
1110
|
split_inputs: bool | None = None,
|
|
1111
|
+
clear: bool = False,
|
|
1111
1112
|
):
|
|
1113
|
+
# ``clear`` rebuilds in place: the tree is emptied (nodes, links and
|
|
1114
|
+
# interface) before the body runs, keeping the datablock so modifiers,
|
|
1115
|
+
# group nodes and pinned editors that reference it stay attached. With
|
|
1116
|
+
# a name, an existing group of that name and tree type is reused
|
|
1117
|
+
# instead of creating a ``Name.001`` duplicate; a group of another
|
|
1118
|
+
# tree type is left alone and a new one is created as usual.
|
|
1119
|
+
# Tree-level properties (description, color_tag, is_tool, fake user,
|
|
1120
|
+
# ...) are kept: only the contents are rebuilt.
|
|
1112
1121
|
if isinstance(tree, str):
|
|
1113
|
-
|
|
1122
|
+
existing = bpy.data.node_groups.get(tree) if clear else None
|
|
1123
|
+
if existing is not None and existing.bl_idname == tree_type:
|
|
1124
|
+
self.tree = existing
|
|
1125
|
+
else:
|
|
1126
|
+
self.tree = bpy.data.node_groups.new(tree, tree_type) # ty: ignore[invalid-assignment]
|
|
1114
1127
|
else:
|
|
1115
1128
|
self.tree = tree # type: ignore
|
|
1129
|
+
if clear:
|
|
1130
|
+
assert self.tree.interface is not None
|
|
1131
|
+
self.tree.nodes.clear()
|
|
1132
|
+
self.tree.interface.clear()
|
|
1116
1133
|
|
|
1117
1134
|
self._menu_defaults: list[_MenuDefault] = []
|
|
1118
1135
|
self._exited = False
|
|
@@ -1133,6 +1150,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1133
1150
|
arrange: ArrangeMethod = "sugiyama",
|
|
1134
1151
|
fake_user: bool = False,
|
|
1135
1152
|
split_inputs: bool | None = None,
|
|
1153
|
+
clear: bool = False,
|
|
1136
1154
|
) -> TreeBuilder[GeometryNodeTree]:
|
|
1137
1155
|
"""Create a geometry node tree."""
|
|
1138
1156
|
return cast(
|
|
@@ -1144,6 +1162,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1144
1162
|
arrange=arrange,
|
|
1145
1163
|
fake_user=fake_user,
|
|
1146
1164
|
split_inputs=split_inputs,
|
|
1165
|
+
clear=clear,
|
|
1147
1166
|
),
|
|
1148
1167
|
)
|
|
1149
1168
|
|
|
@@ -1156,6 +1175,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1156
1175
|
arrange: ArrangeMethod = "sugiyama",
|
|
1157
1176
|
fake_user: bool = False,
|
|
1158
1177
|
split_inputs: bool | None = None,
|
|
1178
|
+
clear: bool = False,
|
|
1159
1179
|
) -> TreeBuilder[ShaderNodeTree]:
|
|
1160
1180
|
"""Create a shader node tree."""
|
|
1161
1181
|
return cast(
|
|
@@ -1167,6 +1187,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1167
1187
|
arrange=arrange,
|
|
1168
1188
|
fake_user=fake_user,
|
|
1169
1189
|
split_inputs=split_inputs,
|
|
1190
|
+
clear=clear,
|
|
1170
1191
|
),
|
|
1171
1192
|
)
|
|
1172
1193
|
|
|
@@ -1179,6 +1200,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1179
1200
|
arrange: ArrangeMethod = "sugiyama",
|
|
1180
1201
|
fake_user: bool = False,
|
|
1181
1202
|
split_inputs: bool | None = None,
|
|
1203
|
+
clear: bool = False,
|
|
1182
1204
|
) -> TreeBuilder[CompositorNodeTree]:
|
|
1183
1205
|
"""Create a compositor node tree."""
|
|
1184
1206
|
return cast(
|
|
@@ -1190,6 +1212,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1190
1212
|
arrange=arrange,
|
|
1191
1213
|
fake_user=fake_user,
|
|
1192
1214
|
split_inputs=split_inputs,
|
|
1215
|
+
clear=clear,
|
|
1193
1216
|
),
|
|
1194
1217
|
)
|
|
1195
1218
|
|
|
@@ -1215,6 +1238,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1215
1238
|
top_level: Literal["with", "class"] = "with",
|
|
1216
1239
|
format: bool = True,
|
|
1217
1240
|
nodebpy_pkg: str = "nodebpy",
|
|
1241
|
+
in_place: bool = False,
|
|
1218
1242
|
) -> str:
|
|
1219
1243
|
"""Generate Python source that recreates this tree using nodebpy.
|
|
1220
1244
|
|
|
@@ -1232,6 +1256,7 @@ class TreeBuilder[TreeT: NodeTree]:
|
|
|
1232
1256
|
top_level=top_level,
|
|
1233
1257
|
format=format,
|
|
1234
1258
|
nodebpy_pkg=nodebpy_pkg,
|
|
1259
|
+
in_place=in_place,
|
|
1235
1260
|
)
|
|
1236
1261
|
|
|
1237
1262
|
def to_mermaid(self, fenced: bool = True) -> str:
|
|
@@ -3724,6 +3724,7 @@ def to_python(
|
|
|
3724
3724
|
external_groups: Collection[str] | None = None,
|
|
3725
3725
|
typed_groups: Collection[str] | None = None,
|
|
3726
3726
|
root_interface: GroupInterface | None = None,
|
|
3727
|
+
in_place: bool = False,
|
|
3727
3728
|
) -> str:
|
|
3728
3729
|
"""Generate Python code that recreates the given node tree using nodebpy.
|
|
3729
3730
|
|
|
@@ -3794,6 +3795,17 @@ def to_python(
|
|
|
3794
3795
|
Typed-interface parts (docstring, class attributes, accessors and
|
|
3795
3796
|
``__init__``) spliced into the top-level tree's class in ``class``
|
|
3796
3797
|
mode, ahead of ``_build_group``.
|
|
3798
|
+
in_place: bool
|
|
3799
|
+
If True, the ``with`` header is emitted as
|
|
3800
|
+
``with g.tree("Name", clear=True) as tree:`` so running the source
|
|
3801
|
+
rebuilds the exported tree in place — the same datablock, emptied
|
|
3802
|
+
first — instead of creating a ``Name.001`` copy, keeping modifiers,
|
|
3803
|
+
group nodes and pinned editors attached across re-runs. Only the
|
|
3804
|
+
top-level tree is rebuilt this way: nested groups are emitted as
|
|
3805
|
+
``Custom*Group`` classes whose ``create_group()`` reuses an existing
|
|
3806
|
+
tree of the same name, so edits to them only take effect through
|
|
3807
|
+
:func:`nodebpy.live.run_source`. Ignored with ``top_level="class"``
|
|
3808
|
+
(a class is rebuilt by ``run_source``, not by a header).
|
|
3797
3809
|
|
|
3798
3810
|
Returns
|
|
3799
3811
|
-------
|
|
@@ -3876,6 +3888,8 @@ def to_python(
|
|
|
3876
3888
|
# for either option.
|
|
3877
3889
|
if snapshot_positions or keep_reroutes:
|
|
3878
3890
|
ctor_args.append("arrange=None")
|
|
3891
|
+
if in_place:
|
|
3892
|
+
ctor_args.append("clear=True")
|
|
3879
3893
|
lines.append(f"with {constructor}({', '.join(ctor_args)}) as tree:")
|
|
3880
3894
|
lines.extend(_assemble_tree_body(emission))
|
|
3881
3895
|
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Re-run nodebpy source safely inside a live Blender session.
|
|
2
|
+
|
|
3
|
+
A live editor (a Text block, a file on disk) re-executes its code on every
|
|
4
|
+
change. Three things make a naive ``exec`` unpleasant, and :func:`run_source`
|
|
5
|
+
handles all of them:
|
|
6
|
+
|
|
7
|
+
- ``Custom*Group.create_group()`` reuses an existing group of the same
|
|
8
|
+
``_name`` rather than rebuilding it, so re-running edited class code would
|
|
9
|
+
silently hand back the previous build. Existing groups the code claims are
|
|
10
|
+
renamed out of the way first (:func:`stash_groups`), then their users are
|
|
11
|
+
remapped onto the rebuilt trees and the old ones removed
|
|
12
|
+
(:meth:`GroupStash.replace`); on failure the old names are put back
|
|
13
|
+
(:meth:`GroupStash.restore`).
|
|
14
|
+
- Rebuilding a tree's interface regenerates its socket identifiers, which is
|
|
15
|
+
what Geometry Nodes modifiers key their input values on, so the values
|
|
16
|
+
would be lost. :func:`preserve_modifier_inputs` snapshots them by socket
|
|
17
|
+
name and reapplies them afterwards.
|
|
18
|
+
- The tree the run produced has to be found among whatever the code did:
|
|
19
|
+
the ``tree`` variable of the ``with g.tree(...) as tree:`` form, the newest
|
|
20
|
+
top-level group it created, or the last group class it defined.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
from collections.abc import Iterable, Iterator
|
|
27
|
+
from contextlib import contextmanager, suppress
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, cast
|
|
30
|
+
|
|
31
|
+
import bpy
|
|
32
|
+
|
|
33
|
+
from .builder import TreeBuilder
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"GroupStash",
|
|
37
|
+
"RunResult",
|
|
38
|
+
"group_names_in_source",
|
|
39
|
+
"preserve_modifier_inputs",
|
|
40
|
+
"run_source",
|
|
41
|
+
"stash_groups",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def group_names_in_source(code: str) -> set[str]:
|
|
46
|
+
"""Literal ``_name`` values assigned in class bodies of ``code``.
|
|
47
|
+
|
|
48
|
+
Parsed with :mod:`ast`; both ``_name = "..."`` and ``_name: str = "..."``
|
|
49
|
+
count. Returns an empty set on a :class:`SyntaxError` (``exec`` reports
|
|
50
|
+
it).
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
module = ast.parse(code)
|
|
54
|
+
except SyntaxError:
|
|
55
|
+
return set()
|
|
56
|
+
names: set[str] = set()
|
|
57
|
+
for node in ast.walk(module):
|
|
58
|
+
if not isinstance(node, ast.ClassDef):
|
|
59
|
+
continue
|
|
60
|
+
for stmt in node.body:
|
|
61
|
+
if isinstance(stmt, ast.Assign):
|
|
62
|
+
targets, value = stmt.targets, stmt.value
|
|
63
|
+
elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
|
|
64
|
+
targets, value = [stmt.target], stmt.value
|
|
65
|
+
else:
|
|
66
|
+
continue
|
|
67
|
+
if not (isinstance(value, ast.Constant) and isinstance(value.value, str)):
|
|
68
|
+
continue
|
|
69
|
+
if any(isinstance(t, ast.Name) and t.id == "_name" for t in targets):
|
|
70
|
+
names.add(value.value)
|
|
71
|
+
return names
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class GroupStash:
|
|
76
|
+
"""Existing node groups renamed out of the way before a run.
|
|
77
|
+
|
|
78
|
+
Maps each original name to the old tree, now named ``<name>.stale``.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
stashed: dict[str, bpy.types.NodeTree] = field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
def restore(self) -> None:
|
|
84
|
+
"""Failure path: remove any partial build under an original name and
|
|
85
|
+
give the old trees their names back. Empties the stash, so calling
|
|
86
|
+
it (or :meth:`replace`) again is a no-op."""
|
|
87
|
+
for name, old in self.stashed.items():
|
|
88
|
+
leftover = bpy.data.node_groups.get(name)
|
|
89
|
+
if leftover is not None and leftover != old:
|
|
90
|
+
bpy.data.node_groups.remove(leftover)
|
|
91
|
+
old.name = name
|
|
92
|
+
self.stashed.clear()
|
|
93
|
+
|
|
94
|
+
def replace(self) -> None:
|
|
95
|
+
"""Success path: for each name, if the run created a new tree of the
|
|
96
|
+
same type under it, point the old tree's users (group nodes,
|
|
97
|
+
modifiers, pinned editors) at the new one and remove the old;
|
|
98
|
+
otherwise the old tree keeps its original name. Empties the stash,
|
|
99
|
+
so calling it (or :meth:`restore`) again is a no-op."""
|
|
100
|
+
for name, old in self.stashed.items():
|
|
101
|
+
new = bpy.data.node_groups.get(name)
|
|
102
|
+
if new is not None and new != old and new.bl_idname == old.bl_idname:
|
|
103
|
+
old.user_remap(new)
|
|
104
|
+
bpy.data.node_groups.remove(old)
|
|
105
|
+
else:
|
|
106
|
+
old.name = name
|
|
107
|
+
self.stashed.clear()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def stash_groups(names: Iterable[str]) -> GroupStash:
|
|
111
|
+
"""Rename each existing ``bpy.data.node_groups[name]`` to ``"<name>.stale"``
|
|
112
|
+
and return the :class:`GroupStash` that undoes or completes the move.
|
|
113
|
+
|
|
114
|
+
A group linked from a library (an appended asset) cannot be renamed and
|
|
115
|
+
is left in place, so ``create_group()`` reuses it as usual.
|
|
116
|
+
"""
|
|
117
|
+
stash = GroupStash()
|
|
118
|
+
for name in names:
|
|
119
|
+
if name in stash.stashed:
|
|
120
|
+
continue
|
|
121
|
+
existing = bpy.data.node_groups.get(name)
|
|
122
|
+
if existing is not None and existing.library is None:
|
|
123
|
+
existing.name = f"{name}.stale"
|
|
124
|
+
stash.stashed[name] = existing
|
|
125
|
+
return stash
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _copy_value(value: Any) -> Any:
|
|
129
|
+
# Vector / colour values come back as bpy_prop_array views into the
|
|
130
|
+
# modifier's storage, which the rebuild replaces; keep a plain tuple.
|
|
131
|
+
if hasattr(value, "__len__") and not isinstance(value, (str, bytes, bpy.types.ID)):
|
|
132
|
+
return tuple(value)
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _input_sockets(
|
|
137
|
+
tree: bpy.types.NodeTree,
|
|
138
|
+
) -> dict[str, bpy.types.NodeTreeInterfaceSocket]:
|
|
139
|
+
"""Interface input sockets by name (first wins on duplicate names)."""
|
|
140
|
+
sockets: dict[str, bpy.types.NodeTreeInterfaceSocket] = {}
|
|
141
|
+
assert tree.interface is not None
|
|
142
|
+
for item in tree.interface.items_tree:
|
|
143
|
+
if item.item_type != "SOCKET":
|
|
144
|
+
continue
|
|
145
|
+
assert isinstance(item, bpy.types.NodeTreeInterfaceSocket)
|
|
146
|
+
if item.in_out == "INPUT" and item.name not in sockets:
|
|
147
|
+
sockets[item.name] = item
|
|
148
|
+
return sockets
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _modifier_inputs(modifier: bpy.types.NodesModifier) -> Any:
|
|
152
|
+
"""``modifier.properties.inputs``: one RNA property per interface input,
|
|
153
|
+
named by socket identifier, each wrapping the stored ``value`` (Blender
|
|
154
|
+
5.x; the type stubs predate it)."""
|
|
155
|
+
properties = cast("Any", modifier.properties)
|
|
156
|
+
return properties.inputs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _nodes_modifiers(pointers: set[int]) -> Iterator[bpy.types.NodesModifier]:
|
|
160
|
+
for obj in bpy.data.objects:
|
|
161
|
+
for modifier in obj.modifiers:
|
|
162
|
+
if not isinstance(modifier, bpy.types.NodesModifier):
|
|
163
|
+
continue
|
|
164
|
+
group = modifier.node_group
|
|
165
|
+
if group is not None and group.as_pointer() in pointers:
|
|
166
|
+
yield modifier
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Per-input state a modifier stores besides ``value``: whether the input
|
|
170
|
+
# reads a named attribute instead (``type`` is ``VALUE`` or ``ATTRIBUTE``)
|
|
171
|
+
# and which one.
|
|
172
|
+
_INPUT_ATTRS = ("value", "type", "attribute_name")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class _InputState:
|
|
177
|
+
socket_type: str
|
|
178
|
+
attrs: dict[str, Any]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@contextmanager
|
|
182
|
+
def preserve_modifier_inputs(trees: Iterable[bpy.types.NodeTree]) -> Iterator[None]:
|
|
183
|
+
"""Keep Geometry Nodes modifier input values across an interface rebuild.
|
|
184
|
+
|
|
185
|
+
Snapshots the input state of every Geometry Nodes modifier (on any
|
|
186
|
+
object) whose ``node_group`` is one of ``trees``, keyed by interface
|
|
187
|
+
socket *name*: the value, and whether the input reads a named attribute
|
|
188
|
+
instead (``type`` and ``attribute_name``). On exit — also when the body
|
|
189
|
+
raised, so a failed in-place rebuild keeps the values of the sockets it
|
|
190
|
+
did build — each entry whose socket name still exists on the modifier's
|
|
191
|
+
(possibly rebuilt, possibly remapped) tree is reapplied; sockets whose
|
|
192
|
+
type changed are skipped, as is anything that fails to apply — the
|
|
193
|
+
reapply step never raises.
|
|
194
|
+
"""
|
|
195
|
+
pointers = {tree.as_pointer() for tree in trees}
|
|
196
|
+
snapshot: list[tuple[bpy.types.NodesModifier, dict[str, _InputState]]] = []
|
|
197
|
+
for modifier in _nodes_modifiers(pointers):
|
|
198
|
+
assert modifier.node_group is not None
|
|
199
|
+
inputs = _modifier_inputs(modifier)
|
|
200
|
+
values: dict[str, _InputState] = {}
|
|
201
|
+
for name, socket in _input_sockets(modifier.node_group).items():
|
|
202
|
+
wrapper = getattr(inputs, socket.identifier, None)
|
|
203
|
+
if wrapper is None or not hasattr(wrapper, "value"):
|
|
204
|
+
continue # a geometry socket, or one with no stored value
|
|
205
|
+
attrs = {
|
|
206
|
+
attr: _copy_value(getattr(wrapper, attr))
|
|
207
|
+
for attr in _INPUT_ATTRS
|
|
208
|
+
if hasattr(wrapper, attr)
|
|
209
|
+
}
|
|
210
|
+
values[name] = _InputState(socket.socket_type, attrs)
|
|
211
|
+
snapshot.append((modifier, values))
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
yield
|
|
215
|
+
finally:
|
|
216
|
+
for modifier, values in snapshot:
|
|
217
|
+
# A modifier the run removed, a tree it freed, ...: never raise.
|
|
218
|
+
with suppress(Exception):
|
|
219
|
+
_reapply_inputs(modifier, values)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _reapply_inputs(
|
|
223
|
+
modifier: bpy.types.NodesModifier, values: dict[str, _InputState]
|
|
224
|
+
) -> None:
|
|
225
|
+
group = modifier.node_group
|
|
226
|
+
if group is None:
|
|
227
|
+
return
|
|
228
|
+
inputs = _modifier_inputs(modifier)
|
|
229
|
+
sockets = _input_sockets(group)
|
|
230
|
+
for name, state in values.items():
|
|
231
|
+
socket = sockets.get(name)
|
|
232
|
+
if socket is None or socket.socket_type != state.socket_type:
|
|
233
|
+
continue
|
|
234
|
+
wrapper = getattr(inputs, socket.identifier, None)
|
|
235
|
+
if wrapper is None or not hasattr(wrapper, "value"):
|
|
236
|
+
continue
|
|
237
|
+
for attr, value in state.attrs.items():
|
|
238
|
+
with suppress(Exception): # best effort, per attribute
|
|
239
|
+
setattr(wrapper, attr, value)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class RunResult:
|
|
244
|
+
"""What :func:`run_source` produced."""
|
|
245
|
+
|
|
246
|
+
tree: bpy.types.NodeTree | None
|
|
247
|
+
"""The tree the run produced, or ``None`` if it made no tree."""
|
|
248
|
+
created: list[bpy.types.NodeTree]
|
|
249
|
+
"""Every node group that exists after the run and did not before."""
|
|
250
|
+
namespace: dict[str, Any]
|
|
251
|
+
"""The globals the code ran in."""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _is_group_class(value: Any, module_name: str | None) -> bool:
|
|
255
|
+
return (
|
|
256
|
+
isinstance(value, type)
|
|
257
|
+
and getattr(value, "__module__", None) == module_name
|
|
258
|
+
and callable(getattr(value, "create_group", None))
|
|
259
|
+
and bool(getattr(value, "_name", None))
|
|
260
|
+
and bool(getattr(value, "_tree_idname", None))
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _produced_tree(
|
|
265
|
+
namespace: dict[str, Any], before: set[int], prior: Any
|
|
266
|
+
) -> bpy.types.NodeTree | None:
|
|
267
|
+
"""The tree a run produced: the ``tree`` variable's, else the newest
|
|
268
|
+
top-level group created, else the last group class defined (built now).
|
|
269
|
+
|
|
270
|
+
``before`` holds the ``session_uid`` of every group that existed before
|
|
271
|
+
the run; ``prior`` is whatever ``tree`` was bound to before it (a value
|
|
272
|
+
the caller's namespace carried over from an earlier run), which does not
|
|
273
|
+
count as produced by this one."""
|
|
274
|
+
bound = namespace.get("tree")
|
|
275
|
+
if bound is not prior:
|
|
276
|
+
if isinstance(bound, TreeBuilder):
|
|
277
|
+
return bound.tree
|
|
278
|
+
if isinstance(bound, bpy.types.NodeTree):
|
|
279
|
+
return bound
|
|
280
|
+
|
|
281
|
+
new_groups = [g for g in bpy.data.node_groups if g.session_uid not in before]
|
|
282
|
+
if new_groups:
|
|
283
|
+
nested = {
|
|
284
|
+
sub.as_pointer()
|
|
285
|
+
for group in new_groups
|
|
286
|
+
for node in group.nodes
|
|
287
|
+
if (sub := getattr(node, "node_tree", None)) is not None
|
|
288
|
+
}
|
|
289
|
+
top_level = [g for g in new_groups if g.as_pointer() not in nested]
|
|
290
|
+
# bpy.data.node_groups is sorted by name; session_uid counts up with
|
|
291
|
+
# creation, so it picks the group the code made last.
|
|
292
|
+
return max(top_level or new_groups, key=lambda g: g.session_uid)
|
|
293
|
+
|
|
294
|
+
module_name = namespace.get("__name__")
|
|
295
|
+
for value in reversed(list(namespace.values())):
|
|
296
|
+
if _is_group_class(value, module_name):
|
|
297
|
+
tree = value.create_group()
|
|
298
|
+
if isinstance(tree, bpy.types.NodeTree):
|
|
299
|
+
return tree
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def run_source(
|
|
304
|
+
code: str,
|
|
305
|
+
*,
|
|
306
|
+
filename: str = "<nodebpy>",
|
|
307
|
+
namespace: dict[str, Any] | None = None,
|
|
308
|
+
) -> RunResult:
|
|
309
|
+
"""Execute nodebpy source for a live re-run.
|
|
310
|
+
|
|
311
|
+
Existing groups the code's classes claim by ``_name`` are stashed so
|
|
312
|
+
``create_group()`` builds fresh, Geometry Nodes modifier input values on
|
|
313
|
+
every pre-existing tree are preserved across the rebuild, and the
|
|
314
|
+
stashed trees' users are remapped onto the new builds afterwards. On any
|
|
315
|
+
exception the stash is restored and the exception re-raised unchanged;
|
|
316
|
+
``filename`` is what its traceback names. Nothing is printed or captured.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
code
|
|
321
|
+
The source to run.
|
|
322
|
+
filename
|
|
323
|
+
Filename for the compiled code, so tracebacks point at the caller's
|
|
324
|
+
Text block or file.
|
|
325
|
+
namespace
|
|
326
|
+
Extra globals merged over the defaults (``__name__``, ``__file__``
|
|
327
|
+
and ``bpy``). A ``tree`` it carries over from an earlier run's
|
|
328
|
+
:attr:`RunResult.namespace` is not mistaken for this run's tree.
|
|
329
|
+
|
|
330
|
+
A run that fails partway through a ``with g.tree(..., clear=True)`` body
|
|
331
|
+
leaves that tree half built (the class form is atomic: the old build is
|
|
332
|
+
restored). Groups the failed run created under other names are left
|
|
333
|
+
behind as well.
|
|
334
|
+
"""
|
|
335
|
+
stash = stash_groups(group_names_in_source(code))
|
|
336
|
+
before = {group.session_uid for group in bpy.data.node_groups}
|
|
337
|
+
globals_: dict[str, Any] = {
|
|
338
|
+
"__name__": "__nodebpy_live__",
|
|
339
|
+
"__file__": filename,
|
|
340
|
+
"bpy": bpy,
|
|
341
|
+
}
|
|
342
|
+
if namespace:
|
|
343
|
+
globals_.update(namespace)
|
|
344
|
+
prior = globals_.get("tree")
|
|
345
|
+
|
|
346
|
+
existing = list(bpy.data.node_groups)
|
|
347
|
+
with preserve_modifier_inputs(existing):
|
|
348
|
+
try:
|
|
349
|
+
exec(compile(code, filename, "exec"), globals_) # noqa: S102
|
|
350
|
+
# Resolved before the stash is replaced: building a defined-only
|
|
351
|
+
# class must not find the old tree back under its name.
|
|
352
|
+
tree = _produced_tree(globals_, before, prior)
|
|
353
|
+
except BaseException:
|
|
354
|
+
stash.restore()
|
|
355
|
+
raise
|
|
356
|
+
# Inside the preserve context: remapping a modifier onto the rebuilt
|
|
357
|
+
# tree resets its inputs just like an in-place rebuild does.
|
|
358
|
+
stash.replace()
|
|
359
|
+
|
|
360
|
+
created = [g for g in bpy.data.node_groups if g.session_uid not in before]
|
|
361
|
+
return RunResult(tree=tree, created=created, namespace=globals_)
|
|
@@ -42,9 +42,10 @@ def tree(
|
|
|
42
42
|
collapse: bool = False,
|
|
43
43
|
arrange: ArrangeMethod = "sugiyama",
|
|
44
44
|
fake_user: bool = False,
|
|
45
|
+
clear: bool = False,
|
|
45
46
|
) -> TreeBuilder[CompositorNodeTree]:
|
|
46
47
|
return TreeBuilder.compositor(
|
|
47
|
-
name, collapse=collapse, arrange=arrange, fake_user=fake_user
|
|
48
|
+
name, collapse=collapse, arrange=arrange, fake_user=fake_user, clear=clear
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
|
|
@@ -152,8 +152,9 @@ def tree(
|
|
|
152
152
|
*,
|
|
153
153
|
collapse: bool = False,
|
|
154
154
|
arrange: ArrangeMethod = "sugiyama",
|
|
155
|
+
clear: bool = False,
|
|
155
156
|
) -> TreeBuilder[GeometryNodeTree]:
|
|
156
|
-
return TreeBuilder.geometry(name, collapse=collapse, arrange=arrange)
|
|
157
|
+
return TreeBuilder.geometry(name, collapse=collapse, arrange=arrange, clear=clear)
|
|
157
158
|
|
|
158
159
|
|
|
159
160
|
_ColorRampColorInterpolations = Literal[
|
|
@@ -52,9 +52,10 @@ def tree(
|
|
|
52
52
|
collapse: bool = False,
|
|
53
53
|
arrange: ArrangeMethod = "sugiyama",
|
|
54
54
|
fake_user: bool = False,
|
|
55
|
+
clear: bool = False,
|
|
55
56
|
) -> TreeBuilder[ShaderNodeTree]:
|
|
56
57
|
return TreeBuilder.shader(
|
|
57
|
-
name, collapse=collapse, arrange=arrange, fake_user=fake_user
|
|
58
|
+
name, collapse=collapse, arrange=arrange, fake_user=fake_user, clear=clear
|
|
58
59
|
)
|
|
59
60
|
|
|
60
61
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|