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
parent
fc0d7afa4d
commit
c63dda2224
|
@ -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()
|
||||
|
@ -259,4 +291,15 @@ class ManagedBLImage(base.ManagedObj):
|
|||
if bl_select:
|
||||
self.bl_select()
|
||||
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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
Loading…
Reference in New Issue