diff --git a/doc/modules/ROOT/pages/concepts.adoc b/doc/modules/ROOT/pages/concepts.adoc index 5155e636..7d2fbab7 100644 --- a/doc/modules/ROOT/pages/concepts.adoc +++ b/doc/modules/ROOT/pages/concepts.adoc @@ -101,6 +101,16 @@ NOTE: While using the regular return to home, it is not guaranteed that each dro *Skybrush Studio for Blender* also supports flawless light design for your drone light shows. In Blender we support a single RGB color to be mapped to each drone at each frame in the timeline by simple baked colors or by complex parametric light effects overlayed to the base color. The RGB color output from Blender will be transformed to the RGB or RGBW color of your LED driver on your drones by the Skybrush backend and firmware. There are several smart tools dedicated in *Skybrush Studio for Blender* for light design, to see these options please checkout the description of the xref:panels/leds.adoc[LEDs tab]. +=== Storage and visualization of color data + +Baked colors of a given drone object are stored in the animation data of the `color` property of the object (shown as "Color" in the "Viewport Display" section in the Properties editor), while parametric light effect colors are dynamically overlaid onto this property for the current frame in real-time. + +For properly visualizing colors, internally we use a common drone template material for all drones (which should always be the first material of each drone) with a shader node tree that injects the viewport color of the object (i.e. the `color` property) as the color source for the material. Therefore, for properly visualizing drone colors, you should set the "Object Color" and/or "Wireframe Color" properties of the Viewport Shading panel in your 3D viewports to `Object`. + +Note that earlier (pre 4.0) versions of the *Skybrush Studio for Blender* plugin used a different shader node tree with one unique material for each drone. This solution was changed in version 4.0 due to efficiency reasons. When you open a file that was created with an earlier version of the plugin, a migration window should pop up warning you about the deprecated structure of your old file and providing means for upgrading your file to the new structure automatically with a single click. + +=== Color space + Internally, *Skybrush Studio for Blender* uses the linear RGB color space, just like the entire Blender pipeline. The colors are encoded in this color space when they are exported to a show file, and it is the responsibility of the show viewer application (i.e. *Skybrush Viewer*) and the LED driver of the drone to convert linear RGB colors to the color space appropriate for the end device. @@ -108,6 +118,11 @@ Internally, *Skybrush Studio for Blender* uses the linear RGB color space, just *Skybrush Studio for Blender* supports pyro control as part of your drone light shows. Pyro trigger events on multiple channels can be created with convenient payload descriptors and different visualization types. Pyro control events can be exported into the main .skyc show file or into external formats supporting drone-launched pyro. For more information please checkout the description of the xref:panels/pyro.adoc[Pyro tab]. +=== Storage and visualization of pyro data + +Pyro data for each drone is stored as a JSON string under "Object Data Properties" -> "Drone Show" -> "Pyro trigger events". + +This JSON string gets converted into different visualizations based on the "Render" dropdown selection of the xref:panels/pyro/pyro_control.adoc[Pyro Control panel]. Some visualizations of pyro data need a dedicated material for each drone, this is always stored as the second material of the drone objects. == Yaw control diff --git a/src/addons/ui_skybrush_studio.py b/src/addons/ui_skybrush_studio.py index c3168be9..ec6f9762 100644 --- a/src/addons/ui_skybrush_studio.py +++ b/src/addons/ui_skybrush_studio.py @@ -126,6 +126,7 @@ UpdateFrameRangeFromStoryboardOperator, UpdateTimeMarkersFromStoryboardOperator, UseSelectedVertexGroupForFormationOperator, + UseSharedMaterialForAllDronesMigrationOperator, ValidateTrajectoriesOperator, VVIZExportOperator, ) @@ -254,6 +255,7 @@ AddMarkersFromQRCodeOperator, RefreshFileFormatsOperator, RunFullProximityCheckOperator, + UseSharedMaterialForAllDronesMigrationOperator, ) #: List widgets in this addon. diff --git a/src/modules/sbstudio/plugin/actions.py b/src/modules/sbstudio/plugin/actions.py index 1e3019ab..cb75cea9 100644 --- a/src/modules/sbstudio/plugin/actions.py +++ b/src/modules/sbstudio/plugin/actions.py @@ -146,9 +146,7 @@ def find_f_curve_for_data_path_and_index( return None -def find_all_f_curves_for_data_path( - object_or_action, data_path: str -) -> Optional[FCurve]: +def find_all_f_curves_for_data_path(object_or_action, data_path: str) -> list[FCurve]: """Finds all F-curves in the F-curves of the action whose data path matches the given property, sorted by the array index of the curves. @@ -166,9 +164,10 @@ def find_all_f_curves_for_data_path( else: action = object_or_action - # TODO(ntamas): sort by array index! - result = [curve for curve in action.fcurves if curve.data_path == data_path] - return result + return sorted( + [curve for curve in action.fcurves if curve.data_path == data_path], + key=lambda c: c.array_index, + ) def cleanup_actions_for_object(object): diff --git a/src/modules/sbstudio/plugin/colors.py b/src/modules/sbstudio/plugin/colors.py new file mode 100644 index 00000000..aa26579a --- /dev/null +++ b/src/modules/sbstudio/plugin/colors.py @@ -0,0 +1,71 @@ +import bpy + +from sbstudio.model.types import RGBAColor, RGBAColorLike +from sbstudio.plugin.actions import ensure_action_exists_for_object +from sbstudio.plugin.keyframes import set_keyframes + +__all__ = ( + "create_keyframe_for_color_of_drone", + "get_color_of_drone", + "set_color_of_drone", +) + +is_blender_4 = bpy.app.version >= (4, 0, 0) + + +def create_keyframe_for_color_of_drone( + drone, + color: tuple[float, float, float] | tuple[float, float, float, float] | RGBAColor, + *, + frame: int | None = None, + step: bool = False, +): + """Creates color keyframes for the given drone to set + in the given frame. + + Parameters: + drone: the drone object to modify + color: the RGB color to use for the color of the drone + frame: the frame to apply the color on; `None` means the + current frame + step: whether to insert an additional keyframe in the preceding frame to + ensure an abrupt transition + """ + if frame is None: + frame = bpy.context.scene.frame_current + + ensure_action_exists_for_object(drone) + + if hasattr(color, "r"): + color_as_rgba = color.r, color.g, color.b, 1.0 + else: + color_as_rgba = color[0], color[1], color[2], 1.0 + + keyframes = [(frame, color_as_rgba)] + if step and frame > bpy.context.scene.frame_start: + keyframes.insert(0, (frame - 1, None)) + + set_keyframes(drone, "color", keyframes, interpolation="LINEAR") + + +def get_color_of_drone(drone) -> RGBAColor: + """Returns the color of the LED light on the given drone. + + Parameters: + drone: the drone to query + color: the color to apply to the LED light of the drone + """ + if drone.color is not None: + return drone.color + + return (0.0, 0.0, 0.0, 0.0) + + +def set_color_of_drone(drone, color: RGBAColorLike): + """Sets the color of the LED light on the given drone. + + Parameters: + drone: the drone to update + color: the color to apply to the LED light of the drone + """ + drone.color = color diff --git a/src/modules/sbstudio/plugin/constants.py b/src/modules/sbstudio/plugin/constants.py index 74fe2a6d..80eaa06d 100644 --- a/src/modules/sbstudio/plugin/constants.py +++ b/src/modules/sbstudio/plugin/constants.py @@ -46,6 +46,9 @@ DEFAULT_OUTDOOR_DRONE_RADIUS = 0.5 """Default outdoor drone radius""" +LATEST_SKYBRUSH_PLUGIN_VERSION = 2 +"""The latest (current) plugin version.""" + NUM_PYRO_CHANNELS = 6 """The number of pyro channels that we support.""" diff --git a/src/modules/sbstudio/plugin/keyframes.py b/src/modules/sbstudio/plugin/keyframes.py index 29be13c2..94e89272 100644 --- a/src/modules/sbstudio/plugin/keyframes.py +++ b/src/modules/sbstudio/plugin/keyframes.py @@ -1,6 +1,7 @@ """Functions related to the handling of keyframes in animation actions.""" from bpy.types import Action, FCurve +from collections import defaultdict from typing import Callable, Optional, Sequence, Tuple, Union from .actions import ( @@ -9,7 +10,7 @@ get_action_for_object, ) -__all__ = ("clear_keyframes", "set_keyframes") +__all__ = ("clear_keyframes", "get_keyframes", "set_keyframes") def clear_keyframes( @@ -67,6 +68,38 @@ def clear_keyframes( points.remove(points[index]) +def get_keyframes( + object, + data_path: str, +) -> list[tuple[float, float | list[float]]]: + """Gets the values of all keyframes of an object at the given data path. + + Parameters: + object: the object on which the keyframes are to be retrieved + data_path: the data path to use + + Returns: + the keyframes of the object at the given data path + """ + source, sep, prop = data_path.rpartition(".") + source = object.path_resolve(source) if sep else object + + fcurves = find_all_f_curves_for_data_path(object, data_path) + + match len(fcurves): + case 0: + return [] + case 1: + return [(p.co.x, p.co.y) for p in fcurves[0].keyframe_points] + case _: + frames_dict = defaultdict(lambda: [0.0] * len(fcurves)) + for curve in fcurves: + if curve.data_path == data_path: + for point in curve.keyframe_points: + frames_dict[int(point.co.x)][curve.array_index] = point.co.y + return sorted(frames_dict.items()) + + def set_keyframes( object, data_path: str, diff --git a/src/modules/sbstudio/plugin/materials.py b/src/modules/sbstudio/plugin/materials.py index 2952a4a8..229656f1 100644 --- a/src/modules/sbstudio/plugin/materials.py +++ b/src/modules/sbstudio/plugin/materials.py @@ -14,7 +14,7 @@ "create_glowing_material", "get_material_for_led_light_color", "get_material_for_pyro", - "set_led_light_color", + "set_emission_strength_of_material", ) is_blender_4 = bpy.app.version >= (4, 0, 0) @@ -96,11 +96,17 @@ def create_glowing_material( links.clear() nodes.clear() + object_info_node = nodes.new("ShaderNodeObjectInfo") + object_info_node.location = (-300, 0) + emission_node = nodes.new("ShaderNodeEmission") emission_node.inputs["Strength"].default_value = strength + emission_node.location = (0, 0) output_node = nodes.new("ShaderNodeOutputMaterial") + output_node.location = (300, 0) + links.new(object_info_node.outputs["Color"], emission_node.inputs["Color"]) links.new(emission_node.outputs["Emission"], output_node.inputs["Surface"]) _set_diffuse_color_of_material(mat, color) @@ -139,25 +145,6 @@ def get_material_for_pyro(drone) -> Optional[Material]: return None -def _get_diffuse_color_of_material(material) -> RGBAColor: - """Returns the diffuse color of the given material to the given value. - - The material must use a principled BSDF or an emission shader. - - Parameters: - material: the Blender material to update - color: the color to apply to the material - """ - if material.use_nodes: - # Material is using shader nodes so we need to adjust the diffuse - # color in the shader as well (the base property would control only - # what we see in the preview window) - _, input = _get_shader_node_and_input_for_diffuse_color_of_material(material) - return tuple(input.default_value) - else: - return material.diffuse_color - - def _set_diffuse_color_of_material(material, color: RGBAColor): """Sets the diffuse color of the given material to the given value. @@ -191,79 +178,6 @@ def set_emission_strength_of_material(material, value: float) -> None: input.default_value = value -def create_keyframe_for_diffuse_color_of_material( - material, - color: Union[ - Tuple[float, float, float], Tuple[float, float, float, float], RGBAColor - ], - *, - frame: Optional[int] = None, - step: bool = False, -): - """Creates keyframes for the diffuse color of the given material to set it - to the given color in the given frame. - - Parameters: - material: the material to modify - color: the RGB color to use for the diffuse color of the material - frame: the frame to apply the diffuse color on; `None` means the - current frame - step: whether to insert an additional keyframe in the preceding frame to - ensure an abrupt transition - """ - if frame is None: - frame = bpy.context.scene.frame_current - - # Ensure that we have animation data for the shader node tree. we need - # to use a custom name because all the node trees otherwise have the - # same name ("Shader Nodetree") so they would get the same action - node_tree = material.node_tree - ensure_action_exists_for_object( - node_tree, name=f"{material.name} Shader Nodetree Action" - ) - - if hasattr(color, "r"): - color_as_rgba = color.r, color.g, color.b, 1.0 - else: - color_as_rgba = color[0], color[1], color[2], 1.0 - - keyframes = [(frame, color_as_rgba)] - if step and frame > bpy.context.scene.frame_start: - keyframes.insert(0, (frame - 1, None)) - - # Set the keyframes - node, input = _get_shader_node_and_input_for_diffuse_color_of_material(material) - index = node.inputs.find(input.name) - data_path = f'nodes["{node.name}"].inputs[{index}].default_value' - set_keyframes(node_tree, data_path, keyframes, interpolation="LINEAR") - - -def get_led_light_color(drone) -> RGBAColor: - """Returns the color of the LED light on the given drone. - - Parameters: - drone: the drone to query - color: the color to apply to the LED light of the drone - """ - material = get_material_for_led_light_color(drone) - if material is not None: - return _get_diffuse_color_of_material(material) - else: - return (0.0, 0.0, 0.0, 0.0) - - -def set_led_light_color(drone, color: RGBAColorLike): - """Sets the color of the LED light on the given drone. - - Parameters: - drone: the drone to update - color: the color to apply to the LED light of the drone - """ - material = get_material_for_led_light_color(drone) - if material is not None: - _set_diffuse_color_of_material(material, color) - - def _get_shader_node_and_input_for_diffuse_color_of_material(material): """Returns a reference to the shader node and its input that controls the diffuse color of the given material. diff --git a/src/modules/sbstudio/plugin/meshes.py b/src/modules/sbstudio/plugin/meshes.py index ef6c7635..e1fd7c08 100644 --- a/src/modules/sbstudio/plugin/meshes.py +++ b/src/modules/sbstudio/plugin/meshes.py @@ -16,47 +16,12 @@ __all__ = ( "create_cone", - "create_cube", "create_icosphere", "edit_mesh", "subdivide_edges", ) -def _current_object_renamed_to(name): - result = bpy.context.object - if name is not None: - result.name = name - return result - - -def create_cube( - center: Coordinate3D = (0, 0, 0), - size: float = 1, - *, - name: Optional[str] = None, -): - """Creates a Blender mesh object with the shape of a cube. - - Parameters: - center: the center of the cube - size: the size of the cube. You may also pass a tuple of length 3 - to create a box with different width, height and depth. - name: the name of the mesh object; `None` to use the default name that - Blender assigns to the object - - Returns: - object: the created mesh object - """ - if isinstance(size, (int, float)): - size = (size, size, size) - - bpy.ops.mesh.primitive_cube_add(location=center) - bpy.context.object.scale = size - - return _current_object_renamed_to(name) - - def create_cone( center: Coordinate3D = (0, 0, 0), radius: float = 1, *, name: Optional[str] = None ): @@ -135,27 +100,6 @@ def create_object_from_bmesh(bm, *, name: Optional[str] = None): return obj -def create_sphere( - center: Coordinate3D = (0, 0, 0), - radius: float = 1, - *, - name: Optional[str] = None, -): - """Creates a Blender mesh object with the shape of a sphere. - - Parameters: - center: the center of the sphere - radius: the radius of the sphere - name: the name of the mesh object; `None` to use the default name that - Blender assigns to the object - - Returns: - object: the created mesh object - """ - bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=center) - return _current_object_renamed_to(name) - - @contextmanager def edit_mesh(obj) -> Iterator[bmesh.types.BMesh]: """Establishes a context in which the mesh of the given Blender object diff --git a/src/modules/sbstudio/plugin/model/show.py b/src/modules/sbstudio/plugin/model/show.py index adad15ff..14c85dbf 100644 --- a/src/modules/sbstudio/plugin/model/show.py +++ b/src/modules/sbstudio/plugin/model/show.py @@ -1,6 +1,8 @@ -from bpy.props import PointerProperty +from bpy.props import IntProperty, PointerProperty from bpy.types import PropertyGroup +from sbstudio.plugin.constants import LATEST_SKYBRUSH_PLUGIN_VERSION + from .formations_panel import FormationsPanelProperties from .led_control import LEDControlPanelProperties from .light_effects import LightEffectCollection @@ -35,3 +37,14 @@ class DroneShowAddonProperties(PropertyGroup): type=DroneShowAddonFileSpecificSettings ) storyboard: Storyboard = PointerProperty(type=Storyboard) + version: IntProperty = IntProperty( + name="Version", + description=( + "Current version of the show content stored in Blender. " + "Version 1 is the initial version (plugin version <= 3.13.2). " + "Version 2 uses a shared material for all drones to speed up light effects." + ), + min=1, + max=LATEST_SKYBRUSH_PLUGIN_VERSION, + default=1, + ) diff --git a/src/modules/sbstudio/plugin/operators/__init__.py b/src/modules/sbstudio/plugin/operators/__init__.py index 909b0532..981f2787 100644 --- a/src/modules/sbstudio/plugin/operators/__init__.py +++ b/src/modules/sbstudio/plugin/operators/__init__.py @@ -30,6 +30,9 @@ from .get_formation_stats import GetFormationStatisticsOperator from .import_light_effects import ImportLightEffectsOperator from .land import LandOperator +from .migrations.use_common_material_for_all_drones import ( + UseSharedMaterialForAllDronesMigrationOperator, +) from .move_light_effect import ( MoveLightEffectDownOperator, MoveLightEffectUpOperator, @@ -128,6 +131,7 @@ "UpdateFrameRangeFromStoryboardOperator", "UpdateTimeMarkersFromStoryboardOperator", "UseSelectedVertexGroupForFormationOperator", + "UseSharedMaterialForAllDronesMigrationOperator", "ValidateTrajectoriesOperator", "VVIZExportOperator", ) diff --git a/src/modules/sbstudio/plugin/operators/apply_color.py b/src/modules/sbstudio/plugin/operators/apply_color.py index 00f93af6..7e72ad8b 100644 --- a/src/modules/sbstudio/plugin/operators/apply_color.py +++ b/src/modules/sbstudio/plugin/operators/apply_color.py @@ -2,10 +2,7 @@ from bpy.types import Operator from random import shuffle -from sbstudio.plugin.materials import ( - create_keyframe_for_diffuse_color_of_material, - get_material_for_led_light_color, -) +from sbstudio.plugin.colors import create_keyframe_for_color_of_drone from sbstudio.plugin.props import ColorProperty from sbstudio.plugin.selection import get_selected_drones from sbstudio.plugin.utils.evaluator import create_position_evaluator @@ -158,11 +155,6 @@ def _apply_color_to_single_drone(self, drone, frame: int, index: int, ratio: flo # Gradient color = self.primary_color * (1 - ratio) + self.secondary_color * ratio - material = get_material_for_led_light_color(drone) - if not material: - # Drone does not have an LED light - return - - create_keyframe_for_diffuse_color_of_material( - material, color, frame=frame, step=not self.fade + create_keyframe_for_color_of_drone( + drone, color, frame=frame, step=not self.fade ) diff --git a/src/modules/sbstudio/plugin/operators/base.py b/src/modules/sbstudio/plugin/operators/base.py index 55cc2d72..57baa79c 100644 --- a/src/modules/sbstudio/plugin/operators/base.py +++ b/src/modules/sbstudio/plugin/operators/base.py @@ -10,7 +10,7 @@ from numpy.typing import NDArray from typing import Any, Optional -from bpy.props import BoolProperty, EnumProperty +from bpy.props import BoolProperty, EnumProperty, IntProperty from bpy.types import Collection, Context, Object, Operator from bpy_extras.io_utils import ExportHelper @@ -496,3 +496,62 @@ def _propose_marker_count(self, context: Context) -> int: else: num_existing_markers = 0 return max(0, num_drones - num_existing_markers) + + +class MigrationOperator(Operator): + """Operator mixin for migrations/upgrades for files created in earlier + versions of the Skybrush Studio for Blender plugin.""" + + version_from = IntProperty(name="Input format version", options={"HIDDEN"}) + version_to = IntProperty(name="Output format version", options={"HIDDEN"}) + + @classmethod + def poll(cls, context: Context): + return context.scene.skybrush + + def execute(self, context: Context): + if context.scene.skybrush.version < self.version_from: + raise RuntimeError( + f"Input format version should be {self.version_from}, " + f"not {context.scene.skybrush.version}" + ) + elif context.scene.skybrush.version == self.version_from: + retval = ( + self.execute_migration(context) + if self.needs_migration() + else {"FINISHED"} + ) + if retval == {"FINISHED"}: + context.scene.skybrush.version = self.version_to + + return retval + + return {"FINISHED"} + + def invoke(self, context: Context, event): + self.initialize_migration() + + if context.scene.skybrush.version >= self.version_to: + return {"CANCELLED"} + + if self.needs_migration(): + return context.window_manager.invoke_confirm( + self, event, title=self.bl_label, message=self.bl_description + ) + else: + return self.execute(context) + + def execute_migration(self, context: Context): + """Executes the migration/upgrade on the current Blender content.""" + raise NotImplementedError + + def initialize_migration(self) -> None: + """Initializes the operator by setting up the from/to versions.""" + raise NotImplementedError + + def needs_migration(self) -> bool: + """Returns whether the current Blender content needs migration. + + Note that return value is checked based on actual content, + irrespective of the current plugin version.""" + raise NotImplementedError diff --git a/src/modules/sbstudio/plugin/operators/create_takeoff_grid.py b/src/modules/sbstudio/plugin/operators/create_takeoff_grid.py index 26be0a46..0f77c69b 100644 --- a/src/modules/sbstudio/plugin/operators/create_takeoff_grid.py +++ b/src/modules/sbstudio/plugin/operators/create_takeoff_grid.py @@ -7,15 +7,11 @@ from sbstudio.model.types import Coordinate3D from sbstudio.plugin.constants import Collections, Formations, Templates -from sbstudio.plugin.materials import ( - get_material_for_led_light_color, - get_material_for_pyro, - create_keyframe_for_diffuse_color_of_material, -) +from sbstudio.plugin.colors import create_keyframe_for_color_of_drone +from sbstudio.plugin.materials import get_material_for_pyro from sbstudio.plugin.model.formation import add_points_to_formation, create_formation from sbstudio.plugin.model.storyboard import StoryboardEntryPurpose, get_storyboard from sbstudio.plugin.operators.detach_materials_from_template import ( - detach_led_light_material_from_drone_template, detach_pyro_material_from_drone_template, ) from sbstudio.plugin.selection import select_only @@ -355,7 +351,6 @@ def _run(self, context): ) drone_collection = Collections.find_drones() - led_light_template_material = get_material_for_led_light_color(drone_template) pyro_template_material = get_material_for_pyro(drone_template) drones = [] @@ -367,16 +362,13 @@ def _run(self, context): template=drone_template, collection=drone_collection, ) - material = detach_led_light_material_from_drone_template( - drone, template_material=led_light_template_material - ) # The next line is needed for light effects to work properly - create_keyframe_for_diffuse_color_of_material( - material, (1.0, 1.0, 1.0), frame=context.scene.frame_start, step=True + create_keyframe_for_color_of_drone( + drone, (1.0, 1.0, 1.0), frame=context.scene.frame_start, step=True ) - _pyro_material = detach_pyro_material_from_drone_template( + detach_pyro_material_from_drone_template( drone, template_material=pyro_template_material ) diff --git a/src/modules/sbstudio/plugin/operators/detach_materials_from_template.py b/src/modules/sbstudio/plugin/operators/detach_materials_from_template.py index e9cf23f4..0744facb 100644 --- a/src/modules/sbstudio/plugin/operators/detach_materials_from_template.py +++ b/src/modules/sbstudio/plugin/operators/detach_materials_from_template.py @@ -4,33 +4,12 @@ from sbstudio.plugin.constants import Collections, Templates from sbstudio.plugin.materials import ( create_colored_material, - get_material_for_led_light_color, get_material_for_pyro, ) __all__ = ("DetachMaterialsFromDroneTemplateOperator",) -def detach_led_light_material_from_drone_template( - drone, template_material: Optional[Material] = None -) -> None: - if template_material is None: - template = Templates.find_drone(create=False) - template_material = ( - get_material_for_led_light_color(template) if template else None - ) - - if template_material is None: - return None - - for slot in drone.material_slots: - if slot.material == template_material: - copied_material = template_material.copy() - copied_material.name = f"LED color of {drone.name}" - slot.material = copied_material - return slot.material - - def detach_pyro_material_from_drone_template( drone, template_material: Optional[Material] = None ) -> None: @@ -74,16 +53,10 @@ class DetachMaterialsFromDroneTemplateOperator(Operator): def execute(self, context): template = Templates.find_drone(create=False) - led_light_template_material = ( - get_material_for_led_light_color(template) if template else None - ) pyro_template_material = get_material_for_pyro(template) if template else None drones = Collections.find_drones() for drone in drones.objects: - detach_led_light_material_from_drone_template( - drone, template_material=led_light_template_material - ) detach_pyro_material_from_drone_template( drone, template_material=pyro_template_material ) diff --git a/src/modules/sbstudio/plugin/operators/migrations/use_common_material_for_all_drones.py b/src/modules/sbstudio/plugin/operators/migrations/use_common_material_for_all_drones.py new file mode 100644 index 00000000..2327c1fc --- /dev/null +++ b/src/modules/sbstudio/plugin/operators/migrations/use_common_material_for_all_drones.py @@ -0,0 +1,149 @@ +import bpy +import logging + +from time import time + +from sbstudio.plugin.actions import ensure_action_exists_for_object +from sbstudio.plugin.constants import Collections, Templates +from sbstudio.plugin.errors import SkybrushStudioAddonError +from sbstudio.plugin.keyframes import get_keyframes, set_keyframes +from sbstudio.plugin.materials import ( + get_material_for_led_light_color, + _get_shader_node_and_input_for_diffuse_color_of_material, +) +from sbstudio.plugin.operators.base import MigrationOperator +from sbstudio.plugin.views import find_all_3d_views + +__all__ = ("UseSharedMaterialForAllDronesMigrationOperator",) + +log = logging.getLogger(__name__) + + +def add_object_info_to_shader_node_tree_of_drone_template() -> None: + """Checks drone template and if it seems to be old, upgrade + it to contain object info shader node as drone color source.""" + template = Templates.find_drone(create=False) + template_material = get_material_for_led_light_color(template) + if template_material is not None: + _, input = _get_shader_node_and_input_for_diffuse_color_of_material( + template_material + ) + if not input.is_linked: + nodes = template_material.node_tree.nodes + links = template_material.node_tree.links + object_info_node = nodes.new("ShaderNodeObjectInfo") + links.new(object_info_node.outputs["Color"], input) + elif input.links[0].from_node.name != "Object Info": + raise SkybrushStudioAddonError("Template drone shader node tree mismatch") + + +def set_all_shading_color_types_to_object() -> None: + """Sets the object color source of the solid and wireframe + shading types of the 3D Viewport to use object color.""" + for space in find_all_3d_views(): + space.shading.color_type = "OBJECT" + space.shading.wireframe_color_type = "OBJECT" + + +def upgrade_drone_color_animations_and_drone_materials() -> None: + """Moves drone color animation from shader nodes to object colors + and replaces drone material with template material.""" + template = Templates.find_drone(create=False) + template_material = get_material_for_led_light_color(template) + drones = Collections.find_drones() + num_drones = len(drones.objects) + last_log = time() + for i, drone in enumerate(drones.objects): + material = get_material_for_led_light_color(drone) + if material: + # copy color animation from shader node to drone + node_tree = material.node_tree + if node_tree.animation_data: + node, input = _get_shader_node_and_input_for_diffuse_color_of_material( + material + ) + index = node.inputs.find(input.name) + data_path = f'nodes["{node.name}"].inputs[{index}].default_value' + keyframes = get_keyframes(node_tree, data_path) + ensure_action_exists_for_object(drone) + set_keyframes(drone, "color", keyframes, interpolation="LINEAR") + # reset drone's material to the template material + drone.material_slots[0].material = template_material + if time() - last_log > 10 or i == num_drones - 1: + log.info( + f"{(i + 1) / num_drones * 100:.1f}% ready, " + f"{i + 1}/{num_drones} drones converted" + ) + last_log = time() + + +def needs_migration(): + """Returns whether the current Blender content needs migration. + + Note that return value is checked based on actual content, + irrespective of the current plugin version.""" + # TODO: what should be the optimal method to check if file + # needs migration or not? We check the template material now + # as it is most probably not modified by the users frequently + try: + template = Templates.find_drone(create=False) + except (KeyError, ValueError): + return False + + template_material = get_material_for_led_light_color(template) + if template_material is None: + return False + + _, input = _get_shader_node_and_input_for_diffuse_color_of_material( + template_material + ) + + return not input.is_linked + + +class UseSharedMaterialForAllDronesMigrationOperator(MigrationOperator): + """Upgrades old Skybrush Studio for Blender file content (<=3.13.2) + that uses a separate material for all drone objects to a new version + in which all drones share a common material. This speeds up light effect + handling substantially. + """ + + bl_idname = "skybrush.use_shared_material_for_all_drones_migration" + bl_label = "Update file content to speed up light effect rendering" + bl_description = ( + "Upgrade your old (<4.0) Skybrush Studio for Blender file content\n" + "to speed up light effect playback and show export, by replacing all\n" + "drone object materials to a shared template material, modifying its shader\n" + "node tree and storing color animations in the drone object's 'color' property.\n" + "The upgrade also changes active 3D Viewport wireframe and object color to 'OBJECT'.\n" + ) + + def initialize_migration(self) -> None: + """Initializes the operator by setting up the from/to versions.""" + self.version_from = 1 + self.version_to = 2 + + def needs_migration(self) -> bool: + """Returns whether the current Blender content needs migration. + + Note that return value is checked based on actual content, + irrespective of the current plugin version.""" + return needs_migration() + + def execute_migration(self, context): + """Executes the migration/upgrade on the current Blender content.""" + + log.info("Upgrade started.") + + log.info("Modifying shader node tree of drone template...") + add_object_info_to_shader_node_tree_of_drone_template() + + log.info("Simplifying drone color animation storage...") + upgrade_drone_color_animations_and_drone_materials() + + log.info("Changing 3D Viewport shader color types to 'OBJECT'...") + set_all_shading_color_types_to_object() + + log.info("Upgrade successful.") + + return {"FINISHED"} diff --git a/src/modules/sbstudio/plugin/panels/formations.py b/src/modules/sbstudio/plugin/panels/formations.py index 1b3fa2b5..9fdc258d 100644 --- a/src/modules/sbstudio/plugin/panels/formations.py +++ b/src/modules/sbstudio/plugin/panels/formations.py @@ -1,7 +1,6 @@ from bpy.types import Panel from sbstudio.plugin.menus import GenerateMarkersMenu -from sbstudio.plugin.model.formation import count_markers_in_formation from sbstudio.plugin.operators import ( CreateFormationOperator, CreateTakeoffGridOperator, @@ -16,7 +15,11 @@ UpdateFormationOperator, AppendFormationToStoryboardOperator, ) -from sbstudio.plugin.stats import get_drone_count +from sbstudio.plugin.utils.warnings import ( + draw_bad_shader_color_source_warning, + draw_formation_size_warning, + draw_version_warning, +) __all__ = ("FormationsPanel",) @@ -40,14 +43,15 @@ def poll(cls, context): return context.scene.skybrush.formations def draw(self, context): - scene = context.scene - formations = scene.skybrush.formations + formations = context.scene.skybrush.formations if not formations: return - selected_formation = formations.selected layout = self.layout + draw_version_warning(context, layout) + draw_bad_shader_color_source_warning(context, layout) + layout.operator(CreateTakeoffGridOperator.bl_idname, icon="ADD") row = layout.row(align=True) @@ -67,22 +71,7 @@ def draw(self, context): row.operator(DeselectFormationOperator.bl_idname, text="Deselect") row.operator(GetFormationStatisticsOperator.bl_idname, text="Stats") - if selected_formation: - num_drones = get_drone_count() - num_markers = count_markers_in_formation(formations.selected) - - # If the number of markers in the formation is different from the - # number of drones, show a warning as we won't know what to do with - # the extra or missing drones - if num_markers != num_drones: - row = layout.box() - row.alert = False - row.label( - text=f"Formation size: {num_markers} " - f"{'<' if num_markers < num_drones else '>'} " - f"{num_drones}", - icon="ERROR", - ) + draw_formation_size_warning(context, layout) row = layout.row(align=True) row.menu( diff --git a/src/modules/sbstudio/plugin/panels/led_control.py b/src/modules/sbstudio/plugin/panels/led_control.py index 91d7d528..9d3412d9 100644 --- a/src/modules/sbstudio/plugin/panels/led_control.py +++ b/src/modules/sbstudio/plugin/panels/led_control.py @@ -4,6 +4,10 @@ ApplyColorsToSelectedDronesOperator as ApplyColors, ) from sbstudio.plugin.utils.bloom import bloom_effect_supported +from sbstudio.plugin.utils.warnings import ( + draw_bad_shader_color_source_warning, + draw_version_warning, +) class LEDControlPanel(Panel): @@ -32,6 +36,9 @@ def draw(self, context): layout = self.layout + draw_version_warning(context, layout) + draw_bad_shader_color_source_warning(context, layout) + row = layout.row() col = row.column() col.prop(led_control, "primary_color", text="Primary", icon="COLOR") diff --git a/src/modules/sbstudio/plugin/tasks/initialization.py b/src/modules/sbstudio/plugin/tasks/initialization.py index 1ab2c702..3cd4722c 100644 --- a/src/modules/sbstudio/plugin/tasks/initialization.py +++ b/src/modules/sbstudio/plugin/tasks/initialization.py @@ -71,6 +71,21 @@ def update_pyro_particles_of_drones(*args): update_pyro_particles_of_object(drone) +def _config_logging(*args): + import logging + + logging.basicConfig( + format="%(asctime)s.%(msecs)03d %(levelname)s: %(message)s", + level=logging.INFO, + datefmt="%H:%M:%S", + ) + + +def perform_migrations(*args): + # version 1 -> 2 + bpy.ops.skybrush.use_shared_material_for_all_drones_migration("INVOKE_DEFAULT") + + class InitializationTask(Task): """Background task that is called every time a new file is loaded.""" @@ -81,5 +96,8 @@ class InitializationTask(Task): remove_legacy_formation_constraints, setup_random_seed, update_pyro_particles_of_drones, + # enable this below for neat logs + # _config_logging, + perform_migrations, ] } diff --git a/src/modules/sbstudio/plugin/tasks/light_effects.py b/src/modules/sbstudio/plugin/tasks/light_effects.py index 95884f22..707f099b 100644 --- a/src/modules/sbstudio/plugin/tasks/light_effects.py +++ b/src/modules/sbstudio/plugin/tasks/light_effects.py @@ -12,7 +12,7 @@ from sbstudio.model.types import MutableRGBAColor, RGBAColor from sbstudio.plugin.constants import Collections -from sbstudio.plugin.materials import get_led_light_color, set_led_light_color +from sbstudio.plugin.colors import get_color_of_drone, set_color_of_drone from sbstudio.plugin.utils.evaluator import get_position_of_object if TYPE_CHECKING: @@ -80,7 +80,7 @@ def update_light_effects(scene: Scene, depsgraph: Depsgraph): # the base color cache in parallel to the colors list colors: list[MutableRGBAColor] = [] for drone in drones: - color = list(get_led_light_color(drone)) + color = list(get_color_of_drone(drone)) colors.append(color) _base_color_cache[id(drone)] = color else: @@ -116,7 +116,7 @@ def update_light_effects(scene: Scene, depsgraph: Depsgraph): if changed: assert drones is not None for drone, color in zip(drones, colors): - set_led_light_color(drone, color) + set_color_of_drone(drone, color) @contextmanager diff --git a/src/modules/sbstudio/plugin/utils/sampling.py b/src/modules/sbstudio/plugin/utils/sampling.py index 14039e81..700c704f 100644 --- a/src/modules/sbstudio/plugin/utils/sampling.py +++ b/src/modules/sbstudio/plugin/utils/sampling.py @@ -9,7 +9,7 @@ from sbstudio.model.point import Point4D from sbstudio.model.trajectory import Trajectory from sbstudio.model.yaw import YawSetpoint, YawSetpointList -from sbstudio.plugin.materials import get_led_light_color +from sbstudio.plugin.colors import get_color_of_drone from sbstudio.plugin.utils.evaluator import ( get_position_of_object, get_xyz_euler_rotation_of_object, @@ -217,7 +217,7 @@ def sample_colors_of_objects( for _, time in each_frame_in(frames, context=context, redraw=redraw): for obj in objects: key = obj.name if by_name else obj - color = get_led_light_color(obj) + color = get_color_of_drone(obj) lights[key].append( Color4D( time, @@ -272,7 +272,7 @@ def sample_positions_and_colors_of_objects( for obj in objects: key = obj.name if by_name else obj pos = get_position_of_object(obj) - color = get_led_light_color(obj) + color = get_color_of_drone(obj) trajectories[key].append(Point4D(time, *pos)) lights[key].append( Color4D( @@ -335,7 +335,7 @@ def sample_positions_colors_and_yaw_of_objects( for obj in objects: key = obj.name if by_name else obj pos = get_position_of_object(obj) - color = get_led_light_color(obj) + color = get_color_of_drone(obj) rotation = get_xyz_euler_rotation_of_object(obj) trajectories[key].append(Point4D(time, *pos)) lights[key].append( diff --git a/src/modules/sbstudio/plugin/utils/warnings.py b/src/modules/sbstudio/plugin/utils/warnings.py new file mode 100644 index 00000000..1b098230 --- /dev/null +++ b/src/modules/sbstudio/plugin/utils/warnings.py @@ -0,0 +1,66 @@ +from bpy.types import Context + +from sbstudio.plugin.constants import LATEST_SKYBRUSH_PLUGIN_VERSION +from sbstudio.plugin.model.formation import count_markers_in_formation +from sbstudio.plugin.stats import get_drone_count + +__all__ = ( + "draw_bad_shader_color_source_warning", + "draw_formation_size_warning", + "draw_version_warning", +) + + +def _draw_warning(layout, text: str) -> None: + row = layout.box() + row.alert = False + row.label(text=text, icon="ERROR") + + +def draw_bad_shader_color_source_warning(context: Context, layout) -> None: + """Draw a bad shader color source warning to a layout, if needed.""" + shading = context.space_data.shading + label = ( + "Set shader Wireframe Color to 'OBJECT'" + if (shading.type == "WIREFRAME" and shading.wireframe_color_type != "OBJECT") + else "Set shader Object Color to 'OBJECT'" + if (shading.type == "SOLID" and shading.color_type != "OBJECT") + else None + ) + if label: + _draw_warning(layout, text=label) + + +def draw_formation_size_warning(context: Context, layout) -> None: + """Draw a formation size warning to a layout, if needed.""" + formations = context.scene.skybrush.formations + if not formations: + return + + selected_formation = formations.selected + if not selected_formation: + return + + num_drones = get_drone_count() + num_markers = count_markers_in_formation(selected_formation) + + # If the number of markers in the formation is different from the + # number of drones, show a warning as we won't know what to do with + # the extra or missing drones + if num_markers != num_drones: + _draw_warning( + layout, + text=f"Formation size: {num_markers} " + f"{'<' if num_markers < num_drones else '>'} " + f"{num_drones}", + ) + + +def draw_version_warning(context: Context, layout) -> None: + """Draw a version warning to a layout, if needed.""" + skybrush = context.scene.skybrush + if skybrush.version < LATEST_SKYBRUSH_PLUGIN_VERSION: + _draw_warning( + layout, + text=f"File format is old (version {skybrush.version} < {LATEST_SKYBRUSH_PLUGIN_VERSION})", + )