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 io
import time import time
import typing as typ import typing as typ
@ -21,11 +23,16 @@ SPACE_TYPE = 'IMAGE_EDITOR'
# - Managed BL Image # - Managed BL Image
#################### ####################
class ManagedBLImage(base.ManagedObj): 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 managed_obj_type = ct.ManagedObjType.ManagedBLImage
_bl_image_name: str _bl_image_name: str
def __init__(self, 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 self._bl_image_name = name
@property @property
@ -34,25 +41,32 @@ class ManagedBLImage(base.ManagedObj):
@name.setter @name.setter
def name(self, value: str): def name(self, value: str):
# Image Doesn't Exist log.info(
if not (bl_image := bpy.data.images.get(self._bl_image_name)): 'Setting ManagedBLImage from "%s" to "%s"',
# ...AND Desired Image Name is Not Taken self.name,
if not bpy.data.objects.get(value): value,
self._bl_image_name = value )
return 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 # Yoink Image Name
msg = f'Desired name {value} for BL image is taken' 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) 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): 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) bpy.data.images.remove(bl_image)
#################### ####################
@ -72,7 +86,8 @@ class ManagedBLImage(base.ManagedObj):
channels = 4 if color_model == 'RGBA' else 3 channels = 4 if color_model == 'RGBA' else 3
# Remove Image (if mismatch) # 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 bl_image.size[0] != width_px
or bl_image.size[1] != height_px or bl_image.size[1] != height_px
or bl_image.channels != channels or bl_image.channels != channels
@ -81,7 +96,8 @@ class ManagedBLImage(base.ManagedObj):
self.free() self.free()
# Create Image w/Geometry (if none exists) # 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( bl_image = bpy.data.images.new(
self.name, self.name,
width=width_px, width=width_px,
@ -89,44 +105,62 @@ class ManagedBLImage(base.ManagedObj):
float_buffer=dtype == 'float32', float_buffer=dtype == 'float32',
) )
# Enable Fake User
bl_image.use_fake_user = True
return bl_image return bl_image
#################### ####################
# - Editor UX Manipulation # - Editor UX Manipulation
#################### ####################
@property @classmethod
def preview_area(self) -> bpy.types.Area: def preview_area(cls) -> bpy.types.Area | None:
"""Returns the visible preview area in the Blender UI. """Deduces a Blender UI area that can be used for image preview.
If none are valid, return None.
Returns:
A Blender UI area, if an appropriate one is visible; else `None`,
""" """
valid_areas = [ valid_areas = [
area for area in bpy.context.screen.areas if area.type == AREA_TYPE area for area in bpy.context.screen.areas if area.type == AREA_TYPE
] ]
if valid_areas: if valid_areas:
return valid_areas[0] return valid_areas[0]
return None
@property @classmethod
def preview_space(self) -> bpy.types.SpaceProperties: def preview_space(cls) -> bpy.types.SpaceProperties | None:
"""Returns the visible preview space in the visible preview area of """Deduces a Blender UI space, within `self.preview_area`, that can be used for image preview.
the Blender UI
Returns:
A Blender UI space within `self.preview_area`, if it isn't None; else, `None`.
""" """
if preview_area := self.preview_area: preview_area = cls.preview_area()
return next( if preview_area is not None:
valid_spaces = [
space for space in preview_area.spaces if space.type == SPACE_TYPE space for space in preview_area.spaces if space.type == SPACE_TYPE
) ]
if valid_spaces:
return valid_spaces[0]
return None
return None
#################### ####################
# - Methods # - Methods
#################### ####################
def bl_select(self) -> None: def bl_select(self) -> None:
"""Synchronizes the managed object to the preview, by manipulating """Selects the image by loading it into an on-screen UI area/space.
relevant editors.
"""
if bl_image := bpy.data.images.get(self.name):
self.preview_space.image = bl_image
def hide_preview(self) -> None: Notes:
self.preview_space.image = None 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 # - Image Geometry
@ -138,7 +172,7 @@ class ManagedBLImage(base.ManagedObj):
dpi: int | None = None, dpi: int | None = None,
): ):
# Compute Image Geometry # Compute Image Geometry
if preview_area := self.preview_area: if preview_area := self.preview_area():
# Retrieve DPI from Blender Preferences # Retrieve DPI from Blender Preferences
_dpi = bpy.context.preferences.system.dpi _dpi = bpy.context.preferences.system.dpi
@ -188,12 +222,10 @@ class ManagedBLImage(base.ManagedObj):
image_data = func_image_data(4) image_data = func_image_data(4)
width_px = image_data.shape[1] width_px = image_data.shape[1]
height_px = image_data.shape[0] 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 = self.bl_image(width_px, height_px, 'RGBA', 'float32')
bl_image.pixels.foreach_set(np.float32(image_data).ravel()) bl_image.pixels.foreach_set(np.float32(image_data).ravel())
bl_image.update() bl_image.update()
# log.debug('Set BL Image (%f)', time.perf_counter() - time_start)
if bl_select: if bl_select:
self.bl_select() self.bl_select()
@ -260,3 +292,14 @@ class ManagedBLImage(base.ManagedObj):
self.bl_select() self.bl_select()
times.append(time.perf_counter() - times[0]) times.append(time.perf_counter() - times[0])
# log.critical('Timing of MPL Plot: %s', str(times)) # 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 blender_maxwell.utils import logger
from . import contracts as ct from . import contracts as ct
from .managed_objs.managed_bl_image import ManagedBLImage
log = logger.get(__name__) log = logger.get(__name__)
@ -209,6 +210,22 @@ class MaxwellSimTree(bpy.types.NodeTree):
for bl_socket in [*node.inputs, *node.outputs]: for bl_socket in [*node.inputs, *node.outputs]:
bl_socket.locked = False 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 @contextlib.contextmanager
def repreview_all(self) -> None: def repreview_all(self) -> None:
all_nodes_with_preview_active = { all_nodes_with_preview_active = {
@ -220,15 +237,12 @@ class MaxwellSimTree(bpy.types.NodeTree):
try: try:
yield yield
finally: finally:
self.is_currently_repreviewing = False
for dangling_previewed_node in [ for dangling_previewed_node in [
node node
for node_instance_id, node in all_nodes_with_preview_active.items() for node_instance_id, node in all_nodes_with_preview_active.items()
if node_instance_id not in self.newly_previewed_nodes 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 dangling_previewed_node.preview_active = False
def report_show_preview(self, node: bpy.types.Node) -> None: def report_show_preview(self, node: bpy.types.Node) -> None:

View File

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

View File

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

View File

@ -245,9 +245,9 @@ class MaxwellSimNode(bpy.types.Node):
#################### ####################
@events.on_value_changed( @events.on_value_changed(
prop_name='sim_node_name', 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( log.info(
'Changed Sim Node Name of a "%s" to "%s" (self=%s)', 'Changed Sim Node Name of a "%s" to "%s" (self=%s)',
self.bl_idname, self.bl_idname,
@ -256,8 +256,7 @@ class MaxwellSimNode(bpy.types.Node):
) )
# Set Name of Managed Objects # Set Name of Managed Objects
for mobj in props['managed_objs'].values(): self.managed_objs = bl_cache.Signal.InvalidateCache
mobj.name = props['sim_node_name']
@events.on_value_changed(prop_name='active_socket_set') @events.on_value_changed(prop_name='active_socket_set')
def _on_socket_set_changed(self): def _on_socket_set_changed(self):
@ -291,16 +290,27 @@ class MaxwellSimNode(bpy.types.Node):
## TODO: Account for FlowKind ## TODO: Account for FlowKind
bl_socket.value = socket_value 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() @events.on_show_preview()
def _on_show_preview(self): def _on_show_preview(self):
node_tree = self.id_data node_tree = self.id_data
node_tree.report_show_preview(self) node_tree.report_show_preview(self)
# Set Preview to Active # Set Preview to Active
## Implicitly triggers any @on_value_changed for preview_active. ## Implicitly triggers any @on_value_changed for preview_active.
if not self.preview_active: if not self.preview_active:
self.preview_active = True 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): def _on_preview_changed(self, props):
if not props['preview_active']: if not props['preview_active']:
for mobj in self.managed_objs.values(): for mobj in self.managed_objs.values():

View File

@ -137,6 +137,10 @@ class ViewerNode(base.MaxwellSimNode):
props={'auto_plot'}, props={'auto_plot'},
) )
def on_changed_plot_preview(self, props): 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']: if props['auto_plot']:
self.trigger_event(ct.FlowEvent.ShowPlot) self.trigger_event(ct.FlowEvent.ShowPlot)