fix: Major streamlining of plot workflow.

Various small adjustments with a big total impact:
- Toggling 3D preview no longer propagates a DataChanged, which prevents
  chronic `bl_select()` invocation that greatly slows down switching
  between images.
- Viewer now uses a replot() context manager to hide any active plots
  whenever no plots were generated, so that turning off plots /
  previewing a node (chain) without plots properly turns off the image,
  instead of letting the image hang around.
- `self.managed_objs` is now properly invalidated, instead of trying to
  set the `name` attribute of in-memory objects, so that persistance
  keeps up with `sim_node_name` changes, so that all the ex. 'Viz' nodes
  don't all try to hog a single 'Viz' image name.
- A pre-save handler was added, which ensures all images are packed into
  the .blend, to ensure that the images will pop up on the next file
  load.
- A fake user is now assigned to all new images, to nail down the idea
  that `ManagedBLImage` is the owner.
- `name` setter of `ManagedBLImage` was unreasonably bugged (it's
  actually incredible that it worked) - it has been fixed, as well as
  other changes applied to the class as a whole (including @classmethod
  on the UI-area/space getters and minor None-sanitizing).
main
Sofus Albert Høgsbro Rose 2024-04-27 01:52:04 +02:00
parent fc0d7afa4d
commit c63dda2224
Signed by: so-rose
GPG Key ID: AD901CB0F3701434
6 changed files with 131 additions and 59 deletions

View File

@ -1,3 +1,5 @@
"""Declares `ManagedBLImage`."""
import io
import time
import typing as typ
@ -21,11 +23,16 @@ SPACE_TYPE = 'IMAGE_EDITOR'
# - Managed BL Image
####################
class ManagedBLImage(base.ManagedObj):
"""Represents a Blender Image datablock, encapsulating various useful interactions with it.
Attributes:
name: The name of the image.
"""
managed_obj_type = ct.ManagedObjType.ManagedBLImage
_bl_image_name: str
def __init__(self, name: str):
## TODO: Check that blender doesn't have any other images by the same name.
self._bl_image_name = name
@property
@ -34,25 +41,32 @@ class ManagedBLImage(base.ManagedObj):
@name.setter
def name(self, value: str):
# Image Doesn't Exist
if not (bl_image := bpy.data.images.get(self._bl_image_name)):
# ...AND Desired Image Name is Not Taken
if not bpy.data.objects.get(value):
self._bl_image_name = value
return
log.info(
'Setting ManagedBLImage from "%s" to "%s"',
self.name,
value,
)
current_bl_image = bpy.data.images.get(self._bl_image_name)
wanted_bl_image = bpy.data.images.get(value)
# ...AND Desired Image Name is Taken
msg = f'Desired name {value} for BL image is taken'
# Yoink Image Name
if current_bl_image is None and wanted_bl_image is None:
self._bl_image_name = value
# Alter Image Name
elif current_bl_image is not None and wanted_bl_image is None:
self._bl_image_name = value
current_bl_image.name = value
# Overlapping Image Name
elif wanted_bl_image is not None:
msg = f'ManagedBLImage "{self._bl_image_name}" could not change its name to "{value}", since it already exists.'
raise ValueError(msg)
# Object DOES Exist
bl_image.name = value
self._bl_image_name = bl_image.name
## - When name exists, Blender adds .### to prevent overlap.
## - `set_name` is allowed to change the name; nodes account for this.
def free(self):
if bl_image := bpy.data.images.get(self.name):
bl_image = bpy.data.images.get(self.name)
if bl_image is not None:
log.debug('Freeing ManagedBLImage "%s"', self.name)
bpy.data.images.remove(bl_image)
####################
@ -72,7 +86,8 @@ class ManagedBLImage(base.ManagedObj):
channels = 4 if color_model == 'RGBA' else 3
# Remove Image (if mismatch)
if (bl_image := bpy.data.images.get(self.name)) and (
bl_image = bpy.data.images.get(self.name)
if bl_image is not None and (
bl_image.size[0] != width_px
or bl_image.size[1] != height_px
or bl_image.channels != channels
@ -81,7 +96,8 @@ class ManagedBLImage(base.ManagedObj):
self.free()
# Create Image w/Geometry (if none exists)
if not (bl_image := bpy.data.images.get(self.name)):
bl_image = bpy.data.images.get(self.name)
if bl_image is None:
bl_image = bpy.data.images.new(
self.name,
width=width_px,
@ -89,44 +105,62 @@ class ManagedBLImage(base.ManagedObj):
float_buffer=dtype == 'float32',
)
# Enable Fake User
bl_image.use_fake_user = True
return bl_image
####################
# - Editor UX Manipulation
####################
@property
def preview_area(self) -> bpy.types.Area:
"""Returns the visible preview area in the Blender UI.
If none are valid, return None.
@classmethod
def preview_area(cls) -> bpy.types.Area | None:
"""Deduces a Blender UI area that can be used for image preview.
Returns:
A Blender UI area, if an appropriate one is visible; else `None`,
"""
valid_areas = [
area for area in bpy.context.screen.areas if area.type == AREA_TYPE
]
if valid_areas:
return valid_areas[0]
return None
@property
def preview_space(self) -> bpy.types.SpaceProperties:
"""Returns the visible preview space in the visible preview area of
the Blender UI
@classmethod
def preview_space(cls) -> bpy.types.SpaceProperties | None:
"""Deduces a Blender UI space, within `self.preview_area`, that can be used for image preview.
Returns:
A Blender UI space within `self.preview_area`, if it isn't None; else, `None`.
"""
if preview_area := self.preview_area:
return next(
preview_area = cls.preview_area()
if preview_area is not None:
valid_spaces = [
space for space in preview_area.spaces if space.type == SPACE_TYPE
)
]
if valid_spaces:
return valid_spaces[0]
return None
return None
####################
# - Methods
####################
def bl_select(self) -> None:
"""Synchronizes the managed object to the preview, by manipulating
relevant editors.
"""
if bl_image := bpy.data.images.get(self.name):
self.preview_space.image = bl_image
"""Selects the image by loading it into an on-screen UI area/space.
def hide_preview(self) -> None:
self.preview_space.image = None
Notes:
The image must already be available, else nothing will happen.
"""
bl_image = bpy.data.images.get(self.name)
if bl_image is not None:
self.preview_space().image = bl_image
@classmethod
def hide_preview(cls) -> None:
"""Deselects the image by loading `None` into the on-screen UI area/space."""
cls.preview_space().image = None
####################
# - Image Geometry
@ -138,7 +172,7 @@ class ManagedBLImage(base.ManagedObj):
dpi: int | None = None,
):
# Compute Image Geometry
if preview_area := self.preview_area:
if preview_area := self.preview_area():
# Retrieve DPI from Blender Preferences
_dpi = bpy.context.preferences.system.dpi
@ -188,12 +222,10 @@ class ManagedBLImage(base.ManagedObj):
image_data = func_image_data(4)
width_px = image_data.shape[1]
height_px = image_data.shape[0]
# log.debug('Computed Image Data (%f)', time.perf_counter() - time_start)
bl_image = self.bl_image(width_px, height_px, 'RGBA', 'float32')
bl_image.pixels.foreach_set(np.float32(image_data).ravel())
bl_image.update()
# log.debug('Set BL Image (%f)', time.perf_counter() - time_start)
if bl_select:
self.bl_select()
@ -260,3 +292,14 @@ class ManagedBLImage(base.ManagedObj):
self.bl_select()
times.append(time.perf_counter() - times[0])
# log.critical('Timing of MPL Plot: %s', str(times))
@bpy.app.handlers.persistent
def pack_managed_images(_):
for image in bpy.data.images:
if image.is_dirty:
image.pack()
## TODO: Only pack images declared by a ManagedBLImage
bpy.app.handlers.save_pre.append(pack_managed_images)

View File

@ -6,6 +6,7 @@ import bpy
from blender_maxwell.utils import logger
from . import contracts as ct
from .managed_objs.managed_bl_image import ManagedBLImage
log = logger.get(__name__)
@ -209,6 +210,22 @@ class MaxwellSimTree(bpy.types.NodeTree):
for bl_socket in [*node.inputs, *node.outputs]:
bl_socket.locked = False
@contextlib.contextmanager
def replot(self) -> None:
self.is_currently_replotting = True
self.something_plotted = False
try:
yield
finally:
self.is_currently_replotting = False
if not self.something_plotted:
ManagedBLImage.hide_preview()
def report_show_plot(self, node: bpy.types.Node) -> None:
if hasattr(self, 'is_currently_replotting') and self.is_currently_replotting:
self.something_plotted = True
@contextlib.contextmanager
def repreview_all(self) -> None:
all_nodes_with_preview_active = {
@ -220,15 +237,12 @@ class MaxwellSimTree(bpy.types.NodeTree):
try:
yield
finally:
self.is_currently_repreviewing = False
for dangling_previewed_node in [
node
for node_instance_id, node in all_nodes_with_preview_active.items()
if node_instance_id not in self.newly_previewed_nodes
]:
# log.debug(
# 'Removing Dangling Preview of Node "{%s}"',
# str(dangling_previewed_node),
# )
dangling_previewed_node.preview_active = False
def report_show_preview(self, node: bpy.types.Node) -> None:

View File

@ -226,16 +226,16 @@ class OperateMathNode(base.MaxwellSimNode):
####################
def draw_label(self):
labels = {
'ADD': lambda: 'Filter: L + R',
'SUB': lambda: 'Filter: L - R',
'MUL': lambda: 'Filter: L · R',
'DIV': lambda: 'Filter: L / R',
'POW': lambda: 'Filter: L^R',
'ATAN2': lambda: 'Filter: atan2(L,R)',
'ADD': lambda: 'L + R',
'SUB': lambda: 'L - R',
'MUL': lambda: 'L · R',
'DIV': lambda: 'L / R',
'POW': lambda: 'L^R',
'ATAN2': lambda: 'atan2(L,R)',
}
if (label := labels.get(self.operation)) is not None:
return label()
return 'Operate: ' + label()
return self.bl_label

View File

@ -186,6 +186,7 @@ class VizNode(base.MaxwellSimNode):
node_type = ct.NodeType.Viz
bl_label = 'Viz'
use_sim_node_name = True
####################
# - Sockets
@ -285,7 +286,7 @@ class VizNode(base.MaxwellSimNode):
@events.on_value_changed(
prop_name='viz_mode',
## run_on_init: Implicitly triggered.
run_on_init=True,
)
def on_viz_mode_changed(self):
self.viz_target = bl_cache.Signal.ResetEnumItems

View File

@ -245,9 +245,9 @@ class MaxwellSimNode(bpy.types.Node):
####################
@events.on_value_changed(
prop_name='sim_node_name',
props={'sim_node_name', 'managed_objs', 'managed_obj_types'},
stop_propagation=True,
)
def _on_sim_node_name_changed(self, props: dict):
def _on_sim_node_name_changed(self):
log.info(
'Changed Sim Node Name of a "%s" to "%s" (self=%s)',
self.bl_idname,
@ -256,8 +256,7 @@ class MaxwellSimNode(bpy.types.Node):
)
# Set Name of Managed Objects
for mobj in props['managed_objs'].values():
mobj.name = props['sim_node_name']
self.managed_objs = bl_cache.Signal.InvalidateCache
@events.on_value_changed(prop_name='active_socket_set')
def _on_socket_set_changed(self):
@ -291,16 +290,27 @@ class MaxwellSimNode(bpy.types.Node):
## TODO: Account for FlowKind
bl_socket.value = socket_value
@events.on_show_plot(stop_propagation=False)
def _on_show_plot(self):
node_tree = self.id_data
if len(self.event_methods_by_event[ct.FlowEvent.ShowPlot]) > 1:
## TODO: Is this check good enough?
## TODO: Set icon/indicator/something to make it clear which node is being previewed.
node_tree.report_show_plot(self)
@events.on_show_preview()
def _on_show_preview(self):
node_tree = self.id_data
node_tree.report_show_preview(self)
# Set Preview to Active
## Implicitly triggers any @on_value_changed for preview_active.
if not self.preview_active:
self.preview_active = True
@events.on_value_changed(prop_name='preview_active', props={'preview_active'})
@events.on_value_changed(
prop_name='preview_active', props={'preview_active'}, stop_propagation=True
)
def _on_preview_changed(self, props):
if not props['preview_active']:
for mobj in self.managed_objs.values():

View File

@ -137,6 +137,10 @@ class ViewerNode(base.MaxwellSimNode):
props={'auto_plot'},
)
def on_changed_plot_preview(self, props):
node_tree = self.id_data
# Unset Plot if Nothing Plotted
with node_tree.replot():
if props['auto_plot']:
self.trigger_event(ct.FlowEvent.ShowPlot)