Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
# support reloading sub-modules
if "bpy" in locals():
from importlib import reload
_modules_loaded[:] = [reload(val) for val in _modules_loaded]
del reload
_modules = [
"add_mesh_torus",
"anim",
"assets",
"bone_selection_sets",
"clip",
"connect_to_output",
"console",
"constraint",
"copy_global_transform",
"file",
"geometry_nodes",
"grease_pencil",
"image",
"image_as_planes",
"mesh",
"node",
"object",
"object_align",
"object_quick_effects",
"object_randomize_transform",
"presets",
"render",
"rigidbody",
"screen_play_rendered_anim",
"sequencer",
"spreadsheet",
"userpref",
"uvcalc_follow_active",
"uvcalc_lightmap",
"uvcalc_transform",
"vertexpaint_dirt",
"view3d",
"world",
"wm",
]
import bpy
if bpy.app.build_options.freestyle:
_modules.append("freestyle")
__import__(name=__name__, fromlist=_modules)
_namespace = globals()
_modules_loaded = [_namespace[name] for name in _modules]
del _namespace
def register():
from bpy.utils import register_class
from . import (
bone_selection_sets,
copy_global_transform,
)
for mod in _modules_loaded:
for cls in mod.classes:
register_class(cls)
bone_selection_sets.register()
copy_global_transform.register()
def unregister():
from bpy.utils import unregister_class
from . import (
bone_selection_sets,
copy_global_transform,
)
bone_selection_sets.unregister()
copy_global_transform.unregister()
for mod in reversed(_modules_loaded):
for cls in reversed(mod.classes):
if cls.is_registered:
unregister_class(cls)

View File

@@ -0,0 +1,262 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import bpy
from bpy.types import Operator
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
IntProperty,
)
from bpy.app.translations import pgettext_data as data_
from bpy_extras import object_utils
def add_torus(major_rad, minor_rad, major_seg, minor_seg):
from math import cos, sin, pi
from mathutils import Vector, Matrix
pi_2 = pi * 2.0
verts = []
faces = []
i1 = 0
tot_verts = major_seg * minor_seg
for major_index in range(major_seg):
matrix = Matrix.Rotation((major_index / major_seg) * pi_2, 3, 'Z')
for minor_index in range(minor_seg):
angle = pi_2 * minor_index / minor_seg
vec = matrix @ Vector((
major_rad + (cos(angle) * minor_rad),
0.0,
sin(angle) * minor_rad,
))
verts.extend(vec[:])
if minor_index + 1 == minor_seg:
i2 = (major_index) * minor_seg
i3 = i1 + minor_seg
i4 = i2 + minor_seg
else:
i2 = i1 + 1
i3 = i1 + minor_seg
i4 = i3 + 1
if i2 >= tot_verts:
i2 = i2 - tot_verts
if i3 >= tot_verts:
i3 = i3 - tot_verts
if i4 >= tot_verts:
i4 = i4 - tot_verts
faces.extend([i1, i3, i4, i2])
i1 += 1
return verts, faces
def add_uvs(mesh, minor_seg, major_seg):
from math import fmod
mesh.uv_layers.new()
uv_data = mesh.uv_layers.active.data
polygons = mesh.polygons
u_step = 1.0 / major_seg
v_step = 1.0 / minor_seg
# Round UVs, needed when segments aren't divisible by 4.
u_init = 0.5 + fmod(0.5, u_step)
v_init = 0.5 + fmod(0.5, v_step)
# Calculate wrapping value under 1.0 to prevent
# float precision errors wrapping at the wrong step.
u_wrap = 1.0 - (u_step / 2.0)
v_wrap = 1.0 - (v_step / 2.0)
vertex_index = 0
u_prev = u_init
u_next = u_prev + u_step
for _major_index in range(major_seg):
v_prev = v_init
v_next = v_prev + v_step
for _minor_index in range(minor_seg):
loops = polygons[vertex_index].loop_indices
uv_data[loops[0]].uv = u_prev, v_prev
uv_data[loops[1]].uv = u_next, v_prev
uv_data[loops[3]].uv = u_prev, v_next
uv_data[loops[2]].uv = u_next, v_next
if v_next > v_wrap:
v_prev = v_next - 1.0
else:
v_prev = v_next
v_next = v_prev + v_step
vertex_index += 1
if u_next > u_wrap:
u_prev = u_next - 1.0
else:
u_prev = u_next
u_next = u_prev + u_step
class AddTorus(Operator, object_utils.AddObjectHelper):
"""Construct a torus mesh"""
bl_idname = "mesh.primitive_torus_add"
bl_label = "Add Torus"
bl_options = {'REGISTER', 'UNDO', 'PRESET'}
def mode_update_callback(self, _context):
if self.mode == 'EXT_INT':
self.abso_major_rad = self.major_radius + self.minor_radius
self.abso_minor_rad = self.major_radius - self.minor_radius
major_segments: IntProperty(
name="Major Segments",
description="Number of segments for the main ring of the torus",
min=3, max=256,
default=48,
)
minor_segments: IntProperty(
name="Minor Segments",
description="Number of segments for the minor ring of the torus",
min=3, max=256,
default=12,
)
mode: EnumProperty(
name="Dimensions Mode",
items=(
('MAJOR_MINOR', "Major/Minor",
"Use the major/minor radii for torus dimensions"),
('EXT_INT', "Exterior/Interior",
"Use the exterior/interior radii for torus dimensions"),
),
update=AddTorus.mode_update_callback,
)
major_radius: FloatProperty(
name="Major Radius",
description="Radius from the origin to the center of the cross sections",
soft_min=0.0, soft_max=100.0,
min=0.0, max=10_000.0,
default=1.0,
subtype='DISTANCE',
unit='LENGTH',
)
minor_radius: FloatProperty(
name="Minor Radius",
description="Radius of the torus's cross section",
soft_min=0.0, soft_max=100.0,
min=0.0, max=10_000.0,
default=0.25,
subtype='DISTANCE',
unit='LENGTH',
)
abso_major_rad: FloatProperty(
name="Exterior Radius",
description="Total Exterior Radius of the torus",
soft_min=0.0, soft_max=100.0,
min=0.0, max=10_000.0,
default=1.25,
subtype='DISTANCE',
unit='LENGTH',
)
abso_minor_rad: FloatProperty(
name="Interior Radius",
description="Total Interior Radius of the torus",
soft_min=0.0, soft_max=100.0,
min=0.0, max=10_000.0,
default=0.75,
subtype='DISTANCE',
unit='LENGTH',
)
generate_uvs: BoolProperty(
name="Generate UVs",
description="Generate a default UV map",
default=True,
)
def draw(self, _context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
layout.separator()
layout.prop(self, "major_segments")
layout.prop(self, "minor_segments")
layout.separator()
layout.prop(self, "mode")
if self.mode == 'MAJOR_MINOR':
layout.prop(self, "major_radius")
layout.prop(self, "minor_radius")
else:
layout.prop(self, "abso_major_rad")
layout.prop(self, "abso_minor_rad")
layout.separator()
layout.prop(self, "generate_uvs")
layout.prop(self, "align")
layout.prop(self, "location")
layout.prop(self, "rotation")
def invoke(self, context, _event):
object_utils.object_add_grid_scale_apply_operator(self, context)
return self.execute(context)
def execute(self, context):
if self.mode == 'EXT_INT':
extra_helper = (self.abso_major_rad - self.abso_minor_rad) * 0.5
self.major_radius = self.abso_minor_rad + extra_helper
self.minor_radius = extra_helper
verts_loc, faces = add_torus(
self.major_radius,
self.minor_radius,
self.major_segments,
self.minor_segments,
)
mesh = bpy.data.meshes.new(data_("Torus"))
mesh.vertices.add(len(verts_loc) // 3)
nbr_loops = len(faces)
nbr_polys = nbr_loops // 4
mesh.loops.add(nbr_loops)
mesh.polygons.add(nbr_polys)
mesh.vertices.foreach_set("co", verts_loc)
mesh.polygons.foreach_set("loop_start", range(0, nbr_loops, 4))
mesh.loops.foreach_set("vertex_index", faces)
mesh.shade_flat()
if self.generate_uvs:
add_uvs(mesh, self.minor_segments, self.major_segments)
mesh.update()
object_utils.object_data_add(context, mesh, operator=self)
return {'FINISHED'}
classes = (
AddTorus,
)

View File

@@ -0,0 +1,923 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import bpy
from bpy.types import Operator
from bpy.props import (
IntProperty,
BoolProperty,
EnumProperty,
StringProperty,
)
from bpy.app.translations import (
pgettext_rpt as rpt_,
contexts as i18n_contexts,
)
class ANIM_OT_keying_set_export(Operator):
"""Export Keying Set to a Python script"""
bl_idname = "anim.keying_set_export"
bl_label = "Export Keying Set..."
filepath: StringProperty(
subtype='FILE_PATH',
)
filter_folder: BoolProperty(
name="Filter folders",
default=True,
options={'HIDDEN'},
)
filter_text: BoolProperty(
name="Filter text",
default=True,
options={'HIDDEN'},
)
filter_python: BoolProperty(
name="Filter Python",
default=True,
options={'HIDDEN'},
)
def execute(self, context):
from bpy.utils import escape_identifier
if not self.filepath:
raise Exception("Filepath not set")
f = open(self.filepath, "w", encoding="utf8")
if not f:
raise Exception("Could not open file")
scene = context.scene
ks = scene.keying_sets.active
f.write("# Keying Set: {:s}\n".format(ks.bl_idname))
f.write("import bpy\n\n")
f.write("scene = bpy.context.scene\n\n")
# Add KeyingSet and set general settings
f.write("# Keying Set Level declarations\n")
f.write("ks = scene.keying_sets.new(idname={!r}, name={!r})\n".format(ks.bl_idname, ks.bl_label))
f.write("ks.bl_description = {!r}\n".format(ks.bl_description))
# TODO: this isn't editable, it should be possible to set this flag for `scene.keying_sets.new`.
# if not ks.is_path_absolute:
# f.write("ks.is_path_absolute = False\n")
f.write("\n")
f.write("ks.use_insertkey_needed = {!r}\n".format(ks.use_insertkey_needed))
f.write("ks.use_insertkey_visual = {!r}\n".format(ks.use_insertkey_visual))
f.write("\n")
# --------------------------------------------------------
# generate and write set of lookups for id's used in paths
# cache for syncing ID-blocks to bpy paths + shorthand's
id_to_paths_cache = {}
for ksp in ks.paths:
if ksp.id is None:
continue
if ksp.id in id_to_paths_cache:
continue
# - `idtype_list` is used to get the list of ID-data-blocks from
# `bpy.data.*` since this info isn't available elsewhere.
# - `id.bl_rna.name` gives a name suitable for UI,
# with a capitalized first letter, but we need
# the plural form that's all lower case.
# - special handling is needed for "nested" ID-blocks
# (e.g. node-tree in Material).
if ksp.id.bl_rna.identifier.startswith("ShaderNodeTree"):
# Find material or light using this node tree...
id_bpy_path = "bpy.data.nodes[\"{:s}\"]"
found = False
for mat in bpy.data.materials:
if mat.node_tree == ksp.id:
id_bpy_path = "bpy.data.materials[\"{:s}\"].node_tree".format(escape_identifier(mat.name))
found = True
break
if not found:
for light in bpy.data.lights:
if light.node_tree == ksp.id:
id_bpy_path = "bpy.data.lights[\"{:s}\"].node_tree".format(escape_identifier(light.name))
found = True
break
if not found:
self.report(
{'WARNING'},
rpt_("Could not find material or light using Shader Node Tree - {:s}").format(str(ksp.id)),
)
elif ksp.id.bl_rna.identifier.startswith("CompositorNodeTree"):
# Find compositor node-tree using this node tree.
for scene in bpy.data.scenes:
if scene.compositing_node_group == ksp.id:
id_bpy_path = "bpy.data.scenes[\"{:s}\"].compositing_node_group".format(
escape_identifier(scene.name))
break
else:
self.report(
{'WARNING'},
rpt_("Could not find scene using Compositor Node Tree - {:s}").format(str(ksp.id)),
)
elif ksp.id.bl_rna.name == "Key":
# "keys" conflicts with a Python keyword, hence the simple solution won't work
id_bpy_path = "bpy.data.shape_keys[\"{:s}\"]".format(escape_identifier(ksp.id.name))
else:
idtype_list = ksp.id.bl_rna.name.lower() + "s"
id_bpy_path = "bpy.data.{:s}[\"{:s}\"]".format(idtype_list, escape_identifier(ksp.id.name))
# shorthand ID for the ID-block (as used in the script)
short_id = "id_{:d}".format(len(id_to_paths_cache))
# store this in the cache now
id_to_paths_cache[ksp.id] = [short_id, id_bpy_path]
f.write("# ID's that are commonly used\n")
for id_pair in id_to_paths_cache.values():
f.write("{:s} = {:s}\n".format(id_pair[0], id_pair[1]))
f.write("\n")
# write paths
f.write("# Path Definitions\n")
for ksp in ks.paths:
f.write("ksp = ks.paths.add(")
# id-block + data_path
if ksp.id:
# find the relevant shorthand from the cache
id_bpy_path = id_to_paths_cache[ksp.id][0]
else:
id_bpy_path = "None" # XXX...
f.write("{:s}, {!r}".format(id_bpy_path, ksp.data_path))
# array index settings (if applicable)
if ksp.use_entire_array:
f.write(", index=-1")
else:
f.write(", index={:d}".format(ksp.array_index))
# grouping settings (if applicable)
# NOTE: the current default is KEYINGSET, but if this changes,
# change this code too
if ksp.group_method == 'NAMED':
f.write(", group_method={!r}, group_name={!r}".format(ksp.group_method, ksp.group))
elif ksp.group_method != 'KEYINGSET':
f.write(", group_method={!r}".format(ksp.group_method))
# finish off
f.write(")\n")
f.write("\n")
f.close()
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class NLA_OT_bake(Operator):
"""Bake all selected objects location/scale/rotation animation to an action"""
bl_idname = "nla.bake"
bl_label = "Bake Action"
bl_options = {'REGISTER', 'UNDO'}
frame_start: IntProperty(
name="Start Frame",
description="Start frame for baking",
min=0, max=300000,
default=1,
)
frame_end: IntProperty(
name="End Frame",
description="End frame for baking",
min=1, max=300000,
default=250,
)
step: IntProperty(
name="Frame Step",
description="Number of frames to skip forward while baking each frame",
min=1, max=120,
default=1,
)
only_selected: BoolProperty(
name="Only Selected Bones",
description="Only key selected bones (Pose baking only)",
default=True,
)
visual_keying: BoolProperty(
name="Visual Keying",
description="Keyframe from the final transformations (with constraints applied)",
default=False,
)
clear_constraints: BoolProperty(
name="Clear Local Constraints",
description=(
"Remove all constraints from keyed object/bones. "
"To get a correct bake with this setting Visual Keying should be enabled"
),
default=False,
)
clear_parents: BoolProperty(
name="Clear Parents",
description="Bake animation onto the object then clear parents (objects only)",
default=False,
)
use_current_action: BoolProperty(
name="Overwrite Current Action",
description="Bake animation into current action, instead of creating a new one "
"(useful for baking only part of bones in an armature)",
default=False,
)
clean_curves: BoolProperty(
name="Clean Curves",
description="After baking curves, remove redundant keys",
default=False,
)
bake_types: EnumProperty(
name="Bake Data",
translation_context=i18n_contexts.id_action,
description="Which data's transformations to bake",
options={'ENUM_FLAG'},
items=(
('POSE', "Pose", "Bake bones transformations"),
('OBJECT', "Object", "Bake object transformations"),
),
default={'POSE'},
)
channel_types: EnumProperty(
name="Channels",
description="Which channels to bake",
options={'ENUM_FLAG'},
items=(
('LOCATION', "Location", "Bake location channels"),
('ROTATION', "Rotation", "Bake rotation channels"),
('SCALE', "Scale", "Bake scale channels"),
('BBONE', "B-Bone", "Bake B-Bone channels"),
('PROPS', "Custom Properties", "Bake custom properties"),
),
default={'LOCATION', 'ROTATION', 'SCALE', 'BBONE', 'PROPS'},
)
def execute(self, context):
from bpy_extras import anim_utils
bake_options = anim_utils.BakeOptions(
only_selected=self.only_selected,
do_pose='POSE' in self.bake_types,
do_object='OBJECT' in self.bake_types,
do_visual_keying=self.visual_keying,
do_constraint_clear=self.clear_constraints,
do_parents_clear=self.clear_parents,
do_clean=self.clean_curves,
do_location='LOCATION' in self.channel_types,
do_rotation='ROTATION' in self.channel_types,
do_scale='SCALE' in self.channel_types,
do_bbone='BBONE' in self.channel_types,
do_custom_props='PROPS' in self.channel_types,
)
if bake_options.do_pose and self.only_selected:
pose_bones = context.selected_pose_bones or []
armatures = {pose_bone.id_data for pose_bone in pose_bones}
objects = list(armatures)
else:
objects = context.selected_editable_objects
if bake_options.do_pose and not bake_options.do_object:
pose_object = getattr(context, "pose_object", None)
if pose_object and pose_object not in objects:
# The active object might not be selected, but it is the one in pose mode.
# It can be assumed this pose needs baking.
objects.append(pose_object)
objects = [obj for obj in objects if obj.pose is not None]
object_action_pairs = (
[(obj, getattr(obj.animation_data, "action", None)) for obj in objects]
if self.use_current_action else
[(obj, None) for obj in objects]
)
actions = anim_utils.bake_action_objects(
object_action_pairs,
frames=range(self.frame_start, self.frame_end + 1, self.step),
bake_options=bake_options,
)
if not any(actions):
self.report({'INFO'}, "Nothing to bake")
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, _event):
scene = context.scene
if scene.use_preview_range:
self.frame_start = scene.frame_preview_start
self.frame_end = scene.frame_preview_end
else:
self.frame_start = scene.frame_start
self.frame_end = scene.frame_end
self.bake_types = {'POSE'} if context.mode == 'POSE' else {'OBJECT'}
wm = context.window_manager
return wm.invoke_props_dialog(self)
class ClearUselessActions(Operator):
"""Mark actions with no F-Curves for deletion after save and reload of """ \
"""file preserving \"action libraries\""""
bl_idname = "anim.clear_useless_actions"
bl_label = "Clear Useless Actions"
bl_options = {'REGISTER', 'UNDO'}
only_unused: BoolProperty(
name="Only Unused",
description="Only unused (Fake User only) actions get considered",
default=True,
)
@classmethod
def poll(cls, _context):
return bool(bpy.data.actions)
@staticmethod
def has_fcurves(action: bpy.types.Action) -> bool:
for layer in action.layers:
for strip in layer.strips:
assert strip.type == 'KEYFRAME'
for channelbag in strip.channelbags:
if channelbag.fcurves:
return True
return False
def execute(self, _context):
removed = 0
for action in bpy.data.actions:
# if only user is "fake" user...
if (
(self.only_unused is False) or
(action.use_fake_user and action.users == 1)
):
# if it has F-Curves, then it's a "action library"
# (i.e. walk, wave, jump, etc.)
# and should be left alone as that's what fake users are for!
if not self.has_fcurves(action):
# mark action for deletion
action.user_clear()
removed += 1
self.report({'INFO'}, rpt_("Removed {:d} empty and/or fake-user only Actions").format(removed))
return {'FINISHED'}
class UpdateAnimatedTransformConstraint(Operator):
"""Update f-curves/drivers affecting Transform constraints (use it with files from 2.70 and earlier)"""
bl_idname = "anim.update_animated_transform_constraints"
bl_label = "Update Animated Transform Constraints"
bl_options = {'REGISTER', 'UNDO'}
use_convert_to_radians: BoolProperty(
name="Convert to Radians",
description=(
"Convert f-curves/drivers affecting rotations to radians.\n"
"Warning: Use this only once"
),
default=True,
)
def execute(self, context):
import _animsys_refactor as animsys_refactor
from math import radians
import io
from_paths = {"from_max_x", "from_max_y", "from_max_z", "from_min_x", "from_min_y", "from_min_z"}
to_paths = {"to_max_x", "to_max_y", "to_max_z", "to_min_x", "to_min_y", "to_min_z"}
paths = from_paths | to_paths
def update_cb(base, _class_name, old_path, fcurve, options):
# print(options)
def handle_deg2rad(fcurve):
if fcurve is not None:
if hasattr(fcurve, "keyframes"):
for k in fcurve.keyframes:
k.co.y = radians(k.co.y)
for mod in fcurve.modifiers:
if mod.type == 'GENERATOR':
if mod.mode == 'POLYNOMIAL':
mod.coefficients[:] = [radians(c) for c in mod.coefficients]
else: # if mod.type == 'POLYNOMIAL_FACTORISED':
mod.coefficients[:2] = [radians(c) for c in mod.coefficients[:2]]
elif mod.type == 'FNGENERATOR':
mod.amplitude = radians(mod.amplitude)
fcurve.update()
data = ...
try:
data = eval("base." + old_path)
except Exception:
pass
ret = (data, old_path)
if isinstance(base, bpy.types.TransformConstraint) and data is not ...:
new_path = None
map_info = base.map_from if old_path in from_paths else base.map_to
if map_info == 'ROTATION':
new_path = old_path + "_rot"
if options is not None and options["use_convert_to_radians"]:
handle_deg2rad(fcurve)
elif map_info == 'SCALE':
new_path = old_path + "_scale"
if new_path is not None:
data = ...
try:
data = eval("base." + new_path)
except Exception:
pass
ret = (data, new_path)
# print(ret)
return ret
options = {"use_convert_to_radians": self.use_convert_to_radians}
replace_ls = [("TransformConstraint", p, update_cb, options) for p in paths]
log = io.StringIO()
animsys_refactor.update_data_paths(replace_ls, log)
context.scene.frame_set(context.scene.frame_current)
log = log.getvalue()
if log:
print(log)
text = bpy.data.texts.new("UpdateAnimatedTransformConstraint Report")
text.from_string(log)
self.report({'INFO'}, rpt_("Complete report available on '{:s}' text data-block").format(text.name))
return {'FINISHED'}
class ARMATURE_OT_copy_bone_color_to_selected(Operator):
"""Copy the bone color of the active bone to all selected bones"""
bl_idname = "armature.copy_bone_color_to_selected"
bl_label = "Copy Colors to Selected"
bl_options = {'REGISTER', 'UNDO'}
_bone_type_enum = [
('EDIT', "Bone", "Copy Bone colors from the active bone to all selected bones"),
('POSE', "Pose Bone", "Copy Pose Bone colors from the active pose bone to all selected pose bones"),
]
bone_type: EnumProperty(
name="Type",
items=_bone_type_enum,
)
@classmethod
def poll(cls, context):
return context.mode in {'EDIT_ARMATURE', 'POSE'}
def execute(self, context):
match(self.bone_type, context.mode):
# Armature in edit mode:
case('POSE', 'EDIT_ARMATURE'):
self.report({'ERROR'}, "Go to pose mode to copy pose bone colors")
return {'OPERATOR_CANCELLED'}
case('EDIT', 'EDIT_ARMATURE'):
bone_source = context.active_bone
bones_dest = context.selected_bones
pose_bones_to_check = []
# Armature in pose mode:
case('POSE', 'POSE'):
bone_source = context.active_pose_bone
bones_dest = context.selected_pose_bones
pose_bones_to_check = []
case('EDIT', 'POSE'):
bone_source = context.active_bone
pose_bones_to_check = context.selected_pose_bones
bones_dest = [posebone.bone for posebone in pose_bones_to_check]
# Anything else:
case _:
self.report({'ERROR'}, rpt_("Cannot do anything in mode {!r}").format(context.mode))
return {'CANCELLED'}
if not bone_source:
self.report({'ERROR'}, "No active bone to copy from")
return {'CANCELLED'}
if not bones_dest:
self.report({'ERROR'}, "No selected bones to copy to")
return {'CANCELLED'}
num_pose_color_overrides = 0
for index, bone_dest in enumerate(bones_dest):
bone_dest.color.palette = bone_source.color.palette
for custom_field in ("normal", "select", "active"):
color = getattr(bone_source.color.custom, custom_field)
setattr(bone_dest.color.custom, custom_field, color)
if self.bone_type == 'EDIT' and pose_bones_to_check:
pose_bone = pose_bones_to_check[index]
if pose_bone.color.palette != 'DEFAULT':
# A pose color has been set, and we're now syncing edit bone
# colors. This means that the synced color will not be
# visible. Better to let the user know about this.
num_pose_color_overrides += 1
if num_pose_color_overrides:
self.report(
{'INFO'},
rpt_("Bone colors were synced; "
"for {:d} bones this will not be visible due to pose bone color overrides").format(
num_pose_color_overrides,
),
)
return {'FINISHED'}
def _armature_from_context(context):
pin_armature = getattr(context, "armature", None)
if pin_armature:
return pin_armature
ob = context.object
if ob and ob.type == 'ARMATURE':
return ob.data
return None
class ARMATURE_OT_collection_show_all(Operator):
"""Show all bone collections"""
bl_idname = "armature.collection_show_all"
bl_label = "Show All"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return _armature_from_context(context) is not None
def execute(self, context):
arm = _armature_from_context(context)
for bcoll in arm.collections_all:
bcoll.is_visible = True
return {'FINISHED'}
class ARMATURE_OT_collection_unsolo_all(Operator):
"""Clear the 'solo' setting on all bone collections"""
bl_idname = "armature.collection_unsolo_all"
bl_label = "Un-solo All"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = _armature_from_context(context)
if not armature:
return False
if not armature.collections.is_solo_active:
cls.poll_message_set("None of the bone collections is marked 'solo'")
return False
return True
def execute(self, context):
arm = _armature_from_context(context)
for bcoll in arm.collections_all:
bcoll.is_solo = False
return {'FINISHED'}
class ARMATURE_OT_collection_remove_unused(Operator):
"""Remove all bone collections that have neither bones nor children. """ \
"""This is done recursively, so bone collections that only have unused children are also removed"""
bl_idname = "armature.collection_remove_unused"
bl_label = "Remove Unused Bone Collections"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
armature = _armature_from_context(context)
if not armature:
return False
return len(armature.collections) > 0
def execute(self, context):
if context.mode == 'EDIT_ARMATURE':
return self.execute_edit_mode(context)
armature = _armature_from_context(context)
# Build a set of bone collections that don't contain any bones, and
# whose children also don't contain any bones.
bcolls_to_remove = {
bcoll
for bcoll in armature.collections_all
if len(bcoll.bones_recursive) == 0}
if not bcolls_to_remove:
self.report({'INFO'}, "All bone collections are in use")
return {'CANCELLED'}
self.remove_bcolls(armature, bcolls_to_remove)
return {'FINISHED'}
def execute_edit_mode(self, context):
# BoneCollection.bones_recursive or .bones are not available in armature
# edit mode, because that has a completely separate list of edit bones.
# This is why edit mode needs separate handling.
armature = _armature_from_context(context)
bcolls_with_bones = {
bcoll
for ebone in armature.edit_bones
for bcoll in ebone.collections
}
bcolls_to_remove = []
for root in armature.collections:
self.visit(root, bcolls_with_bones, bcolls_to_remove)
if not bcolls_to_remove:
self.report({'INFO'}, "All bone collections are in use")
return {'CANCELLED'}
self.remove_bcolls(armature, bcolls_to_remove)
return {'FINISHED'}
def visit(self, bcoll, bcolls_with_bones, bcolls_to_remove):
has_bones = bcoll in bcolls_with_bones
for child in bcoll.children:
child_has_bones = self.visit(child, bcolls_with_bones, bcolls_to_remove)
has_bones = has_bones or child_has_bones
if not has_bones:
bcolls_to_remove.append(bcoll)
return has_bones
def remove_bcolls(self, armature, bcolls_to_remove):
# Count things before they get removed.
num_bcolls_before_removal = len(armature.collections_all)
num_bcolls_to_remove = len(bcolls_to_remove)
# Create a copy of bcolls_to_remove so that it doesn't change when we
# remove bone collections.
for bcoll in reversed(list(bcolls_to_remove)):
armature.collections.remove(bcoll)
self.report(
{'INFO'},
rpt_("Removed {:d} of {:d} bone collections").format(
num_bcolls_to_remove,
num_bcolls_before_removal),
)
class ANIM_OT_slot_new_for_id(Operator):
"""Create a new Action Slot for an ID.
Note that _which_ ID should get this slot must be set in the 'animated_id' context pointer, using:
>>> layout.context_pointer_set("animated_id", animated_id)
When the ID already has a slot assigned, the newly-created slot will be
named after it (ensuring uniqueness with a numerical suffix) and any
animation data of the assigned slot will be duplicated for the new slot.
"""
bl_idname = "anim.slot_new_for_id"
bl_label = "New Slot"
bl_description = "Create a new action slot for this data-block, to hold its animation"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
animated_id = getattr(context, "animated_id", None)
if not animated_id:
return False
if not animated_id.animation_data or not animated_id.animation_data.action:
cls.poll_message_set("An action slot can only be created when an action is assigned")
return False
if not animated_id.animation_data.action.is_editable:
cls.poll_message_set("Creating a new Slot is not possible on a linked Action")
return False
return True
def execute(self, context):
animated_id = context.animated_id
adt = animated_id.animation_data
if adt.action_slot:
slot = adt.action_slot.duplicate()
else:
slot_name = adt.last_slot_identifier[2:] or animated_id.name
slot = adt.action.slots.new(animated_id.id_type, slot_name)
adt.action_slot = slot
return {'FINISHED'}
class ANIM_OT_slot_unassign_from_id(Operator):
"""Un-assign the assigned Action Slot from an ID.
Note that _which_ ID should get this slot unassigned must be set in the
"animated_id" context pointer, using:
>>> layout.context_pointer_set("animated_id", animated_id)
"""
bl_idname = "anim.slot_unassign_from_id"
bl_label = "Unassign Slot"
bl_description = "Un-assign the action slot, effectively making this data-block non-animated"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
animated_id = getattr(context, "animated_id", None)
if not animated_id:
return False
if not animated_id.animation_data or not animated_id.animation_data.action_slot:
cls.poll_message_set("This data-block has no Action slot assigned")
return False
return True
def execute(self, context):
animated_id = context.animated_id
animated_id.animation_data.action_slot = None
return {'FINISHED'}
class generic_slot_unassign_mixin:
context_property_name = ""
"""Which context attribute to use to get the to-be-manipulated data-block."""
@classmethod
def poll(cls, context):
slot_user = getattr(context, cls.context_property_name, None)
if not slot_user:
return False
if not slot_user.action_slot:
cls.poll_message_set("No Action slot is assigned, so there is nothing to un-assign")
return False
return True
def execute(self, context):
slot_user = getattr(context, self.context_property_name, None)
slot_user.action_slot = None
return {'FINISHED'}
class ANIM_OT_slot_unassign_from_nla_strip(generic_slot_unassign_mixin, Operator):
"""Un-assign the assigned Action Slot from an NLA strip.
Note that _which_ NLA strip should get this slot unassigned must be set in
the "nla_strip" context pointer, using:
>>> layout.context_pointer_set("nla_strip", nla_strip)
"""
bl_idname = "anim.slot_unassign_from_nla_strip"
bl_label = "Unassign Slot"
bl_description = "Un-assign the action slot from this NLA strip, effectively making it non-animated"
bl_options = {'REGISTER', 'UNDO'}
context_property_name = "nla_strip"
class ANIM_OT_slot_unassign_from_constraint(generic_slot_unassign_mixin, Operator):
"""Un-assign the assigned Action Slot from an Action constraint.
Note that _which_ constraint should get this slot unassigned must be set in
the "constraint" context pointer, using:
>>> layout.context_pointer_set("constraint", constraint)
"""
bl_idname = "anim.slot_unassign_from_constraint"
bl_label = "Unassign Slot"
bl_description = "Un-assign the action slot from this constraint"
bl_options = {'REGISTER', 'UNDO'}
context_property_name = "constraint"
# This is for the versioning from 4.5 to 5.0 and can be removed in 6.0.
class ANIM_OT_version_bone_hide_property(Operator):
bl_idname = "anim.version_bone_hide_property"
bl_label = "Version Bone Hide Property"
bl_description = "Moves any F-Curves for the `hide` property of selected armatures " \
"into the action of the object. This will only operate on the first layer " \
"and strip of the action"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if len(context.selected_objects) == 0:
cls.poll_message_set("No objects selected")
return False
return True
@staticmethod
def find_property_fcurves(channelbag):
fcurves = []
for fcurve in channelbag.fcurves:
if fcurve.data_path.startswith("bones[") and fcurve.data_path.endswith("].hide"):
fcurves.append(fcurve)
return fcurves
def execute(self, context):
from bpy_extras import anim_utils
selected_armatures = []
for arm_ob in context.selected_objects:
if arm_ob.type != 'ARMATURE' or not arm_ob.data:
continue
armature = arm_ob.data
assigned_channelbag = anim_utils.animdata_get_channelbag_for_assigned_slot(armature.animation_data)
if not assigned_channelbag:
# Armature not animated. Cannot have the FCurve we need.
continue
selected_armatures.append(arm_ob)
if not selected_armatures:
self.report({'WARNING'}, rpt_("No animated armatures selected"))
return {'CANCELLED'}
warn = True
modified_armatures = []
# The objects also have to be animated -> have an assigned action + slot.
# This means we know with certainty which action to move the data into.
for arm_ob in selected_armatures:
ob_adt = arm_ob.animation_data
arm_adt = arm_ob.data.animation_data
if warn and (not ob_adt or not ob_adt.action or not ob_adt.action_slot):
self.report({'WARNING'}, rpt_("Not all armature objects have an action and slot assigned"))
# Only warn once.
warn = False
continue
# Only armatures with an action and slot are added to `selected_armatures`.
assert arm_adt is not None
armature_channelbag = anim_utils.action_get_channelbag_for_slot(arm_adt.action, arm_adt.action_slot)
if not armature_channelbag:
continue
fcurves = self.find_property_fcurves(armature_channelbag)
if not fcurves:
# No FCurves for the hide property found.
continue
# An action + slot is assigned, but that doesn't mean there is a layer and a strip.
ob_channelbag = anim_utils.action_ensure_channelbag_for_slot(ob_adt.action, ob_adt.action_slot)
for fcurve in fcurves:
new_path = "pose." + fcurve.data_path
if ob_channelbag.fcurves.find(new_path):
# FCurve for that property already exists.
continue
ob_channelbag.fcurves.new_from_fcurve(fcurve, data_path=new_path)
modified_armatures.append(arm_ob)
if not modified_armatures:
self.report({'WARNING'}, rpt_("No armature animation was modified"))
return {'CANCELLED'}
self.report({'INFO'}, rpt_("Modified the animation of {:d} armatures").format(len(modified_armatures)))
for screen in bpy.data.screens:
for area in screen.areas:
area.tag_redraw()
return {'FINISHED'}
classes = (
ANIM_OT_keying_set_export,
NLA_OT_bake,
ClearUselessActions,
UpdateAnimatedTransformConstraint,
ARMATURE_OT_copy_bone_color_to_selected,
ARMATURE_OT_collection_show_all,
ARMATURE_OT_collection_unsolo_all,
ARMATURE_OT_collection_remove_unused,
ANIM_OT_slot_new_for_id,
ANIM_OT_slot_unassign_from_id,
ANIM_OT_slot_unassign_from_nla_strip,
ANIM_OT_slot_unassign_from_constraint,
ANIM_OT_version_bone_hide_property,
)

View File

@@ -0,0 +1,200 @@
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import bpy
from bpy.types import Operator
from bpy.app.translations import (
pgettext_data as data_,
pgettext_rpt as rpt_,
)
from bpy_extras.asset_utils import (
SpaceAssetInfo,
)
class AssetBrowserMetadataOperator:
@classmethod
def poll(cls, context):
if not SpaceAssetInfo.is_asset_browser_poll(context) or not context.asset:
return False
if not context.asset.local_id:
Operator.poll_message_set(
"Asset metadata from external asset libraries cannot be "
"edited, only assets stored in the current file can"
)
return False
return True
class ASSET_OT_tag_add(AssetBrowserMetadataOperator, Operator):
"""Add a new keyword tag to the active asset"""
bl_idname = "asset.tag_add"
bl_label = "Add Asset Tag"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
active_asset = context.asset
active_asset.metadata.tags.new(data_("Tag"))
return {'FINISHED'}
class ASSET_OT_tag_remove(AssetBrowserMetadataOperator, Operator):
"""Remove an existing keyword tag from the active asset"""
bl_idname = "asset.tag_remove"
bl_label = "Remove Asset Tag"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
active_asset = context.asset
asset_metadata = active_asset.metadata
return asset_metadata.active_tag in range(len(asset_metadata.tags))
def execute(self, context):
active_asset = context.asset
asset_metadata = active_asset.metadata
tag = asset_metadata.tags[asset_metadata.active_tag]
asset_metadata.tags.remove(tag)
asset_metadata.active_tag -= 1
return {'FINISHED'}
class ASSET_OT_open_containing_blend_file(Operator):
"""Open the blend file that contains the active asset"""
bl_idname = "asset.open_containing_blend_file"
bl_label = "Open Blend File"
bl_options = {'REGISTER'}
_process = None # Optional[subprocess.Popen]
@classmethod
def poll(cls, context):
asset = getattr(context, "asset", None)
if not asset:
cls.poll_message_set("No asset selected")
return False
if asset.local_id:
cls.poll_message_set("Selected asset is contained in the current file")
return False
if asset.is_online:
cls.poll_message_set("Selected asset is stored online")
return False
if not asset.owner_asset_library.is_editable:
cls.poll_message_set(
"The asset library this asset belongs to is not editable"
)
return False
# This could become a built-in query, for now this is good enough.
if asset.full_library_path.endswith(".asset.blend"):
cls.poll_message_set(
"Selected asset is contained in a file managed by the asset system, manual edits should be avoided",
)
return False
return True
def execute(self, context):
asset = context.asset
if asset.local_id:
self.report({'WARNING'}, "This asset is stored in the current blend file")
return {'CANCELLED'}
asset_lib_path = asset.full_library_path
self.open_in_new_blender(asset_lib_path)
wm = context.window_manager
self._timer = wm.event_timer_add(0.1, window=context.window)
wm.modal_handler_add(self)
return {'RUNNING_MODAL'}
def modal(self, context, event):
if event.type != 'TIMER':
return {'PASS_THROUGH'}
if self._process is None:
self.report({'ERROR'}, "Unable to find any running process")
self.cancel(context)
return {'CANCELLED'}
returncode = self._process.poll()
if returncode is None:
# Process is still running.
return {'RUNNING_MODAL'}
if returncode:
self.report({'WARNING'}, rpt_("Blender sub-process exited with error code {:d}").format(returncode))
if bpy.ops.asset.library_refresh.poll():
bpy.ops.asset.library_refresh()
self.cancel(context)
return {'FINISHED'}
def cancel(self, context):
wm = context.window_manager
wm.event_timer_remove(self._timer)
def open_in_new_blender(self, filepath):
import subprocess
cli_args = [bpy.app.binary_path, str(filepath)]
self._process = subprocess.Popen(cli_args)
class ASSET_OT_browse_containing_blend_file(Operator):
"""Open the system's file browser with the blend file that contains the active asset"""
bl_idname = "asset.browse_containing_blend_file"
bl_label = "Open File Location"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
asset = getattr(context, "asset", None)
if not asset:
cls.poll_message_set("No asset selected")
return False
if asset.local_id and not bpy.data.filepath:
cls.poll_message_set("Asset local to the current file, which is not saved anywhere")
return False
if asset.is_online:
cls.poll_message_set("Selected asset is stored online")
return False
return True
def execute(self, context):
from pathlib import Path
asset = context.asset
if asset.local_id:
asset_path = Path(bpy.data.filepath)
else:
asset_path = Path(asset.full_library_path)
return bpy.ops.wm.path_open(filepath=str(asset_path.parent))
classes = (
ASSET_OT_tag_add,
ASSET_OT_tag_remove,
ASSET_OT_open_containing_blend_file,
ASSET_OT_browse_containing_blend_file,
)

View File

@@ -0,0 +1,341 @@
# SPDX-FileCopyrightText: 2016-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Utilities to detect the next matching element (vert/edge/face)
# based on an existing pair of elements.
import bmesh
__all__ = (
"select_prev",
"select_next",
)
def other_edges_over_face(e):
# Can yield same edge multiple times, its fine.
for l in e.link_loops:
yield l.link_loop_next.edge
yield l.link_loop_prev.edge
def other_edges_over_edge(e):
# Can yield same edge multiple times, its fine.
for v in e.verts:
for e_other in v.link_edges:
if e_other is not e:
if not e.is_wire:
yield e_other
def verts_from_elem(ele):
ele_type = type(ele)
if ele_type is bmesh.types.BMFace:
return [l.vert for l in ele.loops]
elif ele_type is bmesh.types.BMEdge:
return [v for v in ele.verts]
elif ele_type is bmesh.types.BMVert:
return [ele]
else:
raise TypeError("wrong type")
def edges_from_elem(ele):
ele_type = type(ele)
if ele_type is bmesh.types.BMFace:
return [l.edge for l in ele.loops]
elif ele_type is bmesh.types.BMEdge:
return [ele]
elif ele_type is bmesh.types.BMVert:
return [e for e in ele.link_edges]
else:
raise TypeError("wrong type")
def elems_depth_search(ele_init, depths, other_edges_over_cb, results_init=None):
"""
List of depths -> List of elems that match those depths.
"""
depth_max = max(depths)
depth_min = min(depths)
depths_sorted = tuple(sorted(depths))
stack_old = edges_from_elem(ele_init)
stack_new = []
stack_visit = set(stack_old)
vert_depths = {}
vert_depths_setdefault = vert_depths.setdefault
depth = 0
while stack_old and depth <= depth_max:
for ele in stack_old:
for v in verts_from_elem(ele):
vert_depths_setdefault(v, depth)
for ele_other in other_edges_over_cb(ele):
stack_visit_len = len(stack_visit)
stack_visit.add(ele_other)
if stack_visit_len != len(stack_visit):
stack_new.append(ele_other)
stack_new, stack_old = stack_old, stack_new
stack_new[:] = []
depth += 1
# now we have many verts in vert_depths which are attached to elements
# which are candidates for matching with depths
if type(ele_init) is bmesh.types.BMFace:
test_ele = {
l.face for v, depth in vert_depths.items()
if depth >= depth_min for l in v.link_loops
}
elif type(ele_init) is bmesh.types.BMEdge:
test_ele = {
e for v, depth in vert_depths.items()
if depth >= depth_min for e in v.link_edges if not e.is_wire
}
else:
test_ele = {
v for v, depth in vert_depths.items()
if depth >= depth_min
}
result_ele = set()
vert_depths_get = vert_depths.get
# re-used each time, will always be the same length
depths_test = [None] * len(depths)
for ele in test_ele:
verts_test = verts_from_elem(ele)
if len(verts_test) != len(depths):
continue
if results_init is not None and ele not in results_init:
continue
if ele in result_ele:
continue
ok = True
for i, v in enumerate(verts_test):
depth = vert_depths_get(v)
if depth is not None:
depths_test[i] = depth
else:
ok = False
break
if ok:
if depths_sorted == tuple(sorted(depths_test)):
# Note, its possible the order of sorted items moves the values out-of-order.
# for this we could do a circular list comparison,
# however - this is such a rare case that we're ignoring it.
result_ele.add(ele)
return result_ele
def elems_depth_measure(ele_dst, ele_src, other_edges_over_cb):
"""
Returns·ele_dst vert depths from ele_src, aligned with ele_dst verts.
"""
stack_old = edges_from_elem(ele_src)
stack_new = []
stack_visit = set(stack_old)
# continue until we've reached all verts in the destination
ele_dst_verts = verts_from_elem(ele_dst)
all_dst = set(ele_dst_verts)
all_dst_discard = all_dst.discard
vert_depths = {}
depth = 0
while stack_old and all_dst:
for ele in stack_old:
for v in verts_from_elem(ele):
len_prev = len(all_dst)
all_dst_discard(v)
if len_prev != len(all_dst):
vert_depths[v] = depth
for ele_other in other_edges_over_cb(ele):
stack_visit_len = len(stack_visit)
stack_visit.add(ele_other)
if stack_visit_len != len(stack_visit):
stack_new.append(ele_other)
stack_new, stack_old = stack_old, stack_new
stack_new[:] = []
depth += 1
if not all_dst:
return [vert_depths[v] for v in ele_dst_verts]
else:
return None
def find_next(ele_dst, ele_src):
depth_src_a = elems_depth_measure(ele_dst, ele_src, other_edges_over_edge)
depth_src_b = elems_depth_measure(ele_dst, ele_src, other_edges_over_face)
# path not found
if depth_src_a is None or depth_src_b is None:
return []
depth_src = tuple(zip(depth_src_a, depth_src_b))
candidates = elems_depth_search(ele_dst, depth_src_a, other_edges_over_edge)
candidates = elems_depth_search(ele_dst, depth_src_b, other_edges_over_face, candidates)
candidates.discard(ele_src)
candidates.discard(ele_dst)
if not candidates:
return []
# Now we have to pick which is the best next-element,
# do this by calculating the element with the largest
# variation in depth from the relationship to the source.
# ... So we have the highest chance of stepping onto the opposite element.
diff_best = 0
ele_best = None
ele_best_ls = []
for ele_test in candidates:
depth_test_a = elems_depth_measure(ele_dst, ele_test, other_edges_over_edge)
depth_test_b = elems_depth_measure(ele_dst, ele_test, other_edges_over_face)
if depth_test_a is None or depth_test_b is None:
continue
depth_test = tuple(zip(depth_test_a, depth_test_b))
# square so a few high values win over many small ones
diff_test = sum(
(abs(a[0] - b[0]) ** 2) +
(abs(a[1] - b[1]) ** 2) for a, b in zip(depth_src, depth_test)
)
if diff_test > diff_best:
diff_best = diff_test
ele_best = ele_test
ele_best_ls[:] = [ele_best]
elif diff_test == diff_best:
if ele_best is None:
ele_best = ele_test
ele_best_ls.append(ele_test)
if len(ele_best_ls) > 1:
ele_best_ls_init = ele_best_ls
ele_best_ls = []
depth_accum_max = -1
for ele_test in ele_best_ls_init:
depth_test_a = elems_depth_measure(ele_src, ele_test, other_edges_over_edge)
depth_test_b = elems_depth_measure(ele_src, ele_test, other_edges_over_face)
if depth_test_a is None or depth_test_b is None:
continue
depth_accum_test = (
sum(depth_test_a) + sum(depth_test_b))
if depth_accum_test > depth_accum_max:
depth_accum_max = depth_accum_test
ele_best = ele_test
ele_best_ls[:] = [ele_best]
elif depth_accum_test == depth_accum_max:
# we have multiple bests, don't return any
ele_best_ls.append(ele_test)
return ele_best_ls
# expose for operators
def select_next(bm, report):
ele_pair = [None, None]
for i, ele in enumerate(reversed(bm.select_history)):
ele_pair[i] = ele
if i == 1:
break
if ele_pair[-1] is None:
report({'INFO'}, "Selection pair not found")
return False
ele_pair_next = find_next(*ele_pair)
if len(ele_pair_next) > 1:
# We have multiple options,
# check topology around the element and find the closest match
# (allow for sloppy comparison if exact checks fail).
def ele_uuid(ele):
ele_type = type(ele)
if ele_type is bmesh.types.BMFace:
ret = [len(f.verts) for l in ele.loops for f in l.edge.link_faces if f is not ele]
elif ele_type is bmesh.types.BMEdge:
ret = [len(l.face.verts) for l in ele.link_loops]
elif ele_type is bmesh.types.BMVert:
ret = [len(l.face.verts) for l in ele.link_loops]
else:
raise TypeError("wrong type")
return tuple(sorted(ret))
def ele_uuid_filter():
def pass_fn(seq):
return seq
def sum_set(seq):
return sum(set(seq))
uuid_cmp = ele_uuid(ele_pair[0])
ele_pair_next_uuid = [(ele, ele_uuid(ele)) for ele in ele_pair_next]
# Attempt to find the closest match,
# start specific, use increasingly more approximate comparisons.
for fn in (pass_fn, set, sum_set, len):
uuid_cmp_test = fn(uuid_cmp)
ele_pair_next_uuid_test = [
(ele, uuid) for (ele, uuid) in ele_pair_next_uuid
if uuid_cmp_test == fn(uuid)
]
if len(ele_pair_next_uuid_test) > 1:
ele_pair_next_uuid = ele_pair_next_uuid_test
elif len(ele_pair_next_uuid_test) == 1:
return [ele for (ele, uuid) in ele_pair_next_uuid_test]
return []
ele_pair_next[:] = ele_uuid_filter()
del ele_uuid, ele_uuid_filter
if len(ele_pair_next) != 1:
report({'INFO'}, "No single next item found")
return False
ele = ele_pair_next[0]
if ele.hide:
report({'INFO'}, "Next element is hidden")
return False
ele.select_set(False)
ele.select_set(True)
bm.select_history.discard(ele)
bm.select_history.add(ele)
if type(ele) is bmesh.types.BMFace:
bm.faces.active = ele
return True
def select_prev(bm, report):
import bmesh
for ele in reversed(bm.select_history):
break
else:
report({'INFO'}, "Last selected not found")
return False
ele.select_set(False)
for i, ele in enumerate(reversed(bm.select_history)):
if i == 1:
if type(ele) is bmesh.types.BMFace:
bm.faces.active = ele
break
return True

View File

@@ -0,0 +1,443 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import (
Operator,
PropertyGroup,
)
from bpy.props import (
StringProperty,
IntProperty,
EnumProperty,
BoolProperty,
CollectionProperty,
)
# Note: The UI classes are stored in bl_ui/properties_data_armature.py
# Data Structure ##############################################################
# Note: bones are stored by name, this means that if the bone is renamed,
# there can be problems. However, bone renaming is unlikely during animation.
class SelectionEntry(PropertyGroup):
__slots__ = ()
name: StringProperty(name="Bone Name", override={'LIBRARY_OVERRIDABLE'})
class SelectionSet(PropertyGroup):
__slots__ = ()
name: StringProperty(name="Set Name", override={'LIBRARY_OVERRIDABLE'})
bone_ids: CollectionProperty(
type=SelectionEntry,
override={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}
)
is_selected: BoolProperty(
name="Include this selection set when copying to the clipboard. "
"If none are specified, all sets will be copied.",
override={'LIBRARY_OVERRIDABLE'},
)
# Operators ##############################################################
class _PoseModeOnlyMixin:
"""Operator only available for objects of type armature in pose mode."""
@classmethod
def poll(cls, context):
return (
context.object and
context.object.type == 'ARMATURE' and
context.mode == 'POSE'
)
class _NeedSelSetMixin(_PoseModeOnlyMixin):
"""Operator only available if the armature has a selected selection set."""
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
arm = context.object
return 0 <= arm.active_selection_set < len(arm.selection_sets)
class POSE_OT_selection_set_delete_all(_PoseModeOnlyMixin, Operator):
bl_idname = "pose.selection_set_delete_all"
bl_label = "Delete All Sets"
bl_description = "Remove all Selection Sets from this Armature"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
arm = context.object
arm.selection_sets.clear()
return {'FINISHED'}
class POSE_OT_selection_set_remove_bones(_PoseModeOnlyMixin, Operator):
bl_idname = "pose.selection_set_remove_bones"
bl_label = "Remove Selected Bones from All Sets"
bl_description = "Remove the selected bones from all Selection Sets"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
arm = context.object
# Iterate only the selected bones in current pose that are not hidden.
for bone in context.selected_pose_bones:
for selset in arm.selection_sets:
if bone.name in selset.bone_ids:
idx = selset.bone_ids.find(bone.name)
selset.bone_ids.remove(idx)
return {'FINISHED'}
class POSE_OT_selection_set_move(_NeedSelSetMixin, Operator):
bl_idname = "pose.selection_set_move"
bl_label = "Move Selection Set in List"
bl_description = "Move the active Selection Set up/down the list of sets"
bl_options = {'UNDO', 'REGISTER'}
direction: EnumProperty(
name="Move Direction",
description="Direction to move the active Selection Set: UP (default) or DOWN",
items=[
('UP', "Up", "", -1),
('DOWN', "Down", "", 1),
],
default='UP',
options={'HIDDEN'},
)
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
arm = context.object
return len(arm.selection_sets) > 1
def execute(self, context):
arm = context.object
active_idx = arm.active_selection_set
new_idx = active_idx + (-1 if self.direction == 'UP' else 1)
if new_idx < 0 or new_idx >= len(arm.selection_sets):
return {'FINISHED'}
arm.selection_sets.move(active_idx, new_idx)
arm.active_selection_set = new_idx
return {'FINISHED'}
class POSE_OT_selection_set_add(_PoseModeOnlyMixin, Operator):
bl_idname = "pose.selection_set_add"
bl_label = "Create Selection Set"
bl_description = "Create a new empty Selection Set"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
arm = context.object
sel_sets = arm.selection_sets
new_sel_set = sel_sets.add()
new_sel_set.name = _uniqify("SelectionSet", sel_sets.keys())
# Select newly created set.
arm.active_selection_set = len(sel_sets) - 1
return {'FINISHED'}
class POSE_OT_selection_set_remove(_NeedSelSetMixin, Operator):
bl_idname = "pose.selection_set_remove"
bl_label = "Delete Selection Set"
bl_description = "Remove a Selection Set from this Armature"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
arm = context.object
arm.selection_sets.remove(arm.active_selection_set)
# Change currently active selection set.
numsets = len(arm.selection_sets)
if (arm.active_selection_set > (numsets - 1) and numsets > 0):
arm.active_selection_set = len(arm.selection_sets) - 1
return {'FINISHED'}
class POSE_OT_selection_set_assign(_PoseModeOnlyMixin, Operator):
bl_idname = "pose.selection_set_assign"
bl_label = "Add Bones to Selection Set"
bl_description = "Add selected bones to Selection Set"
bl_options = {'UNDO', 'REGISTER'}
def invoke(self, context, _event):
arm = context.object
if not (arm.active_selection_set < len(arm.selection_sets)):
bpy.ops.wm.call_menu("INVOKE_DEFAULT", name="POSE_MT_selection_set_create")
else:
bpy.ops.pose.selection_set_assign('EXEC_DEFAULT')
return {'FINISHED'}
def execute(self, context):
arm = context.object
act_sel_set = arm.selection_sets[arm.active_selection_set]
# Iterate only the selected bones in current pose that are not hidden.
for bone in context.selected_pose_bones:
if bone.name not in act_sel_set.bone_ids:
bone_id = act_sel_set.bone_ids.add()
bone_id.name = bone.name
return {'FINISHED'}
class POSE_OT_selection_set_unassign(_NeedSelSetMixin, Operator):
bl_idname = "pose.selection_set_unassign"
bl_label = "Remove Bones from Selection Set"
bl_description = "Remove selected bones from Selection Set"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
arm = context.object
act_sel_set = arm.selection_sets[arm.active_selection_set]
# Iterate only the selected bones in current pose that are not hidden.
for bone in context.selected_pose_bones:
if bone.name in act_sel_set.bone_ids:
idx = act_sel_set.bone_ids.find(bone.name)
act_sel_set.bone_ids.remove(idx)
return {'FINISHED'}
class POSE_OT_selection_set_select(_NeedSelSetMixin, Operator):
bl_idname = "pose.selection_set_select"
bl_label = "Select Selection Set"
bl_description = "Select the bones from this Selection Set"
bl_options = {'UNDO', 'REGISTER'}
selection_set_index: IntProperty(
name="Selection Set Index",
default=-1,
description="Which Selection Set to select; -1 uses the active Selection Set",
options={'HIDDEN'},
)
def execute(self, context):
arm = context.object
if self.selection_set_index == -1:
idx = arm.active_selection_set
else:
idx = self.selection_set_index
sel_set = arm.selection_sets[idx]
for bone in context.visible_pose_bones:
if bone.name in sel_set.bone_ids:
bone.select = True
return {'FINISHED'}
class POSE_OT_selection_set_deselect(_NeedSelSetMixin, Operator):
bl_idname = "pose.selection_set_deselect"
bl_label = "Deselect Selection Set"
bl_description = "Remove Selection Set bones from current selection"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
arm = context.object
act_sel_set = arm.selection_sets[arm.active_selection_set]
for bone in context.selected_pose_bones:
if bone.name in act_sel_set.bone_ids:
bone.select = False
return {'FINISHED'}
class POSE_OT_selection_set_add_and_assign(_PoseModeOnlyMixin, Operator):
bl_idname = "pose.selection_set_add_and_assign"
bl_label = "Create and Add Bones to Selection Set"
bl_description = "Create a new Selection Set with the currently selected bones"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
bpy.ops.pose.selection_set_add('EXEC_DEFAULT')
bpy.ops.pose.selection_set_assign('EXEC_DEFAULT')
return {'FINISHED'}
class POSE_OT_selection_set_copy(_NeedSelSetMixin, Operator):
bl_idname = "pose.selection_set_copy"
bl_label = "Copy Selection Set(s)"
bl_description = "Copy the selected Selection Set(s) to the clipboard"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
context.window_manager.clipboard = _to_json(context)
self.report({'INFO'}, "Copied Selection Set(s) to clipboard")
return {'FINISHED'}
class POSE_OT_selection_set_paste(_PoseModeOnlyMixin, Operator):
bl_idname = "pose.selection_set_paste"
bl_label = "Paste Selection Set(s)"
bl_description = "Add new Selection Set(s) from the clipboard"
bl_options = {'UNDO', 'REGISTER'}
def execute(self, context):
import json
try:
_from_json(context, context.window_manager.clipboard)
except (json.JSONDecodeError, KeyError):
self.report({'ERROR'}, "The clipboard does not contain a Selection Set")
else:
# Select the pasted Selection Set.
context.object.active_selection_set = len(context.object.selection_sets) - 1
return {'FINISHED'}
def _uniqify(name, other_names):
# :param name: The name to make unique.
# :type name: str
# :param other_names: The name to make unique.
# :type other_names: str
# :return: Return a unique name with ``.xxx`` suffix if necessary.
# :rtype: str
#
# Example usage:
#
# >>> _uniqify('hey', ['there'])
# 'hey'
# >>> _uniqify('hey', ['hey.001', 'hey.005'])
# 'hey'
# >>> _uniqify('hey', ['hey', 'hey.001', 'hey.005'])
# 'hey.002'
# >>> _uniqify('hey', ['hey', 'hey.005', 'hey.001'])
# 'hey.002'
# >>> _uniqify('hey', ['hey', 'hey.005', 'hey.001', 'hey.left'])
# 'hey.002'
# >>> _uniqify('hey', ['hey', 'hey.001', 'hey.002'])
# 'hey.003'
#
# It also works with a dict_keys object:
# >>> _uniqify('hey', {'hey': 1, 'hey.005': 1, 'hey.001': 1}.keys())
# 'hey.002'
if name not in other_names:
return name
# Construct the list of numbers already in use.
offset = len(name) + 1
others = (
n[offset:] for n in other_names
if n.startswith(name + '.')
)
numbers = sorted(
int(suffix) for suffix in others
if suffix.isdigit()
)
# Find the first unused number.
min_index = 1
for num in numbers:
if min_index < num:
break
min_index = num + 1
return "{:s}.{:03d}".format(name, min_index)
def _to_json(context):
# Convert the selected Selection Sets of the current rig to JSON.
#
# Selected Sets are the active_selection_set determined by the UIList
# plus any with the is_selected checkbox on.
#
# :return: The selection as JSON data.
# :rtype: str
import json
arm = context.object
active_idx = arm.active_selection_set
json_obj = {}
for idx, sel_set in enumerate(context.object.selection_sets):
if idx == active_idx or sel_set.is_selected:
bones = [bone_id.name for bone_id in sel_set.bone_ids]
json_obj[sel_set.name] = bones
return json.dumps(json_obj)
def _from_json(context, as_json):
# Add the selection sets (one or more) from JSON to the current rig.
#
# :param as_json: The JSON contents to load.
# :type as_json: str
import json
json_obj = json.loads(as_json)
arm_sel_sets = context.object.selection_sets
for name, bones in json_obj.items():
new_sel_set = arm_sel_sets.add()
new_sel_set.name = _uniqify(name, arm_sel_sets.keys())
for bone_name in bones:
bone_id = new_sel_set.bone_ids.add()
bone_id.name = bone_name
# Registry ####################################################################
classes = (
SelectionEntry,
SelectionSet,
POSE_OT_selection_set_delete_all,
POSE_OT_selection_set_remove_bones,
POSE_OT_selection_set_move,
POSE_OT_selection_set_add,
POSE_OT_selection_set_remove,
POSE_OT_selection_set_assign,
POSE_OT_selection_set_unassign,
POSE_OT_selection_set_select,
POSE_OT_selection_set_deselect,
POSE_OT_selection_set_add_and_assign,
POSE_OT_selection_set_copy,
POSE_OT_selection_set_paste,
)
def register():
bpy.types.Object.selection_sets = CollectionProperty(
type=SelectionSet,
name="Selection Sets",
description="List of groups of bones for easy selection",
override={'LIBRARY_OVERRIDABLE', 'USE_INSERTION'}
)
bpy.types.Object.active_selection_set = IntProperty(
name="Active Selection Set",
description="Index of the currently active selection set",
default=0,
override={'LIBRARY_OVERRIDABLE'}
)
def unregister():
del bpy.types.Object.selection_sets
del bpy.types.Object.active_selection_set

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
# SPDX-FileCopyrightText: 2013-2024 Blender Foundation
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import Operator
from bpy.props import BoolProperty
from bpy.app.translations import pgettext_data as data_
from .node_editor.node_functions import (
NodeEditorBase,
node_editor_poll,
node_space_type_poll,
get_group_output_node,
get_output_location,
get_internal_socket,
is_visible_socket,
is_viewer_link,
force_update,
)
class NODE_OT_connect_to_output(Operator, NodeEditorBase):
bl_idname = "node.connect_to_output"
bl_label = "Connect to Output"
bl_description = "Connect active node to the active output node of the node tree"
bl_options = {'REGISTER', 'UNDO'}
# If false, the operator is not executed if the current node group happens to be a geometry nodes group.
# This is needed because geometry nodes has its own viewer node that uses the same shortcut as in the compositor.
run_in_geometry_nodes: BoolProperty(
name="Run in Geometry Nodes Editor",
default=True,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.shader_output_idname = ""
@classmethod
def poll(cls, context):
"""Already implemented natively for compositing nodes."""
return (node_editor_poll(cls, context) and
node_space_type_poll(cls, context, {'ShaderNodeTree', 'GeometryNodeTree'}))
@staticmethod
def get_output_sockets(node_tree):
return [item for item in node_tree.interface.items_tree
if item.item_type == 'SOCKET' and item.in_out == 'OUTPUT']
def init_shader_variables(self, space, shader_type):
"""Get correct output node in shader editor"""
if shader_type == 'OBJECT':
if space.id in bpy.data.lights.values():
self.shader_output_idname = 'ShaderNodeOutputLight'
else:
self.shader_output_idname = 'ShaderNodeOutputMaterial'
elif shader_type == 'WORLD':
self.shader_output_idname = 'ShaderNodeOutputWorld'
def ensure_viewer_socket(self, node_tree, socket_type, connect_socket=None):
"""Check if a viewer output already exists in a node group, otherwise create it"""
viewer_socket = None
output_sockets = self.get_output_sockets(node_tree)
if len(output_sockets):
for i, socket in enumerate(output_sockets):
if socket.is_inspect_output:
# If viewer output is already used but leads to the same socket we can still use it.
is_used = self.has_socket_other_users(socket)
if is_used:
if connect_socket is None:
continue
groupout = get_group_output_node(node_tree)
groupout_input = groupout.inputs[i]
links = groupout_input.links
if connect_socket not in [link.from_socket for link in links]:
continue
viewer_socket = socket
break
if viewer_socket is None:
# Create viewer socket.
viewer_socket = node_tree.interface.new_socket(
data_("(Viewer)"), in_out='OUTPUT', socket_type=socket_type)
viewer_socket.is_inspect_output = True
return viewer_socket
@staticmethod
def ensure_group_output(node_tree):
"""Check if a group output node exists, otherwise create it"""
groupout = get_group_output_node(node_tree)
if groupout is None:
groupout = node_tree.nodes.new('NodeGroupOutput')
loc_x, loc_y = get_output_location(node_tree)
groupout.location.x = loc_x
groupout.location.y = loc_y
groupout.select = False
# So that we don't keep on adding new group outputs.
groupout.is_active_output = True
return groupout
@classmethod
def search_connected_viewer_sockets(cls, output_node, r_sockets, index=None):
"""From an output node, recursively scan node tree for connected viewer sockets"""
for i, input_socket in enumerate(output_node.inputs):
if index and i != index:
continue
if len(input_socket.links):
link = input_socket.links[0]
next_node = link.from_node
external_socket = link.from_socket
if hasattr(next_node, "node_tree"):
for socket_index, socket in enumerate(next_node.node_tree.interface.items_tree):
# Find inside socket matching outside one.
if socket.identifier == external_socket.identifier:
break
if socket.is_inspect_output and socket not in r_sockets:
r_sockets.append(socket)
# Continue search inside of node group but restrict socket to where we came from.
groupout = get_group_output_node(next_node.node_tree)
cls.search_connected_viewer_sockets(groupout, r_sockets, index=socket_index)
@classmethod
def search_viewer_sockets_in_tree(cls, tree, r_sockets):
"""Recursively get all viewer sockets in a node tree"""
for node in tree.nodes:
if hasattr(node, "node_tree"):
if node.node_tree is None:
continue
for socket in cls.get_output_sockets(node.node_tree):
if socket.is_inspect_output and (socket not in r_sockets):
r_sockets.append(socket)
cls.search_viewer_sockets_in_tree(node.node_tree, r_sockets)
@staticmethod
def remove_socket(tree, socket):
interface = tree.interface
interface.remove(socket)
interface.active_index = min(interface.active_index, len(interface.items_tree) - 1)
def link_leads_to_used_socket(self, link):
"""Return True if link leads to a socket that is already used in this node"""
socket = get_internal_socket(link.to_socket)
return socket and self.is_socket_used_active_tree(socket)
def is_socket_used_active_tree(self, socket):
"""Ensure used sockets in active node tree is calculated and check given socket"""
if not hasattr(self, "used_viewer_sockets_active_mat"):
self.used_viewer_sockets_active_mat = []
node_tree = bpy.context.space_data.node_tree
output_node = None
if node_tree.type == 'GEOMETRY':
output_node = get_group_output_node(node_tree)
elif node_tree.type == 'SHADER':
output_node = get_group_output_node(node_tree, output_node_idname=self.shader_output_idname)
if output_node is not None:
self.search_connected_viewer_sockets(output_node, self.used_viewer_sockets_active_mat)
return socket in self.used_viewer_sockets_active_mat
def has_socket_other_users(self, socket):
"""List the other users for this socket (other materials or geometry nodes groups)"""
if not hasattr(self, "other_viewer_sockets_users"):
self.other_viewer_sockets_users = []
if socket.socket_type == 'NodeSocketGeometry':
# This operator can only preview Geometry sockets for geometry nodes,
# so the rest of them are shader nodes.
for obj in bpy.data.objects:
for mod in obj.modifiers:
if mod.type != 'NODES' or mod.node_group == bpy.context.space_data.node_tree:
continue
# Get viewer node.
output_node = get_group_output_node(mod.node_group)
if output_node is not None:
self.search_connected_viewer_sockets(output_node, self.other_viewer_sockets_users)
else:
for mat in bpy.data.materials:
if mat.node_tree == bpy.context.space_data.node_tree or not hasattr(mat.node_tree, "nodes"):
continue
# Get viewer node.
output_node = get_group_output_node(
mat.node_tree,
output_node_idname=self.shader_output_idname,
)
if output_node is not None:
self.search_connected_viewer_sockets(output_node, self.other_viewer_sockets_users)
return socket in self.other_viewer_sockets_users
def get_output_index(self, node, output_node, is_base_node_tree, socket_type, check_type=False):
"""Get the next available output socket in the active node"""
out_i = None
valid_outputs = []
for i, out in enumerate(node.outputs):
if out.select:
return i
if is_visible_socket(out) and (not check_type or out.type == socket_type):
valid_outputs.append(i)
if valid_outputs:
out_i = valid_outputs[0] # Start index of node's outputs.
for i, valid_i in enumerate(valid_outputs):
for out_link in node.outputs[valid_i].links:
if is_viewer_link(out_link, output_node):
if is_base_node_tree or self.link_leads_to_used_socket(out_link):
if i < len(valid_outputs) - 1:
out_i = valid_outputs[i + 1]
else:
out_i = valid_outputs[0]
return out_i
def create_links(self, path, node, active_node_socket_id, socket_type):
"""Create links at each step in the node group path."""
from bpy_extras.node_utils import connect_sockets
path = list(reversed(path))
# Starting from the level of the active node.
for path_index, path_element in enumerate(path[:-1]):
# Ensure there is a viewer node and it has an input.
tree = path_element.node_tree
viewer_socket = self.ensure_viewer_socket(
tree, socket_type,
connect_socket=node.outputs[active_node_socket_id]
if path_index == 0 else None,
)
if viewer_socket in self.delete_sockets:
self.delete_sockets.remove(viewer_socket)
# Connect the current to its viewer.
link_start = node.outputs[active_node_socket_id]
link_end = self.ensure_group_output(tree).inputs[viewer_socket.identifier]
connect_sockets(link_start, link_end)
# Go up in the node group hierarchy.
next_tree = path[path_index + 1].node_tree
node = next(
n for n in next_tree.nodes
if n.type == 'GROUP' and
n.node_tree == tree
)
tree = next_tree
active_node_socket_id = viewer_socket.identifier
return node.outputs[active_node_socket_id]
def cleanup(self):
# Delete sockets.
for socket in self.delete_sockets:
if not self.has_socket_other_users(socket):
tree = socket.id_data
self.remove_socket(tree, socket)
def invoke(self, context, event):
from bpy_extras.node_utils import (
find_base_socket_type,
connect_sockets,
)
space = context.space_data
# Ignore operator when running in wrong context.
if self.run_in_geometry_nodes != (space.tree_type == 'GeometryNodeTree'):
return {'PASS_THROUGH'}
mlocx = event.mouse_region_x
mlocy = event.mouse_region_y
select_node = bpy.ops.node.select(location=(mlocx, mlocy), extend=False, socket_select=True)
if 'FINISHED' not in select_node: # only run if mouse click is on a node.
return {'CANCELLED'}
base_node_tree = space.node_tree
active_tree = context.space_data.edit_tree
path = context.space_data.path
nodes = active_tree.nodes
active = nodes.active
if not active and not any(is_visible_socket(out) for out in active.outputs):
return {'CANCELLED'}
# Scan through all nodes in tree including nodes inside of groups to find viewer sockets.
self.delete_sockets = []
self.search_viewer_sockets_in_tree(base_node_tree, self.delete_sockets)
if not active.outputs:
self.cleanup()
return {'CANCELLED'}
# For geometry node trees, we just connect to the group output.
if space.tree_type == 'GeometryNodeTree':
# Find (or create if needed) the output of this node tree.
output_node = self.ensure_group_output(base_node_tree)
active_node_socket_index = self.get_output_index(
active, output_node, base_node_tree == active_tree, 'GEOMETRY', check_type=True
)
# If there is no 'GEOMETRY' output type - We can't preview the node.
if active_node_socket_index is None:
return {'CANCELLED'}
# Find an input socket of the output of type geometry.
output_node_socket_index = None
for i, inp in enumerate(output_node.inputs):
if inp.type == 'GEOMETRY':
output_node_socket_index = i
break
node_output = active.outputs[active_node_socket_index]
socket_type = find_base_socket_type(node_output)
if output_node_socket_index is None:
output_node_socket_index = self.ensure_viewer_socket(
base_node_tree, socket_type, connect_socket=None,
)
# For shader node trees, we connect to a material output.
elif space.tree_type == 'ShaderNodeTree':
self.init_shader_variables(space, space.shader_type)
# Get or create material_output node.
output_node = get_group_output_node(
base_node_tree,
output_node_idname=self.shader_output_idname,
)
if not output_node:
output_node = base_node_tree.nodes.new(self.shader_output_idname)
output_node.location = get_output_location(base_node_tree)
output_node.select = False
active_node_socket_index = self.get_output_index(
active, output_node, base_node_tree == active_tree, 'SHADER'
)
# Cancel if no socket was found. This can happen for group input
# nodes with only a virtual socket output.
if active_node_socket_index is None:
return {'CANCELLED'}
node_output = active.outputs[active_node_socket_index]
socket_type = find_base_socket_type(node_output)
if node_output.name == "Volume":
output_node_socket_index = 1
else:
output_node_socket_index = 0
# If there are no nested node groups, the link starts at the active node.
if len(path) > 1:
# Recursively connect inside nested node groups and get the one from base level.
node_output = self.create_links(path, active, active_node_socket_index, socket_type)
output_node_input = output_node.inputs[output_node_socket_index]
# Connect at base level.
connect_sockets(node_output, output_node_input)
self.cleanup()
nodes.active = active
active.select = True
force_update(context)
return {'FINISHED'}
classes = (
NODE_OT_connect_to_output,
)

View File

@@ -0,0 +1,156 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import bpy
from bpy.types import Operator
from bpy.props import (
BoolProperty,
StringProperty,
)
from bpy.app.translations import contexts as i18n_contexts
def _lang_module_get(sc):
return __import__(
"_console_" + sc.language,
# for python 3.3, maybe a bug???
level=0,
)
class ConsoleExec(Operator):
"""Execute the current console line as a Python expression"""
bl_idname = "console.execute"
bl_label = "Console Execute"
bl_options = {'UNDO_GROUPED'}
interactive: BoolProperty(
options={'SKIP_SAVE'},
)
@classmethod
def poll(cls, context):
return (context.area and context.area.type == 'CONSOLE')
def execute(self, context):
sc = context.space_data
module = _lang_module_get(sc)
execute = getattr(module, "execute", None)
if execute is not None:
return execute(context, self.interactive)
else:
print("Error: bpy.ops.console.execute_{:s} - not found".format(sc.language))
return {'FINISHED'}
class ConsoleAutocomplete(Operator):
"""Evaluate the namespace up until the cursor and give a list of """ \
"""options or complete the name if there is only one"""
bl_idname = "console.autocomplete"
bl_label = "Console Autocomplete"
@classmethod
def poll(cls, context):
return (context.area and context.area.type == 'CONSOLE')
def execute(self, context):
sc = context.space_data
module = _lang_module_get(sc)
autocomplete = getattr(module, "autocomplete", None)
if autocomplete:
return autocomplete(context)
else:
print("Error: bpy.ops.console.autocomplete_{:s} - not found".format(sc.language))
return {'FINISHED'}
class ConsoleCopyAsScript(Operator):
"""Copy the console contents for use in a script"""
bl_idname = "console.copy_as_script"
bl_label = "Copy to Clipboard (as Script)"
@classmethod
def poll(cls, context):
return (context.area and context.area.type == 'CONSOLE')
def execute(self, context):
sc = context.space_data
module = _lang_module_get(sc)
copy_as_script = getattr(module, "copy_as_script", None)
if copy_as_script:
return copy_as_script(context)
else:
print("Error: copy_as_script - not found for {!r}".format(sc.language))
return {'FINISHED'}
class ConsoleBanner(Operator):
"""Print a message when the terminal initializes"""
bl_idname = "console.banner"
bl_label = "Console Banner"
@classmethod
def poll(cls, context):
return (context.area and context.area.type == 'CONSOLE')
def execute(self, context):
sc = context.space_data
# default to python
if not sc.language:
sc.language = "python"
module = _lang_module_get(sc)
banner = getattr(module, "banner", None)
if banner:
return banner(context)
else:
print("Error: bpy.ops.console.banner_{:s} - not found".format(sc.language))
return {'FINISHED'}
class ConsoleLanguage(Operator):
"""Set the current language for this console"""
bl_idname = "console.language"
bl_label = "Console Language"
language: StringProperty(
name="Language",
translation_context=i18n_contexts.editor_python_console,
maxlen=32,
)
@classmethod
def poll(cls, context):
return (context.area and context.area.type == 'CONSOLE')
def execute(self, context):
sc = context.space_data
# default to python
sc.language = self.language
bpy.ops.console.banner()
# insert a new blank line
bpy.ops.console.history_append(text="", current_character=0, remove_duplicates=True)
return {'FINISHED'}
classes = (
ConsoleAutocomplete,
ConsoleBanner,
ConsoleCopyAsScript,
ConsoleExec,
ConsoleLanguage,
)

View File

@@ -0,0 +1,121 @@
# SPDX-FileCopyrightText: 2018-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
from bpy.types import (
Operator,
)
from bpy.props import (
IntProperty,
)
class CONSTRAINT_OT_add_target(Operator):
"""Add a target to the constraint"""
bl_idname = "constraint.add_target"
bl_label = "Add Target"
bl_options = {'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
constraint = getattr(context, "constraint", None)
return constraint
def execute(self, context):
context.constraint.targets.new()
return {'FINISHED'}
class CONSTRAINT_OT_remove_target(Operator):
"""Remove the target from the constraint"""
bl_idname = "constraint.remove_target"
bl_label = "Remove Target"
bl_options = {'UNDO', 'INTERNAL'}
index: IntProperty()
@classmethod
def poll(cls, context):
constraint = getattr(context, "constraint", None)
return constraint
def execute(self, context):
tgts = context.constraint.targets
tgts.remove(tgts[self.index])
return {'FINISHED'}
class CONSTRAINT_OT_normalize_target_weights(Operator):
"""Normalize weights of all target bones"""
bl_idname = "constraint.normalize_target_weights"
bl_label = "Normalize Weights"
bl_options = {'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
constraint = getattr(context, "constraint", None)
return constraint
def execute(self, context):
tgts = context.constraint.targets
total = sum(t.weight for t in tgts)
if total > 0:
for t in tgts:
t.weight = t.weight / total
return {'FINISHED'}
class CONSTRAINT_OT_disable_keep_transform(Operator):
"""Set the influence of this constraint to zero while """ \
"""trying to maintain the object's transformation. Other active """ \
"""constraints can still influence the final transformation"""
bl_idname = "constraint.disable_keep_transform"
bl_label = "Disable and Keep Transform"
bl_options = {'UNDO', 'INTERNAL'}
@classmethod
def poll(cls, context):
constraint = getattr(context, "constraint", None)
return constraint and constraint.influence > 0.0
def execute(self, context):
"""Disable constraint while maintaining the visual transform"""
# This works most of the time, but when there are multiple constraints active
# there could still be one that overrides the visual transform.
#
# Note that executing this operator and then increasing the constraint
# influence may move the object; this happens when the constraint is
# additive rather than replacing the transform entirely.
# Get the matrix in world space.
is_bone_constraint = context.space_data.context == 'BONE_CONSTRAINT'
ob = context.object
if is_bone_constraint:
bone = context.pose_bone
mat = ob.matrix_world @ bone.matrix
else:
mat = ob.matrix_world
context.constraint.influence = 0.0
# Set the matrix.
if is_bone_constraint:
bone.matrix = ob.matrix_world.inverted() @ mat
else:
ob.matrix_world = mat
return {'FINISHED'}
classes = (
CONSTRAINT_OT_add_target,
CONSTRAINT_OT_remove_target,
CONSTRAINT_OT_normalize_target_weights,
CONSTRAINT_OT_disable_keep_transform,
)

View File

@@ -0,0 +1,823 @@
# SPDX-FileCopyrightText: 2021-2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
Copy Global Transform
Simple operators for copying world-space transforms.
It's called "global" to avoid confusion with the Blender World data-block.
"""
import abc
from typing import Iterable, Optional, Any, TypeAlias
import bpy
from bpy.types import (
Context, Object, Operator, PoseBone,
Camera, ID, ActionChannelbag,
)
from mathutils import Matrix
_axis_enum_items = [
("x", "X", "", 1),
("y", "Y", "", 2),
("z", "Z", "", 3),
]
# Mapping from frame number to the dominant (in terms of genetics) key type.
# GENERATED is the only recessive key type, others are dominant.
KeyInfo: TypeAlias = dict[float, str]
def get_matrix(context: Context) -> Matrix:
bone = context.active_pose_bone
if bone:
# Convert matrix to world space
arm = context.active_object
mat = arm.matrix_world @ bone.matrix
else:
mat = context.active_object.matrix_world
return mat
def set_matrix(context: Context, mat: Matrix) -> None:
from bpy_extras.anim_utils import AutoKeying
bone = context.active_pose_bone
if bone:
# Convert matrix to local space
arm_eval = context.active_object.evaluated_get(context.view_layer.depsgraph)
bone.matrix = arm_eval.matrix_world.inverted() @ mat
AutoKeying.autokey_transformation(context, bone)
else:
context.active_object.matrix_world = mat
AutoKeying.autokey_transformation(context, context.active_object)
def _channelbag_for_id(animated_id: ID) -> ActionChannelbag | None:
# This is on purpose limited to the first layer and strip. To support more
# than 1 layer, a rewrite of the caller is needed.
adt = animated_id.animation_data
action = adt and adt.action
if action is None:
return None
slot = adt.action_slot
for layer in action.layers:
for strip in layer.strips:
assert strip.type == 'KEYFRAME'
channelbag = strip.channelbag(slot)
return channelbag
return None
def _selected_keyframes(context: Context) -> list[float]:
"""Return the list of frame numbers that have a selected key.
Only keys on the active bone/object are considered.
"""
bone = context.active_pose_bone
if bone:
return _selected_keyframes_for_bone(context.active_object, bone)
return _selected_keyframes_for_object(context.active_object)
def _selected_keyframes_for_bone(object: Object, bone: PoseBone) -> list[float]:
"""Return the list of frame numbers that have a selected key.
Only keys on the given pose bone are considered.
"""
name = bpy.utils.escape_identifier(bone.name)
return _selected_keyframes_for_action_slot(object, "pose.bones[\"{:s}\"].".format(name))
def _selected_keyframes_for_object(object: Object) -> list[float]:
"""Return the list of frame numbers that have a selected key.
Only keys on the given object are considered.
"""
return _selected_keyframes_for_action_slot(object, "")
def _selected_keyframes_for_action_slot(object: Object, rna_path_prefix: str) -> list[float]:
"""Return the list of frame numbers that have a selected key.
Only keys on the given object's Action Slot on FCurves starting with rna_path_prefix are considered.
"""
cbag = _channelbag_for_id(object)
if not cbag:
return []
keyframes = set()
for fcurve in cbag.fcurves:
if not fcurve.data_path.startswith(rna_path_prefix):
continue
for kp in fcurve.keyframe_points:
if not kp.select_control_point:
continue
keyframes.add(kp.co.x)
return sorted(keyframes)
def _copy_matrix_to_clipboard(window_manager: bpy.types.WindowManager, matrix: Matrix) -> None:
rows = [" {!r},".format(tuple(row)) for row in matrix]
as_string = "\n".join(rows)
window_manager.clipboard = "Matrix((\n{:s}\n))".format(as_string)
class OBJECT_OT_copy_global_transform(Operator):
bl_idname = "object.copy_global_transform"
bl_label = "Copy Global Transform"
bl_description = (
"Copies the matrix of the currently active object or pose bone to the clipboard. Uses world-space matrices"
)
# This operator cannot be un-done because it manipulates data outside Blender.
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context: Context) -> bool:
return bool(context.active_pose_bone) or bool(context.active_object)
def execute(self, context: Context) -> set[str]:
mat = get_matrix(context)
_copy_matrix_to_clipboard(context.window_manager, mat)
return {'FINISHED'}
def get_relative_ob(context: Context) -> Optional[Object]:
"""Get the 'relative' object.
This is the object that's configured, or if that's empty, the active scene camera.
"""
rel_ob = context.scene.tool_settings.anim_relative_object
return rel_ob or context.scene.camera
class OBJECT_OT_copy_relative_transform(Operator):
bl_idname = "object.copy_relative_transform"
bl_label = "Copy Relative Transform"
bl_description = "Copies the matrix of the currently active object or pose bone to the clipboard. " \
"Uses matrices relative to a specific object or the active scene camera"
# This operator cannot be un-done because it manipulates data outside Blender.
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context: Context) -> bool:
rel_ob = get_relative_ob(context)
if not rel_ob:
return False
return bool(context.active_pose_bone) or bool(context.active_object)
def execute(self, context: Context) -> set[str]:
rel_ob = get_relative_ob(context)
if not rel_ob:
self.report(
{'ERROR'},
"No 'Relative To' object found, set one explicitly or make sure there is an active object")
return {'CANCELLED'}
mat = rel_ob.matrix_world.inverted() @ get_matrix(context)
_copy_matrix_to_clipboard(context.window_manager, mat)
return {'FINISHED'}
class UnableToMirrorError(Exception):
"""Raised when mirroring is enabled but no mirror object/bone is set."""
class OBJECT_OT_paste_transform(Operator):
bl_idname = "object.paste_transform"
bl_label = "Paste Global Transform"
bl_description = (
"Pastes the matrix from the clipboard to the currently active pose bone or object. Uses world-space matrices"
)
bl_options = {'REGISTER', 'UNDO'}
_method_items = [
(
'CURRENT',
"Current Transform",
"Paste onto the current values only, only manipulating the animation data if auto-keying is enabled",
),
(
'EXISTING_KEYS',
"Selected Keys",
"Paste onto frames that have a selected key, potentially creating new keys on those frames",
),
(
'BAKE',
"Bake on Key Range",
"Paste onto all frames between the first and last selected key, creating new keyframes if necessary",
),
]
method: bpy.props.EnumProperty( # type: ignore
items=_method_items,
name="Paste Method",
description="Update the current transform, selected keyframes, or even create new keys",
options={'SKIP_SAVE'},
)
bake_step: bpy.props.IntProperty( # type: ignore
name="Frame Step",
description="Only used for baking. Step=1 creates a key on every frame, step=2 bakes on 2s, etc",
min=1,
soft_min=1,
soft_max=5,
options={'SKIP_SAVE'},
)
use_mirror: bpy.props.BoolProperty( # type: ignore
name="Mirror Transform",
description="When pasting, mirror the transform relative to a specific object or bone",
default=False,
options={'SKIP_SAVE'},
)
mirror_axis_loc: bpy.props.EnumProperty( # type: ignore
items=_axis_enum_items,
name="Location Axis",
description="Coordinate axis used to mirror the location part of the transform",
default='x',
options={'SKIP_SAVE'},
)
mirror_axis_rot: bpy.props.EnumProperty( # type: ignore
items=_axis_enum_items,
name="Rotation Axis",
description="Coordinate axis used to mirror the rotation part of the transform",
default='z',
options={'SKIP_SAVE'},
)
use_relative: bpy.props.BoolProperty( # type: ignore
name="Use Relative Paste",
description="When pasting, assume the pasted matrix is relative to another object (set in the user interface)",
default=False,
options={'SKIP_SAVE'},
)
@classmethod
def poll(cls, context: Context) -> bool:
if not context.active_pose_bone and not context.active_object:
cls.poll_message_set("Select an object or pose bone")
return False
return True
@classmethod
def string_to_matrix(cls, value: str) -> Matrix | None:
if value.startswith("Matrix"):
return cls.parse_matrix(value)
if value.startswith("<Matrix 4x4"):
return cls.parse_repr_m4(value[12:-1])
if value:
return cls.parse_print_m4(value)
return None
@staticmethod
def parse_matrix(value: str) -> Matrix | None:
import ast
try:
return Matrix(ast.literal_eval(value[6:]))
except Exception:
# ast.literal_eval() can raise a slew of exceptions, all of
# which means that it's not a matrix on the clipboard.
return None
@staticmethod
def parse_print_m4(value: str) -> Optional[Matrix]:
"""Parse output from Blender's print_m4() function.
Expects four lines of space-separated floats.
"""
lines = value.strip().splitlines()
if len(lines) != 4:
return None
try:
floats = tuple(tuple(float(item) for item in line.split()) for line in lines)
except ValueError:
# Apparently not the expected format.
return None
return Matrix(floats)
@staticmethod
def parse_repr_m4(value: str) -> Optional[Matrix]:
"""Four lines of (a, b, c, d) floats."""
lines = value.strip().splitlines()
if len(lines) != 4:
return None
try:
floats = tuple(tuple(float(item.strip()) for item in line.strip()[1:-1].split(',')) for line in lines)
except ValueError:
# Apparently not the expected format.
return None
return Matrix(floats)
def execute(self, context: Context) -> set[str]:
clipboard = context.window_manager.clipboard.strip()
mat = self.string_to_matrix(clipboard)
if mat is None:
self.report({'ERROR'}, "Clipboard does not contain a matrix")
return {'CANCELLED'}
try:
mat = self._preprocess_matrix(context, mat)
except UnableToMirrorError:
self.report({'ERROR'}, "Unable to mirror, no mirror object/bone configured")
return {'CANCELLED'}
applicator = {
'CURRENT': self._paste_current,
'EXISTING_KEYS': self._paste_existing_keys,
'BAKE': self._paste_bake,
}[self.method]
return applicator(context, mat)
def _preprocess_matrix(self, context: Context, matrix: Matrix) -> Matrix:
if self.use_relative:
matrix = self._relative_to_world(context, matrix)
if self.use_mirror:
matrix = self._mirror_matrix(context, matrix)
return matrix
def _relative_to_world(self, context: Context, matrix: Matrix) -> Matrix:
rel_ob = get_relative_ob(context)
if not rel_ob:
return matrix
rel_ob_eval = rel_ob.evaluated_get(context.view_layer.depsgraph)
return rel_ob_eval.matrix_world @ matrix
def _mirror_matrix(self, context: Context, matrix: Matrix) -> Matrix:
mirror_ob = context.scene.tool_settings.anim_mirror_object
mirror_bone = context.scene.tool_settings.anim_mirror_bone
# No mirror object means "current armature object".
ctx_ob = context.object
if not mirror_ob and mirror_bone and ctx_ob and ctx_ob.type == 'ARMATURE':
mirror_ob = ctx_ob
if not mirror_ob:
raise UnableToMirrorError()
if mirror_ob.type == 'ARMATURE' and mirror_bone:
return self._mirror_over_bone(matrix, mirror_ob, mirror_bone)
return self._mirror_over_ob(matrix, mirror_ob)
def _mirror_over_ob(self, matrix: Matrix, mirror_ob: bpy.types.Object) -> Matrix:
mirror_matrix = mirror_ob.matrix_world
return self._mirror_over_matrix(matrix, mirror_matrix)
def _mirror_over_bone(self, matrix: Matrix, mirror_ob: bpy.types.Object, mirror_bone_name: str) -> Matrix:
bone = mirror_ob.pose.bones[mirror_bone_name]
mirror_matrix = mirror_ob.matrix_world @ bone.matrix
return self._mirror_over_matrix(matrix, mirror_matrix)
def _mirror_over_matrix(self, matrix: Matrix, mirror_matrix: Matrix) -> Matrix:
# Compute the matrix in the space of the mirror matrix:
mat_local = mirror_matrix.inverted() @ matrix
# Decompose the matrix, as we don't want to touch the scale. This
# operator should only mirror the translation and rotation components.
trans, rot_q, scale = mat_local.decompose()
# Mirror the translation component:
axis_index = ord(self.mirror_axis_loc) - ord('x')
trans[axis_index] *= -1
# Flip the rotation, and use a rotation order that applies the to-be-flipped axes first.
match self.mirror_axis_rot:
case 'x':
rot_e = rot_q.to_euler('XYZ')
rot_e.x *= -1 # Flip the requested rotation axis.
rot_e.y *= -1 # Also flip the bone roll.
case 'y':
rot_e = rot_q.to_euler('YZX')
rot_e.y *= -1 # Flip the requested rotation axis.
rot_e.z *= -1 # Also flip another axis? Not sure how to handle this one.
case 'z':
rot_e = rot_q.to_euler('ZYX')
rot_e.z *= -1 # Flip the requested rotation axis.
rot_e.y *= -1 # Also flip the bone roll.
# Recompose the local matrix:
mat_local = Matrix.LocRotScale(trans, rot_e, scale)
# Go back to world space:
mirrored_world = mirror_matrix @ mat_local
return mirrored_world
@staticmethod
def _paste_current(context: Context, matrix: Matrix) -> set[str]:
set_matrix(context, matrix)
return {'FINISHED'}
def _paste_existing_keys(self, context: Context, matrix: Matrix) -> set[str]:
if not context.scene.tool_settings.use_keyframe_insert_auto:
self.report({'ERROR'}, "This mode requires auto-keying to work properly")
return {'CANCELLED'}
frame_numbers = _selected_keyframes(context)
if not frame_numbers:
self.report({'WARNING'}, "No selected frames found")
return {'CANCELLED'}
self._paste_on_frames(context, frame_numbers, matrix)
return {'FINISHED'}
def _paste_bake(self, context: Context, matrix: Matrix) -> set[str]:
if not context.scene.tool_settings.use_keyframe_insert_auto:
self.report({'ERROR'}, "This mode requires auto-keying to work properly")
return {'CANCELLED'}
bake_step = max(1, self.bake_step)
# Put the clamped bake step back into RNA for the redo panel.
self.bake_step = bake_step
frame_start, frame_end = self._determine_bake_range(context)
frame_range = range(round(frame_start), round(frame_end) + bake_step, bake_step)
self._paste_on_frames(context, frame_range, matrix)
return {'FINISHED'}
def _determine_bake_range(self, context: Context) -> tuple[float, float]:
frame_numbers = _selected_keyframes(context)
if frame_numbers:
# Note that these could be the same frame, if len(frame_numbers) == 1:
return frame_numbers[0], frame_numbers[-1]
if context.scene.use_preview_range:
self.report({'INFO'}, "No selected keys, pasting over preview range")
return context.scene.frame_preview_start, context.scene.frame_preview_end
self.report({'INFO'}, "No selected keys, pasting over scene range")
return context.scene.frame_start, context.scene.frame_end
def _paste_on_frames(self, context: Context, frame_numbers: Iterable[float], matrix: Matrix) -> None:
current_frame = context.scene.frame_current_final
try:
for frame in frame_numbers:
context.scene.frame_set(int(frame), subframe=frame % 1.0)
set_matrix(context, matrix)
finally:
context.scene.frame_set(int(current_frame), subframe=current_frame % 1.0)
class Transformable(metaclass=abc.ABCMeta):
"""Interface for a bone or an object."""
def __init__(self) -> None:
self._key_info_cache: Optional[KeyInfo] = None
@abc.abstractmethod
def matrix_world(self) -> Matrix:
pass
def set_matrix_world(self, context: Context, matrix: Matrix) -> None:
"""Set the world matrix, without auto-keying."""
self._set_matrix_world(context, matrix)
def set_matrix_world_autokey(self, context: Context, matrix: Matrix) -> None:
"""Set the world matrix, and auto-key the resulting transform."""
self._set_matrix_world(context, matrix)
self._autokey_matrix_world(context)
@abc.abstractmethod
def _set_matrix_world(self, context: Context, matrix: Matrix) -> None:
pass
@abc.abstractmethod
def _autokey_matrix_world(self, context: Context) -> None:
pass
@abc.abstractmethod
def _my_fcurves(self) -> Iterable[bpy.types.FCurve]:
pass
def key_info(self) -> KeyInfo:
if self._key_info_cache is not None:
return self._key_info_cache
keyinfo: KeyInfo = {}
for fcurve in self._my_fcurves():
for kp in fcurve.keyframe_points:
frame = kp.co.x
if kp.type == 'GENERATED' and frame in keyinfo:
# Don't bother overwriting other key types.
continue
keyinfo[frame] = kp.type
self._key_info_cache = keyinfo
return keyinfo
def remove_keys_of_type(
self,
key_type: str,
*,
frame_start: float | int = float("-inf"),
frame_end: float | int = float("inf")) -> None:
self._key_info_cache = None
for fcurve in self._my_fcurves():
to_remove = [
kp for kp in fcurve.keyframe_points if kp.type == key_type and (frame_start <= kp.co.x <= frame_end)
]
for kp in reversed(to_remove):
fcurve.keyframe_points.remove(kp, fast=True)
fcurve.keyframe_points.handles_recalc()
class TransformableObject(Transformable):
object: Object
def __init__(self, object: Object) -> None:
super().__init__()
self.object = object
def __str__(self) -> str:
return "TransformableObject({:s})".format(self.object.name)
def matrix_world(self) -> Matrix:
return self.object.matrix_world
def _set_matrix_world(self, _context: Context, matrix: Matrix) -> None:
self.object.matrix_world = matrix
def _autokey_matrix_world(self, context: Context) -> None:
from bpy_extras.anim_utils import AutoKeying
AutoKeying.autokey_transformation(context, self.object)
def __hash__(self) -> int:
return hash(self.object.as_pointer())
def _my_fcurves(self) -> Iterable[bpy.types.FCurve]:
cbag = _channelbag_for_id(self.object)
if not cbag:
return
yield from cbag.fcurves
class TransformableBone(Transformable):
arm_object: Object
pose_bone: PoseBone
def __init__(self, pose_bone: PoseBone) -> None:
super().__init__()
self.arm_object = pose_bone.id_data
self.pose_bone = pose_bone
def __str__(self) -> str:
return "TransformableBone({:s}, bone={:s})".format(self.arm_object.name, self.pose_bone.name)
def matrix_world(self) -> Matrix:
mat = self.arm_object.matrix_world @ self.pose_bone.matrix
return mat
def _set_matrix_world(self, context: Context, matrix: Matrix) -> None:
# Convert matrix to armature-local space
arm_eval = self.arm_object.evaluated_get(context.view_layer.depsgraph)
self.pose_bone.matrix = arm_eval.matrix_world.inverted() @ matrix
def _autokey_matrix_world(self, context: Context) -> None:
from bpy_extras.anim_utils import AutoKeying
AutoKeying.autokey_transformation(context, self.pose_bone)
def __hash__(self) -> int:
return hash(self.pose_bone.as_pointer())
def _my_fcurves(self) -> Iterable[bpy.types.FCurve]:
cbag = _channelbag_for_id(self.arm_object)
if not cbag:
return
rna_prefix = self.pose_bone.path_from_id() + "."
for fcurve in cbag.fcurves:
if fcurve.data_path.startswith(rna_prefix):
yield fcurve
class FixToCameraCommon:
"""Common functionality for the Fix To Scene Camera operator + its 'delete' button."""
keytype = 'GENERATED'
# Operator method stubs to avoid PyLance/MyPy errors:
@classmethod
def poll_message_set(cls, message: str) -> None:
super().poll_message_set(message)
def report(self, level: set[str], message: str) -> None:
super().report(level, message)
# Implement in subclass:
def _execute(self, context: Context, transformables: list[Transformable]) -> None:
raise NotImplementedError()
@classmethod
def poll(cls, context: Context) -> bool:
if not context.active_pose_bone and not context.active_object:
cls.poll_message_set("Select an object or pose bone")
return False
if context.mode not in {'POSE', 'OBJECT'}:
cls.poll_message_set("Switch to Pose or Object mode")
return False
if not context.scene.camera:
cls.poll_message_set("The Scene needs a camera")
return False
return True
def execute(self, context: Context) -> set[str]:
match context.mode:
case 'OBJECT':
transformables = self._transformable_objects(context)
case 'POSE':
transformables = self._transformable_pbones(context)
case mode:
self.report({'ERROR'}, "Unsupported mode: {!r}".format(mode))
return {'CANCELLED'}
restore_frame = context.scene.frame_current
restore_matrices = [(transformable, transformable.matrix_world().copy()) for transformable in transformables]
try:
self._execute(context, transformables)
finally:
# Restore the state of the scene & the transformables. This is necessary
# as not all properties may have been auto-keyed (for example 'only
# available' enabled, and rotation is not actually keyed yet), so we can't
# assume that going to the original frame restores the entire matrix.
context.scene.frame_set(restore_frame)
for transformable, matrix in restore_matrices:
transformable.set_matrix_world(context, matrix)
return {'FINISHED'}
def _transformable_objects(self, context: Context) -> list[Transformable]:
return [TransformableObject(object=ob) for ob in context.selected_editable_objects]
def _transformable_pbones(self, context: Context) -> list[Transformable]:
return [TransformableBone(pose_bone=bone) for bone in context.selected_pose_bones]
class OBJECT_OT_fix_to_camera(FixToCameraCommon, Operator):
bl_idname = "object.fix_to_camera"
bl_label = "Fix to Scene Camera"
bl_description = "Generate new keys to fix the selected object/bone to the camera on unkeyed frames"
bl_options = {'REGISTER', 'UNDO'}
use_location: bpy.props.BoolProperty( # type: ignore
name="Location",
description="Create Location keys when fixing to the scene camera",
default=True,
)
use_rotation: bpy.props.BoolProperty( # type: ignore
name="Rotation",
description="Create Rotation keys when fixing to the scene camera",
default=True,
)
use_scale: bpy.props.BoolProperty( # type: ignore
name="Scale",
description="Create Scale keys when fixing to the scene camera",
default=True,
)
def _get_matrices(self, camera: Camera, transformables: list[Transformable]) -> dict[Transformable, Matrix]:
camera_mat_inv = camera.matrix_world.inverted()
return {t: camera_mat_inv @ t.matrix_world() for t in transformables}
def _execute(self, context: Context, transformables: list[Transformable]) -> None:
from bpy_extras.anim_utils import AutoKeying
from bpy_extras.wm_utils import progress_report
depsgraph = context.view_layer.depsgraph
scene = context.scene
scene.frame_set(scene.frame_start)
camera_eval = scene.camera.evaluated_get(depsgraph)
last_camera_name = scene.camera.name
matrices = self._get_matrices(camera_eval, transformables)
if scene.use_preview_range:
frame_start = scene.frame_preview_start
frame_end = scene.frame_preview_end
else:
frame_start = scene.frame_start
frame_end = scene.frame_end
with (
AutoKeying.options(
keytype=self.keytype,
use_loc=self.use_location,
use_rot=self.use_rotation,
use_scale=self.use_scale,
force_autokey=True,
),
progress_report.ProgressReport(context.window_manager) as progress,
):
frames_to_visit = range(frame_start, frame_end + scene.frame_step, scene.frame_step)
progress.enter_substeps(len(frames_to_visit))
for frame in frames_to_visit:
scene.frame_set(frame)
progress.step()
camera_eval = scene.camera.evaluated_get(depsgraph)
cam_matrix_world = camera_eval.matrix_world
camera_mat_inv = cam_matrix_world.inverted()
if scene.camera.name != last_camera_name:
# The scene camera changed, so the previous
# relative-to-camera matrices can no longer be used.
matrices = self._get_matrices(camera_eval, transformables)
last_camera_name = scene.camera.name
for t, camera_rel_matrix in matrices.items():
key_info = t.key_info()
key_type = key_info.get(frame, "")
if key_type not in {self.keytype, ""}:
# Manually set key, remember the current camera-relative matrix.
matrices[t] = camera_mat_inv @ t.matrix_world()
continue
# No key, or a generated one. Overwrite it with a new transform.
t.set_matrix_world_autokey(context, cam_matrix_world @ camera_rel_matrix)
class OBJECT_OT_delete_fix_to_camera_keys(Operator, FixToCameraCommon):
bl_idname = "object.delete_fix_to_camera_keys"
bl_label = "Delete Generated Keys"
bl_description = "Delete all keys that were generated by the 'Fix to Scene Camera' operator"
bl_options = {'REGISTER', 'UNDO'}
def _execute(self, context: Context, transformables: list[Transformable]) -> None:
scene = context.scene
if scene.use_preview_range:
frame_start = scene.frame_preview_start
frame_end = scene.frame_preview_end
else:
frame_start = scene.frame_start
frame_end = scene.frame_end
for t in transformables:
t.remove_keys_of_type(self.keytype, frame_start=frame_start, frame_end=frame_end)
# MessageBus subscription to monitor changes & refresh panels.
_msgbus_owner = object()
def _refresh_3d_panels():
refresh_area_types = {'VIEW_3D'}
for win in bpy.context.window_manager.windows:
for area in win.screen.areas:
if area.type not in refresh_area_types:
continue
area.tag_redraw()
classes = (
OBJECT_OT_copy_global_transform,
OBJECT_OT_copy_relative_transform,
OBJECT_OT_paste_transform,
OBJECT_OT_fix_to_camera,
OBJECT_OT_delete_fix_to_camera_keys,
)
def _register_message_bus() -> None:
bpy.msgbus.subscribe_rna(
key=(bpy.types.ToolSettings, "use_keyframe_insert_auto"),
owner=_msgbus_owner,
args=(),
notify=_refresh_3d_panels,
options={'PERSISTENT'},
)
def _unregister_message_bus() -> None:
bpy.msgbus.clear_by_owner(_msgbus_owner)
@bpy.app.handlers.persistent # type: ignore
def _on_blendfile_load_post(_none: Any, _other_none: Any) -> None:
# The parameters are required, but both are None.
_register_message_bus()
def register():
bpy.app.handlers.load_post.append(_on_blendfile_load_post)
def unregister():
_unregister_message_bus()
bpy.app.handlers.load_post.remove(_on_blendfile_load_post)

View File

@@ -0,0 +1,308 @@
# SPDX-FileCopyrightText: 2015-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import (
Operator,
OperatorFileListElement,
)
from bpy.props import (
BoolProperty,
CollectionProperty,
StringProperty,
)
from bpy.app.translations import pgettext_rpt as rpt_
# ########## Datablock previews... ##########
class WM_OT_previews_batch_generate(Operator):
"""Generate selected .blend file's previews"""
bl_idname = "wm.previews_batch_generate"
bl_label = "Batch-Generate Previews"
bl_options = {'REGISTER'}
# -----------
# File props.
files: CollectionProperty(
type=OperatorFileListElement,
options={'HIDDEN', 'SKIP_SAVE'},
name="",
description="Collection of file paths with common ``directory`` root",
)
directory: StringProperty(
maxlen=1024,
subtype='FILE_PATH',
options={'HIDDEN', 'SKIP_SAVE'},
name="",
description="Root path of all files listed in ``files`` collection",
)
# Show only images/videos, and directories!
filter_blender: BoolProperty(
default=True,
options={'HIDDEN', 'SKIP_SAVE'},
name="",
description="Show Blender files in the File Browser",
)
filter_folder: BoolProperty(
default=True,
options={'HIDDEN', 'SKIP_SAVE'},
name="",
description="Show folders in the File Browser",
)
# -----------
# Own props.
use_scenes: BoolProperty(
default=True,
name="Scenes",
description="Generate scenes' previews",
)
use_collections: BoolProperty(
default=True,
name="Collections",
description="Generate collections' previews",
)
use_objects: BoolProperty(
default=True,
name="Objects",
description="Generate objects' previews",
)
use_intern_data: BoolProperty(
default=True,
name="Materials & Textures",
description="Generate 'internal' previews (materials, textures, images, etc.)",
)
use_trusted: BoolProperty(
default=False,
name="Trusted Blend Files",
description="Enable Python evaluation for selected files",
)
use_backups: BoolProperty(
default=True,
name="Save Backups",
description="Keep a backup (.blend1) version of the files when saving with generated previews",
)
def invoke(self, context, _event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
import os
import subprocess
from _bl_previews_utils import bl_previews_render as preview_render
context.window_manager.progress_begin(0, len(self.files))
context.window_manager.progress_update(0)
for i, fn in enumerate(self.files):
blen_path = os.path.join(self.directory, fn.name)
cmd = [
bpy.app.binary_path,
"--background",
"--factory-startup",
]
if self.use_trusted:
cmd.append("--enable-autoexec")
cmd.extend([
blen_path,
"--python",
os.path.join(os.path.dirname(preview_render.__file__), "bl_previews_render.py"),
"--",
])
if not self.use_scenes:
cmd.append("--no_scenes")
if not self.use_collections:
cmd.append("--no_collections")
if not self.use_objects:
cmd.append("--no_objects")
if not self.use_intern_data:
cmd.append("--no_data_intern")
if not self.use_backups:
cmd.append("--no_backups")
if subprocess.call(cmd):
self.report({'ERROR'}, rpt_("Previews generation process failed for file '{:s}'!").format(blen_path))
context.window_manager.progress_end()
return {'CANCELLED'}
context.window_manager.progress_update(i + 1)
context.window_manager.progress_end()
return {'FINISHED'}
class WM_OT_previews_batch_clear(Operator):
"""Clear selected .blend file's previews"""
bl_idname = "wm.previews_batch_clear"
bl_label = "Batch-Clear Previews"
bl_options = {'REGISTER'}
# -----------
# File props.
files: CollectionProperty(
type=OperatorFileListElement,
options={'HIDDEN', 'SKIP_SAVE'},
)
directory: StringProperty(
maxlen=1024,
subtype='FILE_PATH',
options={'HIDDEN', 'SKIP_SAVE'},
)
# Show only images/videos, and directories!
filter_blender: BoolProperty(
default=True,
options={'HIDDEN', 'SKIP_SAVE'},
)
filter_folder: BoolProperty(
default=True,
options={'HIDDEN', 'SKIP_SAVE'},
)
# -----------
# Own props.
use_scenes: BoolProperty(
default=True,
name="Scenes",
description="Clear scenes' previews",
)
use_collections: BoolProperty(
default=True,
name="Collections",
description="Clear collections' previews",
)
use_objects: BoolProperty(
default=True,
name="Objects",
description="Clear objects' previews",
)
use_intern_data: BoolProperty(
default=True,
name="Materials & Textures",
description="Clear 'internal' previews (materials, textures, images, etc.)",
)
use_trusted: BoolProperty(
default=False,
name="Trusted Blend Files",
description="Enable Python evaluation for selected files",
)
use_backups: BoolProperty(
default=True,
name="Save Backups",
description="Keep a backup (.blend1) version of the files when saving with cleared previews",
)
def invoke(self, context, _event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
import os
import subprocess
from _bl_previews_utils import bl_previews_render as preview_render
context.window_manager.progress_begin(0, len(self.files))
context.window_manager.progress_update(0)
for i, fn in enumerate(self.files):
blen_path = os.path.join(self.directory, fn.name)
cmd = [
bpy.app.binary_path,
"--background",
"--factory-startup",
]
if self.use_trusted:
cmd.append("--enable-autoexec")
cmd.extend([
blen_path,
"--python",
os.path.join(os.path.dirname(preview_render.__file__), "bl_previews_render.py"),
"--",
"--clear",
])
if not self.use_scenes:
cmd.append("--no_scenes")
if not self.use_collections:
cmd.append("--no_collections")
if not self.use_objects:
cmd.append("--no_objects")
if not self.use_intern_data:
cmd.append("--no_data_intern")
if not self.use_backups:
cmd.append("--no_backups")
if subprocess.call(cmd):
self.report({'ERROR'}, rpt_("Previews clear process failed for file '{:s}'!").format(blen_path))
context.window_manager.progress_end()
return {'CANCELLED'}
context.window_manager.progress_update(i + 1)
context.window_manager.progress_end()
return {'FINISHED'}
class WM_OT_blend_strings_utf8_validate(Operator):
"""Check and fix all strings in current .blend file to be valid UTF-8 Unicode """ \
"""(needed for some old, 2.4x area files)"""
bl_idname = "wm.blend_strings_utf8_validate"
bl_label = "Validate .blend strings"
bl_options = {'REGISTER'}
def validate_strings(self, item, done_items):
if item is None:
return False
if item in done_items:
return False
done_items.add(item)
if getattr(item, "library", None) is not None:
return False # No point in checking library data, we cannot fix it anyway...
changed = False
for prop in item.bl_rna.properties:
if prop.identifier in {"bl_rna", "rna_type"}:
continue # Or we'd recurse 'till Hell freezes.
if prop.is_readonly:
continue
if prop.type == 'STRING':
val_bytes = item.path_resolve(prop.identifier, False).as_bytes()
val_utf8 = val_bytes.decode("utf-8", "replace")
val_bytes_valid = val_utf8.encode("utf-8")
if val_bytes_valid != val_bytes:
print("found bad utf8 encoded string {!r}, fixing to {!r} ({!r})...".format(
val_bytes, val_bytes_valid, val_utf8,
))
setattr(item, prop.identifier, val_utf8)
changed = True
elif prop.type == 'POINTER':
it = getattr(item, prop.identifier)
changed |= self.validate_strings(it, done_items)
elif prop.type == 'COLLECTION':
for it in getattr(item, prop.identifier):
changed |= self.validate_strings(it, done_items)
return changed
def execute(self, _context):
changed = False
done_items = set()
for prop in bpy.data.bl_rna.properties:
if prop.type == 'COLLECTION':
for it in getattr(bpy.data, prop.identifier):
changed |= self.validate_strings(it, done_items)
if changed:
self.report(
{'WARNING'},
"Some strings were fixed, don't forget to save the .blend file to keep those changes",
)
return {'FINISHED'}
classes = (
WM_OT_previews_batch_clear,
WM_OT_previews_batch_generate,
WM_OT_blend_strings_utf8_validate,
)

View File

@@ -0,0 +1,226 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.app.translations import (
pgettext_rpt as rpt_,
)
from bpy.types import (
Operator,
)
from bpy.props import (
BoolProperty,
EnumProperty,
StringProperty,
)
class SCENE_OT_freestyle_fill_range_by_selection(Operator):
"""Fill the Range Min/Max entries by the min/max distance between selected mesh objects and the source object """ \
"""(either a user-specified object or the active camera)"""
bl_idname = "scene.freestyle_fill_range_by_selection"
bl_label = "Fill Range by Selection"
bl_options = {'INTERNAL'}
type: EnumProperty(
name="Type", description="Type of the modifier to work on",
items=(
('COLOR', "Color", "Color modifier type"),
('ALPHA', "Alpha", "Alpha modifier type"),
('THICKNESS', "Thickness", "Thickness modifier type"),
),
)
name: StringProperty(
name="Name",
description="Name of the modifier to work on",
)
@classmethod
def poll(cls, context):
view_layer = context.view_layer
return view_layer and view_layer.freestyle_settings.linesets.active
def execute(self, context):
import sys
scene = context.scene
view_layer = context.view_layer
lineset = view_layer.freestyle_settings.linesets.active
linestyle = lineset.linestyle
# Find the modifier to work on
if self.type == 'COLOR':
m = linestyle.color_modifiers[self.name]
elif self.type == 'ALPHA':
m = linestyle.alpha_modifiers[self.name]
else:
m = linestyle.thickness_modifiers[self.name]
# Find the reference object
if m.type == 'DISTANCE_FROM_CAMERA':
ref = scene.camera
if ref is None:
self.report({'ERROR'}, "No active camera in the scene")
return {'CANCELLED'}
matrix_to_camera = ref.matrix_world.inverted()
elif m.type == 'DISTANCE_FROM_OBJECT':
if m.target is None:
self.report({'ERROR'}, "Target object not specified")
return {'CANCELLED'}
ref = m.target
target_location = ref.location
else:
self.report({'ERROR'}, rpt_("Unexpected modifier type: {:s}").format(m.type))
return {'CANCELLED'}
# Find selected vertices in edit-mesh.
ob = context.active_object
if ob.type == 'MESH' and ob.mode == 'EDIT' and ob.name != ref.name:
bpy.ops.object.mode_set(mode='OBJECT')
selected_verts = [v for v in ob.data.vertices if v.select]
bpy.ops.object.mode_set(mode='EDIT')
# Compute the min/max distance from the reference to mesh vertices
min_dist = sys.float_info.max
max_dist = -min_dist
if m.type == 'DISTANCE_FROM_CAMERA':
ob_to_cam = matrix_to_camera @ ob.matrix_world
for vert in selected_verts:
# dist in the camera space
dist = (ob_to_cam @ vert.co).length
min_dist = min(dist, min_dist)
max_dist = max(dist, max_dist)
elif m.type == 'DISTANCE_FROM_OBJECT':
for vert in selected_verts:
# dist in the world space
dist = (ob.matrix_world @ vert.co - target_location).length
min_dist = min(dist, min_dist)
max_dist = max(dist, max_dist)
# Fill the Range Min/Max entries with the computed distances
m.range_min = min_dist
m.range_max = max_dist
return {'FINISHED'}
# Find selected mesh objects
selection = [ob for ob in scene.objects if ob.select_get() and ob.type == 'MESH' and ob.name != ref.name]
if selection:
# Compute the min/max distance from the reference to mesh vertices
min_dist = sys.float_info.max
max_dist = -min_dist
if m.type == 'DISTANCE_FROM_CAMERA':
for ob in selection:
ob_to_cam = matrix_to_camera @ ob.matrix_world
for vert in ob.data.vertices:
# dist in the camera space
dist = (ob_to_cam @ vert.co).length
min_dist = min(dist, min_dist)
max_dist = max(dist, max_dist)
elif m.type == 'DISTANCE_FROM_OBJECT':
for ob in selection:
for vert in ob.data.vertices:
# dist in the world space
dist = (ob.matrix_world @ vert.co - target_location).length
min_dist = min(dist, min_dist)
max_dist = max(dist, max_dist)
# Fill the Range Min/Max entries with the computed distances
m.range_min = min_dist
m.range_max = max_dist
return {'FINISHED'}
class SCENE_OT_freestyle_add_edge_marks_to_keying_set(Operator):
"""Add the data paths to the Freestyle Edge Mark property of selected edges to the active keying set"""
bl_idname = "scene.freestyle_add_edge_marks_to_keying_set"
bl_label = "Add Edge Marks to Keying Set"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
ob = context.active_object
return (ob and ob.type == 'MESH')
def execute(self, context):
# active keying set
scene = context.scene
ks = scene.keying_sets.active
if ks is None:
ks = scene.keying_sets.new(idname="FreestyleEdgeMarkKeyingSet", name="Freestyle Edge Mark Keying Set")
ks.bl_description = ""
# add data paths to the keying set
ob = context.active_object
ob_mode = ob.mode
mesh = ob.data
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
for i, edge in enumerate(mesh.edges):
if not edge.hide and edge.select:
path = "attributes[\"freestyle_edge\"].data[{:d}].value".format(i)
ks.paths.add(mesh, path, index=0)
bpy.ops.object.mode_set(mode=ob_mode, toggle=False)
return {'FINISHED'}
class SCENE_OT_freestyle_add_face_marks_to_keying_set(Operator):
"""Add the data paths to the Freestyle Face Mark property of selected polygons to the active keying set"""
bl_idname = "scene.freestyle_add_face_marks_to_keying_set"
bl_label = "Add Face Marks to Keying Set"
bl_options = {'UNDO'}
@classmethod
def poll(cls, context):
ob = context.active_object
return (ob and ob.type == 'MESH')
def execute(self, context):
# active keying set
scene = context.scene
ks = scene.keying_sets.active
if ks is None:
ks = scene.keying_sets.new(idname="FreestyleFaceMarkKeyingSet", name="Freestyle Face Mark Keying Set")
ks.bl_description = ""
# add data paths to the keying set
ob = context.active_object
ob_mode = ob.mode
mesh = ob.data
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
for i, polygon in enumerate(mesh.polygons):
if not polygon.hide and polygon.select:
path = "attributes[\"freestyle_face\"].data[{:d}].value".format(i)
ks.paths.add(mesh, path, index=0)
bpy.ops.object.mode_set(mode=ob_mode, toggle=False)
return {'FINISHED'}
class SCENE_OT_freestyle_module_open(Operator):
"""Open a style module file"""
bl_idname = "scene.freestyle_module_open"
bl_label = "Open Style Module File"
bl_options = {'INTERNAL'}
filepath: StringProperty(subtype='FILE_PATH')
make_internal: BoolProperty(
name="Make internal",
description="Make module file internal after loading",
default=True,
)
@classmethod
def poll(cls, context):
view_layer = context.view_layer
return view_layer and view_layer.freestyle_settings.mode == 'SCRIPT'
def invoke(self, context, _event):
self.freestyle_module = context.freestyle_module
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, _context):
text = bpy.data.texts.load(self.filepath, internal=self.make_internal)
self.freestyle_module.script = text
return {'FINISHED'}
classes = (
SCENE_OT_freestyle_add_edge_marks_to_keying_set,
SCENE_OT_freestyle_add_face_marks_to_keying_set,
SCENE_OT_freestyle_fill_range_by_selection,
SCENE_OT_freestyle_module_open,
)

View File

@@ -0,0 +1,384 @@
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import Operator
from bpy.props import BoolProperty
from bpy.app.translations import pgettext_data as data_
def add_empty_geometry_node_group(name, add_geometry_input=True):
group = bpy.data.node_groups.new(name, 'GeometryNodeTree')
if add_geometry_input:
group.interface.new_socket(data_("Geometry"), in_out='INPUT', socket_type='NodeSocketGeometry')
input_node = group.nodes.new('NodeGroupInput')
input_node.select = False
input_node.location.x = -200 - input_node.width
group.interface.new_socket(data_("Geometry"), in_out='OUTPUT', socket_type='NodeSocketGeometry')
output_node = group.nodes.new('NodeGroupOutput')
output_node.is_active_output = True
output_node.select = False
output_node.location.x = 200
return group
def geometry_node_group_empty_new(name, add_geometry_input=True):
group = add_empty_geometry_node_group(name, add_geometry_input)
if add_geometry_input:
group.links.new(group.nodes[data_("Group Input")].outputs[0], group.nodes[data_("Group Output")].inputs[0])
return group
def geometry_node_group_empty_modifier_new(name, add_geometry_input=True):
group = geometry_node_group_empty_new(name, add_geometry_input)
group.is_modifier = True
return group
def geometry_node_group_empty_tool_new(context):
import re
group = geometry_node_group_empty_new(data_("Tool"))
# Node tools have fake users by default, otherwise Blender will delete them since they have no users.
group.use_fake_user = True
group.is_tool = True
# Operator identifier names only support lowercase ASCII characters or numbers.
group.node_tool_idname = "geometry." + re.sub('[^0-9a-z]+', '_', group.name.strip().lower())
ob = context.object
ob_type = ob.type if ob else 'MESH'
if ob_type == 'CURVES':
group.is_type_curve = True
elif ob_type == 'POINTCLOUD':
group.is_type_pointcloud = True
elif ob_type == 'GREASEPENCIL':
group.is_type_grease_pencil = True
else:
group.is_type_mesh = True
mode = ob.mode if ob else 'OBJECT'
if mode in {'SCULPT', 'SCULPT_CURVES', 'SCULPT_GREASE_PENCIL'}:
group.is_mode_sculpt = True
elif mode == 'PAINT_GREASE_PENCIL':
group.is_mode_paint = True
elif mode == 'EDIT':
group.is_mode_edit = True
else:
group.is_mode_object = True
return group
def geometry_modifier_poll(context):
ob = context.object
# Test object support for geometry node modifier
if not ob or ob.type not in {'MESH', 'POINTCLOUD', 'VOLUME', 'CURVE', 'FONT', 'CURVES', 'GREASEPENCIL', 'EMPTY'}:
return False
return True
def get_context_modifier(context):
# Context only has a "modifier" attribute in the modifier extra operators drop-down.
modifier = getattr(context, "modifier", ...)
if modifier is ...:
ob = context.object
if ob is None:
return None
modifier = ob.modifiers.active
if modifier is None or modifier.type != 'NODES':
return None
return modifier
def edit_geometry_nodes_modifier_poll(context):
modifier = get_context_modifier(context)
if modifier is None:
return False
return modifier.id_data.is_editable
def socket_idname_to_attribute_type(idname):
if idname.startswith("NodeSocketInt"):
return 'INT'
elif idname.startswith("NodeSocketColor"):
return 'FLOAT_COLOR'
elif idname.startswith("NodeSocketVector"):
return 'FLOAT_VECTOR'
elif idname.startswith("NodeSocketBool"):
return 'BOOLEAN'
elif idname.startswith("NodeSocketFloat"):
return 'FLOAT'
raise ValueError("Unsupported socket type")
def get_socket_with_identifier(sockets, identifier):
for socket in sockets:
if socket.identifier == identifier:
return socket
return None
def get_enabled_socket_with_name(sockets, name):
for socket in sockets:
if socket.name == name and socket.enabled:
return socket
return None
def create_wrapper_group(operator, modifier, old_group):
wrapper_name = old_group.name + ".wrapper"
group = bpy.data.node_groups.new(wrapper_name, 'GeometryNodeTree')
group.interface.new_socket(data_("Geometry"), in_out='OUTPUT', socket_type='NodeSocketGeometry')
group.is_modifier = True
first_geometry_input = next(
(
item for item in old_group.interface.items_tree if item.item_type == 'SOCKET' and
item.in_out == 'INPUT' and
item.bl_socket_idname == 'NodeSocketGeometry'
),
None,
)
if first_geometry_input:
group.interface.new_socket(data_("Geometry"), in_out='INPUT', socket_type='NodeSocketGeometry')
group_input_node = group.nodes.new('NodeGroupInput')
group_input_node.location.x = -200 - group_input_node.width
group_input_node.select = False
group_output_node = group.nodes.new('NodeGroupOutput')
group_output_node.is_active_output = True
group_output_node.location.x = 200
group_output_node.select = False
group_node = group.nodes.new("GeometryNodeGroup")
group_node.node_tree = old_group
group_node.update()
# Copy default values for inputs and create named attribute input nodes.
input_nodes = []
for input_socket in old_group.interface.items_tree:
if input_socket.item_type != 'SOCKET' or (input_socket.in_out not in {'INPUT', 'BOTH'}):
continue
identifier = input_socket.identifier
group_node_input = get_socket_with_identifier(group_node.inputs, identifier)
prop = getattr(modifier.properties.inputs, identifier)
if hasattr(prop, "type") and prop.type == "ATTRIBUTE":
input_node = group.nodes.new("GeometryNodeInputNamedAttribute")
input_nodes.append(input_node)
input_node.data_type = socket_idname_to_attribute_type(input_socket.bl_socket_idname)
attribute_name = prop.attribute_name
input_node.inputs["Name"].default_value = attribute_name
output_socket = get_enabled_socket_with_name(input_node.outputs, "Attribute")
group.links.new(output_socket, group_node_input)
elif hasattr(input_socket, "default_value"):
group_node_input.default_value = prop.value
if first_geometry_input:
group.links.new(
group_input_node.outputs[0],
get_socket_with_identifier(group_node.inputs, first_geometry_input.identifier),
)
# Adjust locations of named attribute input nodes and group input node to make some space.
if input_nodes:
for i, node in enumerate(input_nodes):
node.location.x = -175
node.location.y = i * -50
group_input_node.location.x = -350
# Connect outputs to store named attribute nodes to replace modifier attribute outputs.
store_nodes = []
first_geometry_output = None
for output_socket in old_group.interface.items_tree:
if output_socket.item_type != 'SOCKET' or (output_socket.in_out not in {'OUTPUT', 'BOTH'}):
continue
identifier = output_socket.identifier
group_node_output = get_socket_with_identifier(group_node.outputs, identifier)
attribute_name = getattr(group_node_output, "attribute_name", None)
if attribute_name:
store_node = group.nodes.new("GeometryNodeStoreNamedAttribute")
store_nodes.append(store_node)
store_node.data_type = socket_idname_to_attribute_type(output_socket.bl_socket_idname)
store_node.domain = output_socket.attribute_domain
store_node.inputs["Name"].default_value = attribute_name
input_socket = get_enabled_socket_with_name(store_node.inputs, "Value")
group.links.new(group_node_output, input_socket)
elif output_socket.bl_socket_idname == 'NodeSocketGeometry':
if not first_geometry_output:
first_geometry_output = group_node_output
# Adjust locations of store named attribute nodes and move group output.
# Note that the node group has its sockets names translated, while the built-in nodes don't.
if store_nodes:
for i, node in enumerate(store_nodes):
node.location.x = (i + 1) * 175
node.location.y = 0
group_output_node.location.x = (len(store_nodes) + 1) * 175
group.links.new(first_geometry_output, store_nodes[0].inputs["Geometry"])
for i in range(len(store_nodes) - 1):
group.links.new(store_nodes[i].outputs["Geometry"], store_nodes[i + 1].inputs["Geometry"])
group.links.new(store_nodes[-1].outputs["Geometry"], group_output_node.inputs[data_("Geometry")])
else:
if not first_geometry_output:
operator.report({'WARNING'}, "Node group must have a geometry output")
return None
group.links.new(first_geometry_output, group_output_node.inputs[data_("Geometry")])
return group
class MoveModifierToNodes(Operator):
"""Move inputs and outputs from in the modifier to a new node group"""
bl_idname = "object.geometry_nodes_move_to_nodes"
bl_label = "Move to Nodes"
bl_options = {'REGISTER', 'UNDO'}
use_selected_objects: BoolProperty(
name="Selected Objects",
description="Affect all selected objects instead of just the active object",
)
@classmethod
def poll(cls, context):
return edit_geometry_nodes_modifier_poll(context)
def invoke(self, context, event):
if event.alt:
self.use_selected_objects = True
return self.execute(context)
def execute(self, context):
active_modifier = get_context_modifier(context)
if not active_modifier:
return {'CANCELLED'}
modifier_name = active_modifier.name
objects = []
if self.use_selected_objects:
objects = context.selected_editable_objects
else:
objects = [context.object]
for ob in objects:
modifier = ob.modifiers[modifier_name]
if not modifier:
continue
old_group = modifier.node_group
if not old_group:
continue
new_group = create_wrapper_group(self, modifier, old_group)
if new_group:
modifier.node_group = new_group
return {'FINISHED'}
class NewGeometryNodesModifier(Operator):
"""Create a new modifier with a new geometry node group"""
bl_idname = "node.new_geometry_nodes_modifier"
bl_label = "New Geometry Node Modifier"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return geometry_modifier_poll(context)
def execute(self, context):
ob = context.object
modifier = ob.modifiers.new(data_("GeometryNodes"), 'NODES')
if not modifier:
return {'CANCELLED'}
is_first_modifier = ob.modifiers[0] == modifier
# For empty objects, don't add a geometry input for the first modifier
add_geometry_input = not (ob.type == 'EMPTY' and ob.instance_collection is None and is_first_modifier)
group = geometry_node_group_empty_modifier_new(data_("Geometry Nodes"), add_geometry_input)
modifier.node_group = group
return {'FINISHED'}
class NewGeometryNodeTreeAssign(Operator):
"""Create a new geometry node group and assign it to the active modifier"""
bl_idname = "node.new_geometry_node_group_assign"
bl_label = "Assign New Geometry Node Group"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return geometry_modifier_poll(context)
def execute(self, context):
modifier = get_context_modifier(context)
if not modifier:
return {'CANCELLED'}
ob = context.object
is_first_modifier = ob.modifiers[0] == modifier
# For empty objects, don't add a geometry input for the first modifier
add_geometry_input = not (ob.type == 'EMPTY' and ob.instance_collection is None and is_first_modifier)
group = geometry_node_group_empty_modifier_new(data_("Geometry Nodes"), add_geometry_input)
modifier.node_group = group
return {'FINISHED'}
class NewGeometryNodeGroupTool(Operator):
"""Create a new geometry node group for a tool"""
bl_idname = "node.new_geometry_node_group_tool"
bl_label = "New Geometry Node Tool Group"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
space = context.space_data
return space and space.type == 'NODE_EDITOR' and space.node_tree_sub_type == 'TOOL'
def execute(self, context):
group = geometry_node_group_empty_tool_new(context)
context.space_data.selected_node_group = group
return {'FINISHED'}
class ZoneOperator:
@classmethod
def get_node(cls, context):
node = context.active_node
if node is None:
return None
if node.bl_idname == cls.output_node_type:
return node
if node.bl_idname == cls.input_node_type:
return node.paired_output
@classmethod
def poll(cls, context):
space = context.space_data
# Needs active node editor and a tree.
if not space or space.type != 'NODE_EDITOR' or not space.edit_tree or not space.edit_tree.is_editable:
return False
if cls.get_node(context) is None:
return False
return True
classes = (
NewGeometryNodesModifier,
NewGeometryNodeTreeAssign,
NewGeometryNodeGroupTool,
MoveModifierToNodes,
)

View File

@@ -0,0 +1,63 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import Operator
from bpy.props import (
EnumProperty,
)
class GREASE_PENCIL_OT_relative_layer_mask_add(Operator):
"""Mask active layer with layer above or below"""
bl_idname = "grease_pencil.relative_layer_mask_add"
bl_label = "Mask with Layer Above/Below"
bl_options = {'REGISTER', 'UNDO'}
mode: EnumProperty(
name="Mode",
items=(
('ABOVE', "Above", ""),
('BELOW', "Below", "")
),
description="Which relative layer (above or below) to use as a mask",
default='ABOVE',
)
@classmethod
def poll(cls, context):
return (
(obj := context.active_object) is not None and
obj.is_editable and
obj.type == 'GREASEPENCIL' and
obj.data.layers.active is not None and
obj.data.is_editable
)
def execute(self, context):
obj = context.active_object
active_layer = obj.data.layers.active
if self.mode == 'ABOVE':
masking_layer = active_layer.next_node
elif self.mode == 'BELOW':
masking_layer = active_layer.prev_node
if masking_layer is None or type(masking_layer) != bpy.types.GreasePencilLayer:
self.report({'ERROR'}, "No layer found")
return {'CANCELLED'}
if masking_layer.name in active_layer.mask_layers:
self.report({'ERROR'}, "Layer is already added as a mask")
return {'CANCELLED'}
bpy.ops.grease_pencil.layer_mask_add(name=masking_layer.name)
active_layer.use_masks = True
return {'FINISHED'}
classes = (
GREASE_PENCIL_OT_relative_layer_mask_add,
)

View File

@@ -0,0 +1,322 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import (
FileHandler,
Operator,
OperatorFileListElement,
)
from bpy.props import (
BoolProperty,
CollectionProperty,
StringProperty,
)
from bpy.app.translations import pgettext_rpt as rpt_
class EditExternally(Operator):
"""Edit image in an external application"""
bl_idname = "image.external_edit"
bl_label = "Image Edit Externally"
bl_options = {'REGISTER'}
filepath: StringProperty(
subtype='FILE_PATH',
)
@staticmethod
def _editor_guess(context):
import sys
image_editor = context.preferences.filepaths.image_editor
# use image editor in the preferences when available.
if not image_editor:
if sys.platform[:3] == "win":
image_editor = ["start"] # not tested!
elif sys.platform == "darwin":
image_editor = ["open"]
else:
image_editor = ["gimp"]
else:
if sys.platform == "darwin":
# blender file selector treats .app as a folder
# and will include a trailing backslash, so we strip it.
image_editor.rstrip('\\')
image_editor = ["open", "-a", image_editor]
else:
image_editor = [image_editor]
return image_editor
def execute(self, context):
import os
import subprocess
filepath = self.filepath
if not filepath:
self.report({'ERROR'}, "Image path not set")
return {'CANCELLED'}
if not os.path.exists(filepath) or not os.path.isfile(filepath):
self.report(
{'ERROR'},
rpt_("Image path {!r} not found, image may be packed or unsaved").format(filepath),
)
return {'CANCELLED'}
cmd = self._editor_guess(context) + [filepath]
try:
subprocess.Popen(cmd)
except Exception:
import traceback
traceback.print_exc()
self.report(
{'ERROR'},
"Image editor could not be launched, ensure that "
"the path in User Preferences > File is valid, and Blender has rights to launch it",
)
return {'CANCELLED'}
return {'FINISHED'}
def invoke(self, context, _event):
import os
sd = context.space_data
try:
image = sd.image
except AttributeError:
self.report({'ERROR'}, "Context incorrect, image not found")
return {'CANCELLED'}
if image.packed_file:
self.report({'ERROR'}, "Image is packed, unpack before editing")
return {'CANCELLED'}
if sd.type == 'IMAGE_EDITOR':
filepath = image.filepath_from_user(image_user=sd.image_user)
else:
filepath = image.filepath
filepath = bpy.path.abspath(filepath, library=image.library)
self.filepath = os.path.normpath(filepath)
self.execute(context)
return {'FINISHED'}
class ProjectEdit(Operator):
"""Edit a snapshot of the 3D Viewport in an external image editor"""
bl_idname = "image.project_edit"
bl_label = "Project Edit"
bl_options = {'REGISTER'}
_proj_hack = [""]
def execute(self, context):
import os
EXT = "png" # could be made an option but for now ok
for image in bpy.data.images:
image.tag = True
# opengl buffer may fail, we can't help this, but best report it.
try:
bpy.ops.paint.image_from_view()
except RuntimeError as ex:
self.report({'ERROR'}, str(ex))
return {'CANCELLED'}
image_new = None
for image in bpy.data.images:
if not image.tag:
image_new = image
break
if not image_new:
self.report({'ERROR'}, "Could not make new image")
return {'CANCELLED'}
filepath = os.path.basename(bpy.data.filepath)
filepath = os.path.splitext(filepath)[0]
# fixes <memory> rubbish, needs checking
# filepath = bpy.path.clean_name(filepath)
if bpy.data.is_saved:
filepath = "//" + filepath
else:
filepath = os.path.join(bpy.app.tempdir, "project_edit")
obj = context.object
if obj:
filepath += "_" + bpy.path.clean_name(obj.name)
filepath_final = filepath + "." + EXT
i = 0
while os.path.exists(bpy.path.abspath(filepath_final)):
filepath_final = filepath + "{:03d}.{:s}".format(i, EXT)
i += 1
image_new.name = bpy.path.basename(filepath_final)
ProjectEdit._proj_hack[0] = image_new.name
image_new.filepath_raw = filepath_final # TODO, filepath raw is crummy
image_new.file_format = 'PNG'
image_new.save()
filepath_final = bpy.path.abspath(filepath_final)
try:
bpy.ops.image.external_edit(filepath=filepath_final)
except RuntimeError as ex:
self.report({'ERROR'}, str(ex))
return {'FINISHED'}
class ProjectApply(Operator):
"""Project edited image back onto the object"""
bl_idname = "image.project_apply"
bl_label = "Project Apply"
bl_options = {'REGISTER'}
def execute(self, _context):
image_name = ProjectEdit._proj_hack[0] # TODO, deal with this nicer
image = bpy.data.images.get((image_name, None))
if image is None:
self.report({'ERROR'}, rpt_("Could not find image '{:s}'").format(image_name))
return {'CANCELLED'}
image.reload()
bpy.ops.paint.project_image(image=image_name)
return {'FINISHED'}
bl_file_extensions_image_movie = (*bpy.path.extensions_image, *bpy.path.extensions_movie)
class IMAGE_OT_open_images(Operator):
bl_idname = "image.open_images"
bl_label = "Open Images"
bl_options = {'REGISTER', 'UNDO'}
directory: StringProperty(
subtype='FILE_PATH',
options={'SKIP_SAVE', 'HIDDEN'},
)
files: CollectionProperty(
type=OperatorFileListElement,
options={'SKIP_SAVE', 'HIDDEN'},
)
relative_path: BoolProperty(
name="Relative Path",
default=True,
)
use_sequence_detection: BoolProperty(
name="Detect Sequence",
default=True,
)
use_udim_detection: BoolProperty(
name="Detect UDIM",
default=True,
)
@classmethod
def poll(cls, context):
return context.area and context.area.type == 'IMAGE_EDITOR'
def execute(self, context):
if not self.directory or len(self.files) == 0:
return {'CANCELLED'}
# List of files that are not part of an image sequence or UDIM group.
files = []
# Groups of files that may be part of an image sequence or a UDIM group.
sequences = []
import re
regex_extension = re.compile(
"(" + "|".join([re.escape(ext) for ext in bl_file_extensions_image_movie]) + ")$",
re.IGNORECASE,
)
regex_sequence = re.compile("(\\d+)(\\.[\\w\\d]+)$")
for file in self.files:
# Filter by extension
if not regex_extension.search(file.name):
continue
match = regex_sequence.search(file.name)
if not (match and (self.use_sequence_detection or self.use_udim_detection)):
files.append(file.name)
continue
seq = {
"prefix": file.name[:len(file.name) - len(match.group(0))],
"ext": match.group(2),
"frame_size": len(match.group(1)),
"files": [file.name],
}
for test_seq in sequences:
if (
(test_seq["prefix"] == seq["prefix"]) and
(test_seq["ext"] == seq["ext"]) and
(test_seq["frame_size"] == seq["frame_size"])
):
test_seq["files"].append(file.name)
seq = None
break
if seq:
sequences.append(seq)
import os
for file in files:
filepath = os.path.join(self.directory, file)
bpy.ops.image.open(filepath=filepath, relative_path=self.relative_path)
for seq in sequences:
seq["files"].sort()
filepath = os.path.join(self.directory, seq["files"][0])
files = [{"name": file} for file in seq["files"]]
bpy.ops.image.open(
filepath=filepath,
directory=self.directory,
files=files,
use_sequence_detection=self.use_sequence_detection,
use_udim_detecting=self.use_udim_detection,
relative_path=self.relative_path,
)
is_tiled = context.edit_image.source == 'TILED'
if len(files) > 1 and self.use_sequence_detection and not is_tiled:
context.edit_image.name = "{:s}{:s}{:s}".format(seq["prefix"], ("#" * seq["frame_size"]), seq["ext"])
return {'FINISHED'}
class IMAGE_FH_drop_handler(FileHandler):
bl_idname = "IMAGE_FH_drop_handler"
bl_label = "Open images"
bl_import_operator = "image.open_images"
bl_file_extensions = ";".join(bl_file_extensions_image_movie)
@classmethod
def poll_drop(cls, context):
return (
(context.area is not None) and
(context.area.type == 'IMAGE_EDITOR') and
(context.region is not None) and
(context.region.type == 'WINDOW')
)
classes = (
EditExternally,
ProjectApply,
IMAGE_OT_open_images,
IMAGE_FH_drop_handler,
ProjectEdit,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from bpy.types import Operator
class MeshSelectNext(Operator):
"""Select the next element (using selection order)"""
bl_idname = "mesh.select_next_item"
bl_label = "Select Next Element"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return (context.mode == 'EDIT_MESH')
def execute(self, context):
import bmesh
from .bmesh import find_adjacent
obj = context.active_object
me = obj.data
bm = bmesh.from_edit_mesh(me)
if find_adjacent.select_next(bm, self.report):
bm.select_flush_mode()
bmesh.update_edit_mesh(me, loop_triangles=False)
return {'FINISHED'}
class MeshSelectPrev(Operator):
"""Select the previous element (using selection order)"""
bl_idname = "mesh.select_prev_item"
bl_label = "Select Previous Element"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
return (context.mode == 'EDIT_MESH')
def execute(self, context):
import bmesh
from .bmesh import find_adjacent
obj = context.active_object
me = obj.data
bm = bmesh.from_edit_mesh(me)
if find_adjacent.select_prev(bm, self.report):
bm.select_flush_mode()
bmesh.update_edit_mesh(me, loop_triangles=False)
return {'FINISHED'}
classes = (
MeshSelectNext,
MeshSelectPrev,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from bpy.app.translations import pgettext_tip as tip_
def node_editor_poll(cls, context):
space = context.space_data
if space is None or (space.type != 'NODE_EDITOR'):
cls.poll_message_set("Active editor is not a node editor.")
return False
if space.node_tree is None:
cls.poll_message_set("Node tree was not found in the active node editor.")
return False
if space.node_tree.library is not None:
cls.poll_message_set("Active node tree is linked from another .blend file.")
return False
if not space.edit_tree.nodes:
cls.poll_message_set("Active node tree does not contain any nodes.")
return False
return True
def node_space_type_poll(cls, context, types):
if context.space_data.tree_type not in types:
tree_types_str = ", ".join(t.split("NodeTree")[0].lower() for t in sorted(types))
poll_message = tip_(
"Current node tree type not supported.\n"
"Should be one of {:s}."
).format(tree_types_str)
cls.poll_message_set(poll_message)
return False
return True
def get_group_output_node(tree, output_node_idname='NodeGroupOutput'):
for node in tree.nodes:
if node.bl_idname == output_node_idname and node.is_active_output:
return node
def get_output_location(tree):
# get right-most location.
sorted_by_xloc = (sorted(tree.nodes, key=lambda x: x.location.x))
max_xloc_node = sorted_by_xloc[-1]
# get average y location.
sum_yloc = 0
for node in tree.nodes:
sum_yloc += node.location.y
loc_x = max_xloc_node.location.x + max_xloc_node.dimensions.x + 80
loc_y = sum_yloc / len(tree.nodes)
return loc_x, loc_y
def get_internal_socket(socket):
# get the internal socket from a socket inside or outside the group.
node = socket.node
if node.type == 'GROUP_OUTPUT':
iterator = node.id_data.interface.items_tree
elif node.type == 'GROUP_INPUT':
iterator = node.id_data.interface.items_tree
elif hasattr(node, "node_tree"):
iterator = node.node_tree.interface.items_tree
else:
return None
for s in iterator:
if s.identifier == socket.identifier:
return s
return iterator[0]
def is_visible_socket(socket):
return socket.is_icon_visible and socket.type != 'CUSTOM'
def is_viewer_link(link, output_node):
if link.to_node == output_node and link.to_socket == output_node.inputs[0]:
return True
if link.to_node.type == 'GROUP_OUTPUT':
socket = get_internal_socket(link.to_socket)
if socket.is_inspect_output:
return True
return False
def force_update(context):
context.space_data.node_tree.update_tag()
class NodeEditorBase:
@classmethod
def poll(cls, context):
return node_editor_poll(cls, context)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,407 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from bpy.types import Operator
from mathutils import Vector
def worldspace_bounds_from_object_bounds(bb_world):
# Initialize the variables with the 8th vertex
left, right, front, back, down, up = (
bb_world[7][0],
bb_world[7][0],
bb_world[7][1],
bb_world[7][1],
bb_world[7][2],
bb_world[7][2],
)
# Test against the other 7 verts
for i in range(7):
# X Range
val = bb_world[i][0]
if val < left:
left = val
if val > right:
right = val
# Y Range
val = bb_world[i][1]
if val < front:
front = val
if val > back:
back = val
# Z Range
val = bb_world[i][2]
if val < down:
down = val
if val > up:
up = val
return (Vector((left, front, up)), Vector((right, back, down)))
def worldspace_bounds_from_object_data(depsgraph, obj):
matrix_world = obj.matrix_world.copy()
# Initialize the variables with the last vertex
ob_eval = obj.evaluated_get(depsgraph)
me = ob_eval.to_mesh()
verts = me.vertices
val = matrix_world @ (verts[-1].co if verts else Vector((0.0, 0.0, 0.0)))
left, right, front, back, down, up = (
val[0],
val[0],
val[1],
val[1],
val[2],
val[2],
)
# Test against all other verts
for v in verts:
vco = matrix_world @ v.co
# X Range
val = vco[0]
if val < left:
left = val
if val > right:
right = val
# Y Range
val = vco[1]
if val < front:
front = val
if val > back:
back = val
# Z Range
val = vco[2]
if val < down:
down = val
if val > up:
up = val
ob_eval.to_mesh_clear()
return Vector((left, front, up)), Vector((right, back, down))
def align_objects(context, align_x, align_y, align_z, align_mode, relative_to, bb_quality):
depsgraph = context.evaluated_depsgraph_get()
scene = context.scene
cursor = scene.cursor.location
# We are accessing runtime data such as evaluated bounding box, so we need to
# be sure it is properly updated and valid (bounding box might be lost on operator redo).
context.view_layer.update()
Left_Front_Up_SEL = [0.0, 0.0, 0.0]
Right_Back_Down_SEL = [0.0, 0.0, 0.0]
flag_first = True
objects = []
for obj in context.selected_objects:
matrix_world = obj.matrix_world.copy()
bb_world = [matrix_world @ Vector(v) for v in obj.bound_box]
objects.append((obj, bb_world))
if not objects:
return False
for obj, bb_world in objects:
if bb_quality and obj.type == 'MESH':
GBB = worldspace_bounds_from_object_data(depsgraph, obj)
else:
GBB = worldspace_bounds_from_object_bounds(bb_world)
Left_Front_Up = GBB[0]
Right_Back_Down = GBB[1]
# Active Center
if obj == context.active_object:
center_active_x = (Left_Front_Up[0] + Right_Back_Down[0]) / 2.0
center_active_y = (Left_Front_Up[1] + Right_Back_Down[1]) / 2.0
center_active_z = (Left_Front_Up[2] + Right_Back_Down[2]) / 2.0
size_active_x = (Right_Back_Down[0] - Left_Front_Up[0]) / 2.0
size_active_y = (Right_Back_Down[1] - Left_Front_Up[1]) / 2.0
size_active_z = (Left_Front_Up[2] - Right_Back_Down[2]) / 2.0
# Selection Center
if flag_first:
flag_first = False
Left_Front_Up_SEL[0] = Left_Front_Up[0]
Left_Front_Up_SEL[1] = Left_Front_Up[1]
Left_Front_Up_SEL[2] = Left_Front_Up[2]
Right_Back_Down_SEL[0] = Right_Back_Down[0]
Right_Back_Down_SEL[1] = Right_Back_Down[1]
Right_Back_Down_SEL[2] = Right_Back_Down[2]
else:
# X axis
if Left_Front_Up[0] < Left_Front_Up_SEL[0]:
Left_Front_Up_SEL[0] = Left_Front_Up[0]
# Y axis
if Left_Front_Up[1] < Left_Front_Up_SEL[1]:
Left_Front_Up_SEL[1] = Left_Front_Up[1]
# Z axis
if Left_Front_Up[2] > Left_Front_Up_SEL[2]:
Left_Front_Up_SEL[2] = Left_Front_Up[2]
# X axis
if Right_Back_Down[0] > Right_Back_Down_SEL[0]:
Right_Back_Down_SEL[0] = Right_Back_Down[0]
# Y axis
if Right_Back_Down[1] > Right_Back_Down_SEL[1]:
Right_Back_Down_SEL[1] = Right_Back_Down[1]
# Z axis
if Right_Back_Down[2] < Right_Back_Down_SEL[2]:
Right_Back_Down_SEL[2] = Right_Back_Down[2]
center_sel_x = (Left_Front_Up_SEL[0] + Right_Back_Down_SEL[0]) / 2.0
center_sel_y = (Left_Front_Up_SEL[1] + Right_Back_Down_SEL[1]) / 2.0
center_sel_z = (Left_Front_Up_SEL[2] + Right_Back_Down_SEL[2]) / 2.0
# Main Loop
for obj, bb_world in objects:
matrix_world = obj.matrix_world.copy()
bb_world = [matrix_world @ Vector(v[:]) for v in obj.bound_box]
if bb_quality and obj.type == 'MESH':
GBB = worldspace_bounds_from_object_data(depsgraph, obj)
else:
GBB = worldspace_bounds_from_object_bounds(bb_world)
Left_Front_Up = GBB[0]
Right_Back_Down = GBB[1]
center_x = (Left_Front_Up[0] + Right_Back_Down[0]) / 2.0
center_y = (Left_Front_Up[1] + Right_Back_Down[1]) / 2.0
center_z = (Left_Front_Up[2] + Right_Back_Down[2]) / 2.0
positive_x = Right_Back_Down[0]
positive_y = Right_Back_Down[1]
positive_z = Left_Front_Up[2]
negative_x = Left_Front_Up[0]
negative_y = Left_Front_Up[1]
negative_z = Right_Back_Down[2]
obj_loc = obj.location
if align_x:
# Align Mode
if relative_to == 'OPT_4': # Active relative
if align_mode == 'OPT_1':
obj_x = obj_loc[0] - negative_x - size_active_x
elif align_mode == 'OPT_3':
obj_x = obj_loc[0] - positive_x + size_active_x
else: # Everything else relative
if align_mode == 'OPT_1':
obj_x = obj_loc[0] - negative_x
elif align_mode == 'OPT_3':
obj_x = obj_loc[0] - positive_x
if align_mode == 'OPT_2': # All relative
obj_x = obj_loc[0] - center_x
# Relative To
if relative_to == 'OPT_1':
loc_x = obj_x
elif relative_to == 'OPT_2':
loc_x = obj_x + cursor[0]
elif relative_to == 'OPT_3':
loc_x = obj_x + center_sel_x
elif relative_to == 'OPT_4':
loc_x = obj_x + center_active_x
obj.location[0] = loc_x
if align_y:
# Align Mode
if relative_to == 'OPT_4': # Active relative
if align_mode == 'OPT_1':
obj_y = obj_loc[1] - negative_y - size_active_y
elif align_mode == 'OPT_3':
obj_y = obj_loc[1] - positive_y + size_active_y
else: # Everything else relative
if align_mode == 'OPT_1':
obj_y = obj_loc[1] - negative_y
elif align_mode == 'OPT_3':
obj_y = obj_loc[1] - positive_y
if align_mode == 'OPT_2': # All relative
obj_y = obj_loc[1] - center_y
# Relative To
if relative_to == 'OPT_1':
loc_y = obj_y
elif relative_to == 'OPT_2':
loc_y = obj_y + cursor[1]
elif relative_to == 'OPT_3':
loc_y = obj_y + center_sel_y
elif relative_to == 'OPT_4':
loc_y = obj_y + center_active_y
obj.location[1] = loc_y
if align_z:
# Align Mode
if relative_to == 'OPT_4': # Active relative
if align_mode == 'OPT_1':
obj_z = obj_loc[2] - negative_z - size_active_z
elif align_mode == 'OPT_3':
obj_z = obj_loc[2] - positive_z + size_active_z
else: # Everything else relative
if align_mode == 'OPT_1':
obj_z = obj_loc[2] - negative_z
elif align_mode == 'OPT_3':
obj_z = obj_loc[2] - positive_z
if align_mode == 'OPT_2': # All relative
obj_z = obj_loc[2] - center_z
# Relative To
if relative_to == 'OPT_1':
loc_z = obj_z
elif relative_to == 'OPT_2':
loc_z = obj_z + cursor[2]
elif relative_to == 'OPT_3':
loc_z = obj_z + center_sel_z
elif relative_to == 'OPT_4':
loc_z = obj_z + center_active_z
obj.location[2] = loc_z
return True
from bpy.props import (
BoolProperty,
EnumProperty,
)
class AlignObjects(Operator):
"""Align objects"""
bl_idname = "object.align"
bl_label = "Align Objects"
bl_options = {'REGISTER', 'UNDO'}
bb_quality: BoolProperty(
name="High Quality",
description=(
"Enables high quality but slow calculation of the "
"bounding box for perfect results on complex "
"shape meshes with rotation/scale"
),
default=True,
)
align_mode: EnumProperty(
name="Align Mode",
description="Side of object to use for alignment",
items=(
('OPT_1', "Negative Sides", ""),
('OPT_2', "Centers", ""),
('OPT_3', "Positive Sides", ""),
),
default='OPT_2',
)
relative_to: EnumProperty(
name="Relative To",
description="Reference location to align to",
items=(
('OPT_1', "Scene Origin", "Use the scene origin as the position for the selected objects to align to"),
('OPT_2', "3D Cursor", "Use the 3D cursor as the position for the selected objects to align to"),
('OPT_3', "Selection", "Use the selected objects as the position for the selected objects to align to"),
('OPT_4', "Active", "Use the active object as the position for the selected objects to align to"),
),
default='OPT_4',
)
align_axis: EnumProperty(
name="Align",
description="Align to axis",
items=(
('X', "X", ""),
('Y', "Y", ""),
('Z', "Z", ""),
),
options={'ENUM_FLAG'},
)
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def execute(self, context):
align_axis = self.align_axis
ret = align_objects(
context,
'X' in align_axis,
'Y' in align_axis,
'Z' in align_axis,
self.align_mode,
self.relative_to,
self.bb_quality,
)
if not ret:
self.report({'WARNING'}, "No objects with bound-box selected")
return {'CANCELLED'}
else:
return {'FINISHED'}
classes = (
AlignObjects,
)

View File

@@ -0,0 +1,677 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from mathutils import Vector
import bpy
from bpy.types import Operator
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
IntProperty,
)
from bpy.app.translations import (
pgettext_rpt as rpt_,
pgettext_data as data_,
)
def object_ensure_material(obj, mat_name):
""" Use an existing material or add a new one.
"""
mat = mat_slot = None
for mat_slot in obj.material_slots:
mat = mat_slot.material
if mat:
break
if mat is None:
mat = bpy.data.materials.new(mat_name)
mat.node_tree.nodes.clear()
if mat_slot:
mat_slot.material = mat
else:
obj.data.materials.append(mat)
return mat
class ObjectModeOperator:
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
class QuickFur(ObjectModeOperator, Operator):
"""Add a fur setup to the selected objects"""
bl_idname = "object.quick_fur"
bl_label = "Quick Fur"
bl_options = {'REGISTER', 'UNDO'}
density: EnumProperty(
name="Density",
items=(
('LOW', "Low", ""),
('MEDIUM', "Medium", ""),
('HIGH', "High", ""),
),
default='MEDIUM',
)
length: FloatProperty(
name="Length",
min=0.001, max=100,
soft_min=0.01, soft_max=10,
default=0.1,
subtype='DISTANCE',
)
radius: FloatProperty(
name="Hair Radius",
min=0.0, max=10,
soft_min=0.0001, soft_max=0.1,
default=0.001,
subtype='DISTANCE',
)
view_percentage: FloatProperty(
name="View Percentage",
min=0.0, max=1.0,
default=1.0,
subtype='FACTOR',
)
apply_hair_guides: BoolProperty(
name="Apply Hair Guides",
default=True,
)
use_noise: BoolProperty(
name="Noise",
default=True,
)
use_frizz: BoolProperty(
name="Frizz",
default=True,
)
@classmethod
def poll(cls, context):
if not super().poll(context):
return False
if context.active_object is None or context.active_object.type != 'MESH':
cls.poll_message_set("No active mesh object.")
return False
return True
def execute(self, context):
import os
from collections import namedtuple
mesh_objects = [obj for obj in context.selected_objects if obj.type == 'MESH']
if not mesh_objects:
self.report({'ERROR'}, "Select at least one mesh object")
return {'CANCELLED'}
if self.density == 'LOW':
count = 1000
elif self.density == 'MEDIUM':
count = 10000
elif self.density == 'HIGH':
count = 100000
asset_library_filepath = os.path.join(
bpy.utils.system_resource('DATAFILES'),
"assets",
"nodes",
"procedural_hair_node_assets.blend",
)
# Create a named tuple that stores attributes for the node-group names.
attr_name_pairs = [
("generate", "Generate Hair Curves"),
("interpolate", "Interpolate Hair Curves"),
("radius", "Set Hair Curve Profile"),
]
if self.use_noise:
attr_name_pairs.append(("noise", "Hair Curves Noise"))
if self.use_frizz:
attr_name_pairs.append(("frizz", "Frizz Hair Curves"))
NodeGroupData = namedtuple("NodeGroupData", tuple(v for v, _ in attr_name_pairs))
with bpy.data.libraries.load(
asset_library_filepath,
link=True,
pack=True,
set_fake=False,
) as (data_src, data_dst):
# The values are assumed to exist, no inspection of the source is needed.
del data_src
data_dst.node_groups.extend([name for _, name in attr_name_pairs])
# For convenient name lookups.
node_groups_name_map = {id.name: id for id in data_dst.node_groups}
node_groups = NodeGroupData(*(node_groups_name_map[name] for _, name in attr_name_pairs))
del node_groups_name_map
material = bpy.data.materials.new(data_("Fur Material"))
mesh_with_zero_area = False
mesh_missing_uv_map = False
modifier_apply_error = False
for mesh_object in mesh_objects:
mesh = mesh_object.data
if len(mesh.uv_layers) == 0:
mesh_missing_uv_map = True
continue
with context.temp_override(active_object=mesh_object):
bpy.ops.object.curves_empty_hair_add()
curves_object = context.active_object
curves = curves_object.data
curves.materials.append(material)
area = 0.0
for poly in mesh.polygons:
area += poly.area
if area == 0.0:
mesh_with_zero_area = True
density = 10
else:
density = count / area
generate_modifier = curves_object.modifiers.new(name=data_("Generate"), type='NODES')
generate_modifier.node_group = node_groups.generate
generate_modifier.properties.inputs.Input_12.value = True
generate_modifier.properties.inputs.Input_20.value = self.length
generate_modifier.properties.inputs.Input_22.value = material
generate_modifier.properties.inputs.Input_15.value = density * 0.01
radius_modifier = curves_object.modifiers.new(name=data_("Set Hair Curve Profile"), type='NODES')
radius_modifier.node_group = node_groups.radius
radius_modifier.properties.inputs.Input_3.value = self.radius
interpolate_modifier = curves_object.modifiers.new(name=data_("Interpolate Hair Curves"), type='NODES')
interpolate_modifier.node_group = node_groups.interpolate
interpolate_modifier.properties.inputs.Input_12.value = True
interpolate_modifier.properties.inputs.Input_15.value = density
interpolate_modifier.properties.inputs.Input_17.value = self.view_percentage
interpolate_modifier.properties.inputs.Input_24.value = True
if self.use_noise:
noise_modifier = curves_object.modifiers.new(name=data_("Hair Curves Noise"), type='NODES')
noise_modifier.node_group = node_groups.noise
if self.use_frizz:
frizz_modifier = curves_object.modifiers.new(name=data_("Frizz Hair Curves"), type='NODES')
frizz_modifier.node_group = node_groups.frizz
if self.apply_hair_guides:
with context.temp_override(object=curves_object):
try:
bpy.ops.object.modifier_apply(modifier=generate_modifier.name)
except Exception:
modifier_apply_error = True
curves_object.modifiers.move(0, len(curves_object.modifiers) - 1)
if mesh_with_zero_area:
self.report({'WARNING'}, "Mesh has no face area")
if mesh_missing_uv_map:
self.report({'WARNING'}, "Mesh UV map required")
if modifier_apply_error and not mesh_with_zero_area:
self.report({'WARNING'}, "Unable to apply \"Generate\" modifier")
return {'FINISHED'}
class QuickExplode(ObjectModeOperator, Operator):
"""Make selected objects explode"""
bl_idname = "object.quick_explode"
bl_label = "Quick Explode"
bl_options = {'REGISTER', 'UNDO'}
style: EnumProperty(
name="Explode Style",
items=(
('EXPLODE', "Explode", ""),
('BLEND', "Blend", ""),
),
default='EXPLODE',
)
amount: IntProperty(
name="Number of Pieces",
min=2, max=10000,
soft_min=2, soft_max=10000,
default=100,
)
frame_duration: IntProperty(
name="Duration",
min=1, max=300000,
soft_min=1, soft_max=10000,
default=50,
)
frame_start: IntProperty(
name="Start Frame",
min=1, max=300000,
soft_min=1, soft_max=10000,
default=1,
)
frame_end: IntProperty(
name="End Frame",
min=1, max=300000,
soft_min=1, soft_max=10000,
default=10,
)
velocity: FloatProperty(
name="Outwards Velocity",
min=0, max=300000,
soft_min=0, soft_max=10,
default=1,
)
fade: BoolProperty(
name="Fade",
description="Fade the pieces over time",
default=True,
)
def execute(self, context):
context_override = context.copy()
obj_act = context.active_object
if obj_act is None or obj_act.type != 'MESH':
self.report({'ERROR'}, "Active object is not a mesh")
return {'CANCELLED'}
mesh_objects = [
obj for obj in context.selected_objects
if obj.type == 'MESH' and obj != obj_act
]
mesh_objects.insert(0, obj_act)
if self.style == 'BLEND' and len(mesh_objects) != 2:
self.report({'ERROR'}, "Select two mesh objects")
self.style = 'EXPLODE'
return {'CANCELLED'}
elif not mesh_objects:
self.report({'ERROR'}, "Select at least one mesh object")
return {'CANCELLED'}
for obj in mesh_objects:
if obj.particle_systems:
self.report({'ERROR'}, rpt_("Object {!r} already has a particle system").format(obj.name))
return {'CANCELLED'}
if self.style == 'BLEND':
from_obj = mesh_objects[1]
to_obj = mesh_objects[0]
for obj in mesh_objects:
context_override["object"] = obj
with context.temp_override(**context_override):
bpy.ops.object.particle_system_add()
settings = obj.particle_systems[-1].settings
settings.count = self.amount
# first set frame end, to prevent frame start clamping
settings.frame_end = self.frame_end - self.frame_duration
settings.frame_start = self.frame_start
settings.lifetime = self.frame_duration
settings.normal_factor = self.velocity
settings.render_type = 'NONE'
explode = obj.modifiers.new(name=data_("Explode"), type='EXPLODE')
explode.use_edge_cut = True
if self.fade:
explode.show_dead = False
uv = obj.data.uv_layers.new(name=data_("Explode fade"))
explode.particle_uv = uv.name
mat = object_ensure_material(obj, data_("Explode Fade"))
mat.surface_render_method = 'DITHERED'
nodes = mat.node_tree.nodes
node_out_mat = nodes.new("ShaderNodeOutputMaterial")
node_surface = nodes.new("ShaderNodeBsdfPrincipled")
nodes.active = node_out_mat
node_x = node_surface.location[0]
node_y = node_surface.location[1] - 400
offset_x = 200
node_out_mat.location[0] = node_x + node_surface.width + offset_x
node_mix = nodes.new('ShaderNodeMixShader')
node_mix.location = (node_x - offset_x, node_y)
mat.node_tree.links.new(node_surface.outputs[0], node_mix.inputs[1])
mat.node_tree.links.new(node_mix.outputs["Shader"], node_out_mat.inputs["Surface"])
offset_x += 200
node_trans = nodes.new('ShaderNodeBsdfTransparent')
node_trans.location = (node_x - offset_x, node_y)
mat.node_tree.links.new(node_trans.outputs["BSDF"], node_mix.inputs[2])
offset_x += 200
node_ramp = nodes.new('ShaderNodeValToRGB')
node_ramp.location = (node_x - offset_x, node_y)
offset_x += 200
mat.node_tree.links.new(node_ramp.outputs["Alpha"], node_mix.inputs["Fac"])
color_ramp = node_ramp.color_ramp
color_ramp.elements[0].color[3] = 0.0
color_ramp.elements[1].color[3] = 1.0
if self.style == 'BLEND':
color_ramp.elements[0].position = 0.333
color_ramp.elements[1].position = 0.666
if obj == to_obj:
# reverse ramp alpha
color_ramp.elements[0].color[3] = 1.0
color_ramp.elements[1].color[3] = 0.0
node_sep = nodes.new('ShaderNodeSeparateXYZ')
node_sep.location = (node_x - offset_x, node_y)
offset_x += 200
mat.node_tree.links.new(node_sep.outputs["X"], node_ramp.inputs["Fac"])
node_uv = nodes.new('ShaderNodeUVMap')
node_uv.location = (node_x - offset_x, node_y)
node_uv.uv_map = uv.name
mat.node_tree.links.new(node_uv.outputs["UV"], node_sep.inputs["Vector"])
if self.style == 'BLEND':
settings.physics_type = 'KEYED'
settings.use_emit_random = False
settings.rotation_mode = 'NOR'
psys = obj.particle_systems[-1]
context_override["particle_system"] = obj.particle_systems[-1]
with context.temp_override(**context_override):
bpy.ops.particle.new_target()
bpy.ops.particle.new_target()
if obj == from_obj:
psys.targets[1].object = to_obj
else:
psys.targets[0].object = from_obj
settings.normal_factor = -self.velocity
explode.show_unborn = False
explode.show_dead = True
else:
settings.factor_random = self.velocity
settings.angular_velocity_factor = self.velocity / 10.0
return {'FINISHED'}
def invoke(self, context, _event):
self.frame_start = context.scene.frame_current
self.frame_end = self.frame_start + self.frame_duration
return self.execute(context)
def obj_bb_minmax(obj, min_co, max_co):
for i in range(0, 8):
bb_vec = obj.matrix_world @ Vector(obj.bound_box[i])
min_co[0] = min(bb_vec[0], min_co[0])
min_co[1] = min(bb_vec[1], min_co[1])
min_co[2] = min(bb_vec[2], min_co[2])
max_co[0] = max(bb_vec[0], max_co[0])
max_co[1] = max(bb_vec[1], max_co[1])
max_co[2] = max(bb_vec[2], max_co[2])
def grid_location(x, y):
return (x * 200, y * 150)
class QuickSmoke(ObjectModeOperator, Operator):
"""Use selected objects as smoke emitters"""
bl_idname = "object.quick_smoke"
bl_label = "Quick Smoke"
bl_options = {'REGISTER', 'UNDO'}
style: EnumProperty(
name="Smoke Style",
items=(
('SMOKE', "Smoke", ""),
('FIRE', "Fire", ""),
('BOTH', "Smoke & Fire", ""),
),
default='SMOKE',
)
show_flows: BoolProperty(
name="Render Smoke Objects",
description="Keep the smoke objects visible during rendering",
default=False,
)
def execute(self, context):
if not bpy.app.build_options.fluid:
self.report({'ERROR'}, "Built without Fluid modifier")
return {'CANCELLED'}
mesh_objects = [
obj for obj in context.selected_objects
if obj.type == 'MESH'
]
min_co = Vector((100000.0, 100000.0, 100000.0))
max_co = -min_co
if not mesh_objects:
self.report({'ERROR'}, "Select at least one mesh object")
return {'CANCELLED'}
for obj in mesh_objects:
fluid = obj.modifiers.new(name=data_("Fluid"), type='FLUID')
fluid.fluid_type = 'FLOW'
# set type
fluid.flow_settings.flow_type = self.style
# set flow behavior
fluid.flow_settings.flow_behavior = 'INFLOW'
# use some surface distance for smoke emission
fluid.flow_settings.surface_distance = 1.0
if not self.show_flows:
obj.display_type = 'WIRE'
# store bounding box min/max for the domain object
obj_bb_minmax(obj, min_co, max_co)
# add the smoke domain object
bpy.ops.mesh.primitive_cube_add()
obj = context.active_object
obj.name = data_("Smoke Domain")
# give the smoke some room above the flows
obj.location = 0.5 * (max_co + min_co) + Vector((0.0, 0.0, 1.0))
obj.scale = 0.5 * (max_co - min_co) + Vector((1.0, 1.0, 2.0))
# setup smoke domain
fluid = obj.modifiers.new(name=data_("Fluid"), type='FLUID')
fluid.fluid_type = 'DOMAIN'
# The default value leads to unstable simulations (see #126924).
fluid.domain_settings.cfl_condition = 4.0
if self.style == {'FIRE', 'BOTH'}:
fluid.domain_settings.use_noise = True
# ensure correct cache file format for smoke
if bpy.app.build_options.openvdb:
fluid.domain_settings.cache_data_format = 'OPENVDB'
# Setup material
# Cycles and EEVEE.
bpy.ops.object.material_slot_add()
mat = bpy.data.materials.new(data_("Smoke Domain Material"))
obj.material_slots[0].material = mat
# Set node variables and clear the default nodes
tree = mat.node_tree
nodes = tree.nodes
links = tree.links
nodes.clear()
# Create shader nodes
# Material output
node_out = nodes.new(type='ShaderNodeOutputMaterial')
node_out.location = grid_location(6, 1)
# Add Principled Volume
node_principled = nodes.new(type='ShaderNodeVolumePrincipled')
node_principled.location = grid_location(4, 1)
links.new(node_principled.outputs["Volume"], node_out.inputs["Volume"])
node_principled.inputs["Density"].default_value = 5.0
if self.style in {'FIRE', 'BOTH'}:
node_principled.inputs["Blackbody Intensity"].default_value = 1.0
return {'FINISHED'}
class QuickLiquid(Operator):
"""Make selected objects liquid"""
bl_idname = "object.quick_liquid"
bl_label = "Quick Liquid"
bl_options = {'REGISTER', 'UNDO'}
show_flows: BoolProperty(
name="Render Liquid Objects",
description="Keep the liquid objects visible during rendering",
default=False,
)
def execute(self, context):
if not bpy.app.build_options.fluid:
self.report({'ERROR'}, "Built without Fluid modifier")
return {'CANCELLED'}
mesh_objects = [
obj for obj in context.selected_objects
if obj.type == 'MESH'
]
min_co = Vector((100000.0, 100000.0, 100000.0))
max_co = -min_co
if not mesh_objects:
self.report({'ERROR'}, "Select at least one mesh object")
return {'CANCELLED'}
# set shading type to wireframe so that liquid particles are visible
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
for space in area.spaces:
if space.type == 'VIEW_3D':
space.shading.type = 'WIREFRAME'
for obj in mesh_objects:
fluid = obj.modifiers.new(name=data_("Fluid"), type='FLUID')
fluid.fluid_type = 'FLOW'
# set type
fluid.flow_settings.flow_type = 'LIQUID'
# set flow behavior
fluid.flow_settings.flow_behavior = 'GEOMETRY'
# use some surface distance for smoke emission
fluid.flow_settings.surface_distance = 0.0
if not self.show_flows:
obj.display_type = 'WIRE'
# store bounding box min/max for the domain object
obj_bb_minmax(obj, min_co, max_co)
# add the liquid domain object
bpy.ops.mesh.primitive_cube_add(align='WORLD')
obj = context.active_object
obj.name = data_("Liquid Domain")
# give the liquid some room above the flows
obj.location = 0.5 * (max_co + min_co) + Vector((0.0, 0.0, -1.0))
obj.scale = 0.5 * (max_co - min_co) + Vector((1.0, 1.0, 2.0))
# setup liquid domain
fluid = obj.modifiers.new(name=data_("Fluid"), type='FLUID')
fluid.fluid_type = 'DOMAIN'
# set all domain borders to obstacle
fluid.domain_settings.use_collision_border_front = True
fluid.domain_settings.use_collision_border_back = True
fluid.domain_settings.use_collision_border_right = True
fluid.domain_settings.use_collision_border_left = True
fluid.domain_settings.use_collision_border_top = True
fluid.domain_settings.use_collision_border_bottom = True
# ensure correct cache file formats for liquid
if bpy.app.build_options.openvdb:
fluid.domain_settings.cache_data_format = 'OPENVDB'
fluid.domain_settings.cache_mesh_format = 'BOBJECT'
# change domain type, will also allocate and show particle system for FLIP
fluid.domain_settings.domain_type = 'LIQUID'
# set color mapping field to show phi grid for liquid
fluid.domain_settings.color_ramp_field = 'PHI'
# perform a single slice of the domain
fluid.domain_settings.use_slice = True
# set display thickness to a lower value for more detailed display of phi grids
fluid.domain_settings.display_thickness = 0.02
# make the domain smooth so it renders nicely
bpy.ops.object.shade_smooth()
# create a ray-transparent material for the domain
bpy.ops.object.material_slot_add()
mat = bpy.data.materials.new(data_("Liquid Domain Material"))
obj.material_slots[0].material = mat
# Set node variables and clear the default nodes
tree = mat.node_tree
nodes = tree.nodes
links = tree.links
nodes.clear()
# Create shader nodes
# Material output
node_out = nodes.new(type='ShaderNodeOutputMaterial')
node_out.location = grid_location(6, 1)
# Add Glass
node_glass = nodes.new(type='ShaderNodeBsdfGlass')
node_glass.location = grid_location(4, 1)
links.new(node_glass.outputs["BSDF"], node_out.inputs["Surface"])
node_glass.inputs["IOR"].default_value = 1.33
# Add Absorption
node_absorption = nodes.new(type='ShaderNodeVolumeAbsorption')
node_absorption.location = grid_location(4, 2)
links.new(node_absorption.outputs["Volume"], node_out.inputs["Volume"])
node_absorption.inputs["Color"].default_value = (0.8, 0.9, 1.0, 1.0)
return {'FINISHED'}
classes = (
QuickExplode,
QuickFur,
QuickSmoke,
QuickLiquid,
)

View File

@@ -0,0 +1,183 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from bpy.types import Operator
from mathutils import Vector
def randomize_selected(context, seed, delta, loc, rot, scale, scale_even, _scale_min):
import random
from random import uniform
random.seed(seed)
def rand_vec(vec_range):
return Vector(uniform(-val, val) for val in vec_range)
for obj in context.selected_objects:
if loc:
if delta:
obj.delta_location += rand_vec(loc)
else:
obj.location += rand_vec(loc)
else:
# Otherwise the values change under us.
uniform(0.0, 0.0)
uniform(0.0, 0.0)
uniform(0.0, 0.0)
if rot:
vec = rand_vec(rot)
rotation_mode = obj.rotation_mode
if rotation_mode in {'QUATERNION', 'AXIS_ANGLE'}:
obj.rotation_mode = 'XYZ'
if delta:
obj.delta_rotation_euler[0] += vec[0]
obj.delta_rotation_euler[1] += vec[1]
obj.delta_rotation_euler[2] += vec[2]
else:
obj.rotation_euler[0] += vec[0]
obj.rotation_euler[1] += vec[1]
obj.rotation_euler[2] += vec[2]
obj.rotation_mode = rotation_mode
else:
uniform(0.0, 0.0), uniform(0.0, 0.0), uniform(0.0, 0.0)
if scale:
if delta:
org_sca_x, org_sca_y, org_sca_z = obj.delta_scale
else:
org_sca_x, org_sca_y, org_sca_z = obj.scale
sca_x, sca_y, sca_z = (
uniform(-scale[0] + 2.0, scale[0]),
uniform(-scale[1] + 2.0, scale[1]),
uniform(-scale[2] + 2.0, scale[2]),
)
if scale_even:
aX = sca_x * org_sca_x
aY = sca_x * org_sca_y
aZ = sca_x * org_sca_z
else:
aX = sca_x * org_sca_x
aY = sca_y * org_sca_y
aZ = sca_z * org_sca_z
if delta:
obj.delta_scale = aX, aY, aZ
else:
obj.scale = aX, aY, aZ
else:
uniform(0.0, 0.0)
uniform(0.0, 0.0)
uniform(0.0, 0.0)
from bpy.props import (
BoolProperty,
FloatVectorProperty,
IntProperty,
)
class RandomizeLocRotSize(Operator):
"""Randomize objects location, rotation, and scale"""
bl_idname = "object.randomize_transform"
bl_label = "Randomize Transform"
bl_options = {'REGISTER', 'UNDO'}
random_seed: IntProperty(
name="Random Seed",
description="Seed value for the random generator",
min=0,
max=10000,
default=0,
)
use_delta: BoolProperty(
name="Transform Delta",
description="Randomize delta transform values instead of regular transform",
default=False,
)
use_loc: BoolProperty(
name="Randomize Location",
description="Randomize the location values",
default=True,
)
loc: FloatVectorProperty(
name="Location",
description="Maximum distance the objects can spread over each axis",
min=-100.0,
max=100.0,
default=(0.0, 0.0, 0.0),
subtype='TRANSLATION',
)
use_rot: BoolProperty(
name="Randomize Rotation",
description="Randomize the rotation values",
default=True,
)
rot: FloatVectorProperty(
name="Rotation",
description="Maximum rotation over each axis",
min=-3.141592, # math.pi
max=+3.141592,
default=(0.0, 0.0, 0.0),
subtype='EULER',
)
use_scale: BoolProperty(
name="Randomize Scale",
description="Randomize the scale values",
default=True,
)
scale_even: BoolProperty(
name="Scale Even",
description="Use the same scale value for all axis",
default=False,
)
'''scale_min: FloatProperty(
name="Minimum Scale Factor",
description="Lowest scale percentage possible",
min=-1.0, max=1.0, precision=3,
default=0.15,
)'''
scale: FloatVectorProperty(
name="Scale",
description="Maximum scale randomization over each axis",
min=-100.0,
max=100.0,
default=(1.0, 1.0, 1.0),
)
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def execute(self, context):
seed = self.random_seed
delta = self.use_delta
loc = None if not self.use_loc else self.loc
rot = None if not self.use_rot else Vector(self.rot)
scale = None if not self.use_scale else self.scale
scale_even = self.scale_even
# scale_min = self.scale_min
scale_min = 0
randomize_selected(context, seed, delta, loc, rot, scale, scale_even, scale_min)
return {'FINISHED'}
classes = (
RandomizeLocRotSize,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from bpy.types import Operator
class RENDER_OT_swap_dimensions(Operator):
bl_label = "Swap Dimensions"
bl_idname = 'render.swap_dimensions'
bl_description = "Flip X and Y resolutions"
bl_options = {'INTERNAL'}
def execute(self, context):
rd = context.scene.render
rd.resolution_x, rd.resolution_y = (rd.resolution_y, rd.resolution_x)
return {'FINISHED'}
classes = (RENDER_OT_swap_dimensions,)

View File

@@ -0,0 +1,323 @@
# SPDX-FileCopyrightText: 2013-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import Operator
from bpy.props import (
EnumProperty,
IntProperty,
)
class CopyRigidbodySettings(Operator):
"""Copy Rigid Body settings from active object to selected"""
bl_idname = "rigidbody.object_settings_copy"
bl_label = "Copy Rigid Body Settings"
bl_options = {'REGISTER', 'UNDO'}
_attrs = (
"type",
"kinematic",
"mass",
"collision_shape",
"use_margin",
"collision_margin",
"friction",
"restitution",
"use_deactivation",
"use_start_deactivated",
"deactivate_linear_velocity",
"deactivate_angular_velocity",
"linear_damping",
"angular_damping",
"collision_collections",
"mesh_source",
"use_deform",
"enabled",
)
@classmethod
def poll(cls, context):
obj = context.object
return (obj and obj.rigid_body)
def execute(self, context):
obj_act = context.object
# Deselect all non mesh objects and objects that
# already have a rigid body attached.
rb_objects = []
for o in context.selected_objects:
if o.type != 'MESH' or o.rigid_body is not None:
o.select_set(False)
if o.rigid_body is not None:
rb_objects.append(o)
bpy.ops.rigidbody.objects_add()
# Ensure that the rigid body objects
# we've de-selected are selected again.
for o in rb_objects:
o.select_set(True)
objects = context.selected_objects
if objects:
rb_from = obj_act.rigid_body
# copy settings
for o in objects:
rb_to = o.rigid_body
if o == obj_act:
continue
for attr in self._attrs:
setattr(rb_to, attr, getattr(rb_from, attr))
return {'FINISHED'}
class BakeToKeyframes(Operator):
"""Bake rigid body transformations of selected objects to keyframes"""
bl_idname = "rigidbody.bake_to_keyframes"
bl_label = "Bake to Keyframes"
bl_options = {'REGISTER', 'UNDO'}
frame_start: IntProperty(
name="Start Frame",
description="Start frame for baking",
min=0, max=300000,
default=1,
)
frame_end: IntProperty(
name="End Frame",
description="End frame for baking",
min=1, max=300000,
default=250,
)
step: IntProperty(
name="Frame Step",
description="Frame Step",
min=1, max=120,
default=1,
)
@classmethod
def poll(cls, context):
obj = context.object
return (obj and obj.rigid_body)
def execute(self, context):
from bpy_extras import anim_utils
bake = []
objects = []
scene = context.scene
frame_orig = scene.frame_current
frames_step = range(self.frame_start, self.frame_end + 1, self.step)
frames_full = range(self.frame_start, self.frame_end + 1)
# filter objects selection
for obj in context.selected_objects:
if not obj.rigid_body or obj.rigid_body.type != 'ACTIVE':
obj.select_set(False)
objects = context.selected_objects
if objects:
# store transformation data
# need to start at scene start frame so simulation is run from the beginning
for f in frames_full:
scene.frame_set(f)
if f in frames_step:
mat = {}
for i, obj in enumerate(objects):
mat[i] = obj.matrix_world.copy()
bake.append(mat)
# apply transformations as keyframes
for i, f in enumerate(frames_step):
scene.frame_set(f)
for j, obj in enumerate(objects):
mat = bake[i][j]
# Convert world space transform to parent space, so parented objects don't get offset after baking.
if obj.parent:
mat = obj.matrix_parent_inverse.inverted() @ obj.parent.matrix_world.inverted() @ mat
obj.location = mat.to_translation()
rot_mode = obj.rotation_mode
if rot_mode == 'QUATERNION':
q1 = obj.rotation_quaternion
q2 = mat.to_quaternion()
# make quaternion compatible with the previous one
if q1.dot(q2) < 0.0:
obj.rotation_quaternion = -q2
else:
obj.rotation_quaternion = q2
elif rot_mode == 'AXIS_ANGLE':
# this is a little roundabout but there's no better way right now
aa = mat.to_quaternion().to_axis_angle()
obj.rotation_axis_angle = (aa[1], *aa[0])
else: # euler
# make sure euler rotation is compatible to previous frame
# NOTE: assume that on first frame, the starting rotation is appropriate
obj.rotation_euler = mat.to_euler(rot_mode, obj.rotation_euler)
bpy.ops.anim.keyframe_insert_by_name(type='BUILTIN_KSI_LocRot')
# remove baked objects from simulation
bpy.ops.rigidbody.objects_remove()
# clean up keyframes
for obj in objects:
channelbag = anim_utils.action_get_channelbag_for_slot(
obj.animation_data.action,
obj.animation_data.action_slot,
)
if not channelbag:
continue
for fcu in channelbag.fcurves:
keyframe_points = fcu.keyframe_points
i = 1
# remove unneeded keyframes
while i < len(keyframe_points) - 1:
val_prev = keyframe_points[i - 1].co[1]
val_next = keyframe_points[i + 1].co[1]
val = keyframe_points[i].co[1]
if abs(val - val_prev) + abs(val - val_next) < 0.0001:
keyframe_points.remove(keyframe_points[i])
else:
i += 1
# use linear interpolation for better visual results
for keyframe in keyframe_points:
keyframe.interpolation = 'LINEAR'
# return to the frame we started on
scene.frame_set(frame_orig)
return {'FINISHED'}
def invoke(self, context, _event):
scene = context.scene
self.frame_start = scene.frame_start
self.frame_end = scene.frame_end
wm = context.window_manager
return wm.invoke_props_dialog(self)
class ConnectRigidBodies(Operator):
"""Create rigid body constraints between selected rigid bodies"""
bl_idname = "rigidbody.connect"
bl_label = "Connect Rigid Bodies"
bl_options = {'REGISTER', 'UNDO'}
con_type: EnumProperty(
name="Type",
description="Type of generated constraint",
# XXX Would be nice to get icons too, but currently not possible ;)
items=tuple(
(e.identifier, e.name, e.description, e. value)
for e in bpy.types.RigidBodyConstraint.bl_rna.properties["type"].enum_items
),
default='FIXED',
)
pivot_type: EnumProperty(
name="Location",
description="Constraint pivot location",
items=(
('CENTER', "Center", "Pivot location is between the constrained rigid bodies"),
('ACTIVE', "Active", "Pivot location is at the active object position"),
('SELECTED', "Selected", "Pivot location is at the selected object position"),
),
default='CENTER',
)
connection_pattern: EnumProperty(
name="Connection Pattern",
description="Pattern used to connect objects",
items=(
('SELECTED_TO_ACTIVE', "Selected to Active", "Connect selected objects to the active object"),
('CHAIN_DISTANCE', "Chain by Distance", "Connect objects as a chain based on distance, "
"starting at the active object"),
),
default='SELECTED_TO_ACTIVE',
)
@classmethod
def poll(cls, context):
obj = context.object
return (obj and obj.rigid_body)
def _add_constraint(self, context, object1, object2):
if object1 == object2:
return
if self.pivot_type == 'ACTIVE':
loc = object1.location
elif self.pivot_type == 'SELECTED':
loc = object2.location
else:
loc = (object1.location + object2.location) / 2.0
ob = bpy.data.objects.new("Constraint", object_data=None)
ob.location = loc
context.scene.collection.objects.link(ob)
context.view_layer.objects.active = ob
ob.select_set(True)
bpy.ops.rigidbody.constraint_add()
con_obj = context.active_object
con_obj.empty_display_type = 'ARROWS'
con = con_obj.rigid_body_constraint
con.type = self.con_type
con.object1 = object1
con.object2 = object2
def execute(self, context):
view_layer = context.view_layer
objects = context.selected_objects
obj_act = context.active_object
change = False
if self.connection_pattern == 'CHAIN_DISTANCE':
objs_sorted = [obj_act]
objects_tmp = context.selected_objects
try:
objects_tmp.remove(obj_act)
except ValueError:
pass
last_obj = obj_act
while objects_tmp:
objects_tmp.sort(key=lambda o: (last_obj.location - o.location).length)
last_obj = objects_tmp.pop(0)
objs_sorted.append(last_obj)
for i in range(1, len(objs_sorted)):
self._add_constraint(context, objs_sorted[i - 1], objs_sorted[i])
change = True
else: # SELECTED_TO_ACTIVE
for obj in objects:
self._add_constraint(context, obj_act, obj)
change = True
if change:
# restore selection
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
view_layer.objects.active = obj_act
return {'FINISHED'}
else:
self.report({'WARNING'}, "No other objects selected")
return {'CANCELLED'}
classes = (
BakeToKeyframes,
ConnectRigidBodies,
CopyRigidbodySettings,
)

View File

@@ -0,0 +1,291 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Originally written by Matt Ebb
import bpy
from bpy.types import Operator
from bpy.app.translations import pgettext_rpt as rpt_
def guess_player_path(preset):
import sys
found_version = (0, 0, 0)
if preset == 'INTERNAL':
return bpy.app.binary_path, found_version
elif preset == 'DJV':
player_path = "djv"
if sys.platform == "linux":
found_version = (3, 4, 0) # Assume it is at least 3.4.0
elif sys.platform == "darwin":
import os
djv3_path = "/Applications/DJV.app/Contents/MacOS/DJV"
djv2_path = "/Applications/DJV2.app/Contents/Resources/bin/djv"
if os.path.exists(djv3_path):
player_path = djv3_path
found_version = (3, 4, 0) # Assume it is at least 3.4.0
elif os.path.exists(djv2_path):
player_path = djv2_path
found_version = (2, 0, 0)
elif sys.platform == "win32":
import winreg
reg_value = None
try:
def extract_version(key_name):
"""Given a key_name like "key name 3.3.4" extract version as (3, 3, 4)"""
version = None
parts = key_name.rsplit(" ", 1)
if len(parts) == 2:
try:
version = tuple(int(x) for x in parts[1].split("."))
except ValueError:
pass
return version
# Enumerate versioned subkeys (e.g. "DJV 3.3.4", "DJV 3.4.0", etc.) and
# pick the key with the greatest version.
reg_base = r"SOFTWARE\WOW6432Node\Grizzly Peak 3D"
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_base, 0, winreg.KEY_READ) as base_key:
best_version = None
best_subkey = None
index = 0
while True:
try:
subkey_name = winreg.EnumKey(base_key, index)
except OSError:
break
index += 1
if (version := extract_version(subkey_name)) is not None:
if best_version is None or version > best_version:
best_subkey = subkey_name
best_version = version
found_version = version
if best_subkey is not None:
reg_path = reg_base + "\\" + best_subkey
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_READ) as regkey:
reg_value = winreg.QueryValue(regkey, None)
except OSError:
pass
# Fallback to djv2 if we didn't find anything
if not reg_value:
try:
reg_path = r"SOFTWARE\Classes\djv\shell\open\command"
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, reg_path, 0, winreg.KEY_READ) as regkey:
reg_value = winreg.QueryValue(regkey, None)
found_version = (2, 0, 0)
except OSError:
pass
if reg_value:
if found_version > (2, 0, 0):
player_path = reg_value.strip() + "\\bin\\djv.exe"
else:
binary = "djv.exe"
index = reg_value.find(binary)
if index > 0:
player_path = reg_value[:index + len(binary)]
elif preset == 'FRAMECYCLER':
player_path = "framecycler"
elif preset == 'RV':
player_path = "rv"
elif preset == 'MPLAYER':
player_path = "mplayer"
else:
player_path = ""
return player_path, found_version
class PlayRenderedAnim(Operator):
"""Play back rendered frames/movies using an external player"""
bl_idname = "render.play_rendered_anim"
bl_label = "Play Rendered Animation"
bl_options = {'REGISTER'}
@staticmethod
def _frame_path_with_number_char(rd, ch, **kwargs):
# Replace the number with `ch`.
# NOTE: make an api call for this would be nice, however this isn't needed in many places.
file_a = rd.frame_path(frame=0, **kwargs)
file_b = rd.frame_path(frame=-1, **kwargs)
assert len(file_b) == len(file_a) + 1
for number_beg in range(len(file_a)):
if file_a[number_beg] != file_b[number_beg]:
break
for number_end in range(-1, -(len(file_a) + 1), -1):
if file_a[number_end] != file_b[number_end]:
break
number_end += len(file_a) + 1
return file_a[:number_beg] + (ch * (number_end - number_beg)) + file_a[number_end:]
def execute(self, context):
import os
import subprocess
from shlex import quote
scene = context.scene
rd = scene.render
prefs = context.preferences
fps_final = rd.fps / rd.fps_base
preset = prefs.filepaths.animation_player_preset
# file_path = bpy.path.abspath(rd.filepath) # UNUSED
is_movie = rd.is_movie_format
views_format = rd.image_settings.views_format
if rd.use_multiview and views_format == 'INDIVIDUAL':
view_suffix = rd.views.active.file_suffix
else:
view_suffix = ""
found_version = (0, 0, 0)
# try and guess a command line if it doesn't exist
if preset == 'CUSTOM':
player_path = prefs.filepaths.animation_player
else:
player_path, found_version = guess_player_path(preset)
if is_movie is False and preset in {'FRAMECYCLER', 'RV', 'MPLAYER'}:
file = PlayRenderedAnim._frame_path_with_number_char(rd, "#", view=view_suffix)
file = bpy.path.abspath(file) # expand '//'
else:
path_valid = True
# works for movies and images
file = rd.frame_path(frame=scene.frame_start, preview=scene.use_preview_range, view=view_suffix)
file = bpy.path.abspath(file) # expand '//'
if not os.path.exists(file):
err_msg = rpt_("File {!r} not found").format(file)
self.report({'WARNING'}, err_msg)
path_valid = False
# one last try for full range if we used preview range
if scene.use_preview_range and not path_valid:
file = rd.frame_path(frame=scene.frame_start, preview=False, view=view_suffix)
file = bpy.path.abspath(file) # expand '//'
err_msg = rpt_("File {!r} not found").format(file)
if not os.path.exists(file):
self.report({'WARNING'}, err_msg)
remove_OCIO_env = False
cmd = [player_path]
# extra options, fps controls etc.
if scene.use_preview_range:
frame_start = scene.frame_preview_start
frame_end = scene.frame_preview_end
else:
frame_start = scene.frame_start
frame_end = scene.frame_end
if preset == 'INTERNAL':
# Use the current GPU backend for the player.
import gpu
gpu_backend = gpu.platform.backend_type_get()
if gpu_backend not in {'NONE', 'UNKNOWN'}:
cmd.extend([
"--gpu-backend", gpu_backend.lower(),
])
del gpu, gpu_backend
opts = [
"-a",
"-f", str(rd.fps), str(rd.fps_base),
"-s", str(frame_start),
"-e", str(frame_end),
"-j", str(scene.frame_step),
"-c", str(prefs.system.memory_cache_limit),
file,
]
cmd.extend(opts)
elif preset == 'DJV':
if found_version >= (3, 4, 0):
opts = [
file,
"-speed", str(fps_final),
"-in", str(frame_start),
"-out", str(frame_end),
"-seek", str(scene.frame_current),
"-timeUnits", "Frames",
]
elif found_version >= (3, 0, 0):
opts = [
file,
"-speed", str(fps_final),
]
else:
remove_OCIO_env = True
opts = [
file,
"-speed", str(fps_final),
"-in_out", str(frame_start), str(frame_end),
"-frame", str(scene.frame_current),
"-time_units", "Frames",
]
cmd.extend(opts)
elif preset == 'FRAMECYCLER':
opts = [file, "{:d}-{:d}".format(scene.frame_start, scene.frame_end)]
cmd.extend(opts)
elif preset == 'RV':
opts = ["-fps", str(rd.fps), "-play"]
if scene.use_preview_range:
opts += [
file.replace("#", "", file.count('#') - 1),
"{:d}-{:d}".format(frame_start, frame_end),
]
else:
opts.append(file)
cmd.extend(opts)
elif preset == 'MPLAYER':
opts = []
if is_movie:
opts.append(file)
else:
opts += [
("mf://" + file.replace("#", "?")),
"-mf",
"fps={:.4f}".format(fps_final),
]
opts += ["-loop", "0", "-really-quiet", "-fs"]
cmd.extend(opts)
else: # 'CUSTOM'
cmd.append(file)
# launch it
print("Executing command:\n ", " ".join(quote(c) for c in cmd))
try:
env_copy = os.environ.copy()
if remove_OCIO_env:
env_copy.pop("OCIO", None)
subprocess.Popen(cmd, env=env_copy)
except Exception as ex:
err_msg = rpt_("Couldn't run external animation player with command {!r}\n{:s}").format(cmd, str(ex))
self.report(
{'ERROR'},
err_msg,
)
return {'CANCELLED'}
return {'FINISHED'}
classes = (
PlayRenderedAnim,
)

View File

@@ -0,0 +1,433 @@
# SPDX-FileCopyrightText: 2010-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import (
FileHandler,
Operator,
)
from bpy.props import (
EnumProperty,
FloatProperty,
IntProperty,
)
from bpy.app.translations import pgettext_rpt as rpt_
def _animated_properties_get(strip):
animated_properties = []
if hasattr(strip, "volume"):
animated_properties.append("volume")
if hasattr(strip, "blend_alpha"):
animated_properties.append("blend_alpha")
return animated_properties
class SequencerCrossfadeSounds(Operator):
"""Do cross-fading volume animation of two selected sound strips"""
bl_idname = "sequencer.crossfade_sounds"
bl_label = "Crossfade Sounds"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
sequencer_scene = context.sequencer_scene
if not sequencer_scene:
return False
strip = context.active_strip
return strip and (strip.type == 'SOUND')
def execute(self, context):
scene = context.sequencer_scene
strip1 = None
strip2 = None
for strip in scene.sequence_editor.strips:
if strip.select and strip.type == 'SOUND':
if strip1 is None:
strip1 = strip
elif strip2 is None:
strip2 = strip
else:
strip2 = None
break
if strip2 is None:
self.report({'ERROR'}, "Select 2 sound strips")
return {'CANCELLED'}
if strip1.left_handle > strip2.left_handle:
strip1, strip2 = strip2, strip1
if strip1.right_handle > strip2.left_handle:
strip1.keyframe_insert("volume", frame=strip2.left_handle)
strip1.volume = 0
strip1.keyframe_insert("volume", frame=strip1.right_handle)
strip2.keyframe_insert("volume", frame=strip1.right_handle)
strip2.volume = 0
strip2.keyframe_insert("volume", frame=strip2.left_handle)
return {'FINISHED'}
self.report({'ERROR'}, "The selected strips don't overlap")
return {'CANCELLED'}
class SequencerSplitMulticam(Operator):
"""Split multicam strip and select camera"""
bl_idname = "sequencer.split_multicam"
bl_label = "Split Multicam"
bl_options = {'REGISTER', 'UNDO'}
camera: IntProperty(
name="Camera",
min=1, max=32,
soft_min=1, soft_max=32,
default=1,
)
@classmethod
def poll(cls, context):
sequencer_scene = context.sequencer_scene
if not sequencer_scene:
return False
strip = context.active_strip
return strip and (strip.type == 'MULTICAM')
def execute(self, context):
scene = context.sequencer_scene
camera = self.camera
strip = context.active_strip
if strip.multicam_source == camera or camera >= strip.channel:
return {'FINISHED'}
cfra = scene.frame_current
right_strip = strip.split(frame=cfra, split_method='SOFT')
if right_strip:
strip.select = False
right_strip.select = True
scene.sequence_editor.active_strip = right_strip
context.active_strip.multicam_source = camera
return {'FINISHED'}
class SequencerDeinterlaceSelectedMovies(Operator):
"""Deinterlace all selected movie sources"""
bl_idname = "sequencer.deinterlace_selected_movies"
bl_label = "Deinterlace Movies"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
scene = context.sequencer_scene
return (scene and scene.sequence_editor)
def execute(self, context):
scene = context.sequencer_scene
for strip in scene.sequence_editor.strips:
if strip.select and strip.type == 'MOVIE':
strip.use_deinterlace = True
return {'FINISHED'}
class SequencerFadesClear(Operator):
"""Removes fade animation from selected strips"""
bl_idname = "sequencer.fades_clear"
bl_label = "Clear Fades"
bl_options = {'REGISTER', 'UNDO'}
@classmethod
def poll(cls, context):
sequencer_scene = context.sequencer_scene
if not sequencer_scene:
return False
strip = context.active_strip
return strip is not None
def execute(self, context):
from bpy_extras import anim_utils
scene = context.sequencer_scene
animation_data = scene.animation_data
if animation_data is None:
return {'CANCELLED'}
channelbag = anim_utils.action_get_channelbag_for_slot(animation_data.action, animation_data.action_slot)
if channelbag is None:
return {'CANCELLED'}
fcurves = channelbag.fcurves
fcurve_map = {
curve.data_path: curve
for curve in fcurves
if curve.data_path.startswith("sequence_editor.strips")
}
for strip in context.selected_strips:
for animated_property in _animated_properties_get(strip):
data_path = strip.path_from_id() + "." + animated_property
curve = fcurve_map.get(data_path)
if curve:
fcurves.remove(curve)
setattr(strip, animated_property, 1.0)
strip.invalidate_cache('COMPOSITE')
return {'FINISHED'}
class SequencerFadesAdd(Operator):
"""Adds or updates a fade animation for either visual or audio strips"""
bl_idname = "sequencer.fades_add"
bl_label = "Add Fades"
bl_options = {'REGISTER', 'UNDO'}
duration_seconds: FloatProperty(
name="Fade Duration",
description="Duration of the fade in seconds",
default=1.0,
min=0.01,
)
type: EnumProperty(
items=(
('IN_OUT', "Fade In and Out", "Fade selected strips in and out"),
('IN', "Fade In", "Fade in selected strips"),
('OUT', "Fade Out", "Fade out selected strips"),
('CURSOR_FROM', "From Current Frame",
"Fade from the time cursor to the end of overlapping strips"),
('CURSOR_TO', "To Current Frame",
"Fade from the start of strips under the time cursor to the current frame"),
),
name="Fade Type",
description="Fade in, out, both in and out, to, or from the current frame. Default is both in and out",
default='IN_OUT',
)
@classmethod
def poll(cls, context):
sequencer_scene = context.sequencer_scene
if not sequencer_scene:
return False
# Can't use context.selected_strips as it can have an impact on performances
strip = context.active_strip
return strip is not None
def execute(self, context):
from math import floor
# We must create a scene action first if there's none
scene = context.sequencer_scene
if not scene.animation_data:
scene.animation_data_create()
if not scene.animation_data.action:
action = bpy.data.actions.new(scene.name + "Action")
scene.animation_data.action = action
strips = context.selected_strips
if not strips:
self.report({'ERROR'}, "No strips selected")
return {'CANCELLED'}
if self.type in {'CURSOR_TO', 'CURSOR_FROM'}:
strips = [
strip for strip in strips
if strip.left_handle < scene.frame_current < strip.right_handle
]
if not strips:
self.report({'ERROR'}, "Current frame not within strip framerange")
return {'CANCELLED'}
max_duration = min(strips, key=lambda strip: strip.duration).duration
max_duration = floor(max_duration / 2.0) if self.type == 'IN_OUT' else max_duration
faded_strips = []
for strip in strips:
duration = self.calculate_fade_duration(context, strip)
duration = min(duration, max_duration)
if not self.is_long_enough(strip, duration):
continue
for animated_property in _animated_properties_get(strip):
fade_fcurve = self.fade_find_or_create_fcurve(context, strip, animated_property)
fades = self.calculate_fades(strip, fade_fcurve, animated_property, duration)
self.fade_animation_clear(fade_fcurve, fades)
self.fade_animation_create(fade_fcurve, fades)
faded_strips.append(strip)
strip.invalidate_cache('COMPOSITE')
strip_string = "strip" if len(faded_strips) == 1 else "strips"
self.report({'INFO'}, rpt_("Added fade animation to {:d} {:s}").format(len(faded_strips), strip_string))
return {'FINISHED'}
def calculate_fade_duration(self, context, strip):
scene = context.sequencer_scene
frame_current = scene.frame_current
duration = 0.0
if self.type == 'CURSOR_TO':
duration = abs(frame_current - strip.left_handle)
elif self.type == 'CURSOR_FROM':
duration = abs(strip.right_handle - frame_current)
else:
duration = calculate_duration_frames(scene, self.duration_seconds)
return max(1, duration)
def is_long_enough(self, strip, duration=0.0):
minimum_duration = duration * 2 if self.type == 'IN_OUT' else duration
return strip.duration >= minimum_duration
def calculate_fades(self, strip, fade_fcurve, animated_property, duration):
"""
Returns a list of Fade objects
"""
fades = []
if self.type in {'IN', 'IN_OUT', 'CURSOR_TO'}:
fade = Fade(strip, fade_fcurve, 'IN', animated_property, duration)
fades.append(fade)
if self.type in {'OUT', 'IN_OUT', 'CURSOR_FROM'}:
fade = Fade(strip, fade_fcurve, 'OUT', animated_property, duration)
fades.append(fade)
return fades
def fade_find_or_create_fcurve(self, context, strip, animated_property):
"""
Iterates over all the fcurves until it finds an fcurve with a data path
that corresponds to the strip.
Returns the matching FCurve or creates a new one if the function can't find a match.
"""
scene = context.sequencer_scene
action = scene.animation_data.action
searched_data_path = strip.path_from_id(animated_property)
return action.fcurve_ensure_for_datablock(scene, searched_data_path)
def fade_animation_clear(self, fade_fcurve, fades):
"""
Removes existing keyframes in the fades' time range, in fast mode, without
updating the fcurve
"""
keyframe_points = fade_fcurve.keyframe_points
for fade in fades:
for keyframe in keyframe_points:
# The keyframe points list doesn't seem to always update as the
# operator re-runs Leading to trying to remove nonexistent keyframes
try:
if fade.start.x < keyframe.co[0] <= fade.end.x:
keyframe_points.remove(keyframe, fast=True)
except Exception:
pass
fade_fcurve.update()
def fade_animation_create(self, fade_fcurve, fades):
"""
Inserts keyframes in the fade_fcurve in fast mode using the Fade objects.
Updates the fcurve after having inserted all keyframes to finish the animation.
"""
keyframe_points = fade_fcurve.keyframe_points
for fade in fades:
for point in (fade.start, fade.end):
keyframe_points.insert(frame=point.x, value=point.y, options={'FAST'})
fade_fcurve.update()
# The graph editor and the audio wave-forms only redraw upon "moving" a keyframe.
keyframe_points[-1].co = keyframe_points[-1].co
class Fade:
# Data structure to represent fades.
__slots__ = (
"type",
"animated_property",
"duration",
"max_value",
"start",
"end",
)
def __init__(self, strip, fade_fcurve, ty, animated_property, duration):
from mathutils import Vector
self.type = ty
self.animated_property = animated_property
self.duration = duration
self.max_value = self.calculate_max_value(strip, fade_fcurve)
if ty == 'IN':
self.start = Vector((strip.left_handle, 0.0))
self.end = Vector((strip.left_handle + self.duration, self.max_value))
elif ty == 'OUT':
self.start = Vector((strip.right_handle - self.duration, self.max_value))
self.end = Vector((strip.right_handle, 0.0))
def calculate_max_value(self, strip, fade_fcurve):
"""
Returns the maximum Y coordinate the fade animation should use for a given strip
Uses either the strip's value for the animated property, or the next keyframe after the fade
"""
max_value = 0.0
if not fade_fcurve.keyframe_points:
max_value = getattr(strip, self.animated_property, 1.0)
else:
if self.type == 'IN':
fade_end = strip.left_handle + self.duration
keyframes = (k for k in fade_fcurve.keyframe_points if k.co[0] >= fade_end)
if self.type == 'OUT':
fade_start = strip.right_handle - self.duration
keyframes = (k for k in reversed(fade_fcurve.keyframe_points) if k.co[0] <= fade_start)
try:
max_value = next(keyframes).co[1]
except StopIteration:
pass
return max_value if max_value > 0.0 else 1.0
def __repr__(self):
return "Fade {!r}: {!r} to {!r}".format(self.type, self.start, self.end)
def calculate_duration_frames(scene, duration_seconds):
return round(duration_seconds * scene.render.fps / scene.render.fps_base)
class SequencerFileHandlerBase:
@classmethod
def poll_drop(cls, context):
return (
(context.region is not None) and
(context.region.type == 'WINDOW') and
(context.area is not None) and
(context.area.ui_type == 'SEQUENCE_EDITOR')
)
class SEQUENCER_FH_image_strip(FileHandler, SequencerFileHandlerBase):
bl_idname = "SEQUENCER_FH_image_strip"
bl_label = "Image strip"
bl_import_operator = "SEQUENCER_OT_image_strip_add"
bl_file_extensions = ";".join(bpy.path.extensions_image)
class SEQUENCER_FH_movie_strip(FileHandler, SequencerFileHandlerBase):
bl_idname = "SEQUENCER_FH_movie_strip"
bl_label = "Movie strip"
bl_import_operator = "SEQUENCER_OT_movie_strip_add"
bl_file_extensions = ";".join(bpy.path.extensions_movie)
class SEQUENCER_FH_sound_strip(FileHandler, SequencerFileHandlerBase):
bl_idname = "SEQUENCER_FH_sound_strip"
bl_label = "Sound strip"
bl_import_operator = "SEQUENCER_OT_sound_strip_add"
bl_file_extensions = ";".join(bpy.path.extensions_audio)
classes = (
SequencerCrossfadeSounds,
SequencerSplitMulticam,
SequencerDeinterlaceSelectedMovies,
SequencerFadesClear,
SequencerFadesAdd,
SEQUENCER_FH_image_strip,
SEQUENCER_FH_movie_strip,
SEQUENCER_FH_sound_strip,
)

View File

@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
from bpy.types import Operator
class SPREADSHEET_OT_toggle_pin(Operator):
"""Turn on or off pinning"""
bl_idname = "spreadsheet.toggle_pin"
bl_label = "Toggle Pin"
bl_options = {'REGISTER'}
@classmethod
def poll(cls, context):
space = context.space_data
return space and space.type == 'SPREADSHEET'
def execute(self, context):
space = context.space_data
if space.is_pinned:
self.unpin(context)
else:
self.pin(context)
return {'FINISHED'}
def pin(self, context):
space = context.space_data
space.is_pinned = True
def unpin(self, context):
space = context.space_data
space.is_pinned = False
classes = (
SPREADSHEET_OT_toggle_pin,
)
if __name__ == "__main__": # Only for live edit.
from bpy.utils import register_class
for cls in classes:
register_class(cls)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
# SPDX-FileCopyrightText: 2009-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"classes",
)
from bpy.types import Operator
from bpy.props import (
EnumProperty,
)
STATUS_OK = (1 << 0)
STATUS_ERR_ACTIVE_FACE = (1 << 1)
STATUS_ERR_NOT_SELECTED = (1 << 2)
STATUS_ERR_NOT_QUAD = (1 << 3)
STATUS_ERR_MISSING_UV_LAYER = (1 << 4)
STATUS_ERR_NO_FACES_SELECTED = (1 << 5)
def extend(scene, obj, EXTEND_MODE, use_uv_selection):
import bmesh
from .uvcalc_transform import is_face_uv_selected_fn_from_context
me = obj.data
bm = bmesh.from_edit_mesh(me)
f_act = bm.faces.active
if f_act is None:
return STATUS_ERR_ACTIVE_FACE # Active face cannot be none.
if not f_act.select:
return STATUS_ERR_NOT_SELECTED # Active face is not selected.
if len(f_act.verts) != 4:
return STATUS_ERR_NOT_QUAD # Active face is not a quad
uv_act = bm.loops.layers.uv.active # Always use the active UV layer.
if uv_act is None:
return STATUS_ERR_MISSING_UV_LAYER # Object's mesh doesn't have any UV layers.
if use_uv_selection:
face_select_test_fn = is_face_uv_selected_fn_from_context(scene, bm)
faces = [
f for f in bm.faces
if f.select and len(f.verts) == 4 and face_select_test_fn(f, False)
]
else:
faces = [
f for f in bm.faces
if f.select and len(f.verts) == 4
]
if not faces:
return STATUS_ERR_NO_FACES_SELECTED
# Our own local walker.
def walk_face_init(faces, f_act):
# First tag all faces True (so we don't UV-map them).
for f in bm.faces:
f.tag = True
# Then tag faces argument False.
for f in faces:
f.tag = False
# Tag the active face True since we begin there.
f_act.tag = True
def walk_face(f):
# All faces in this list must be tagged.
f.tag = True
faces_a = [f]
faces_b = []
while faces_a:
for f in faces_a:
for l in f.loops:
l_edge = l.edge
if (l_edge.is_manifold is True) and (l_edge.seam is False):
l_other = l.link_loop_radial_next
f_other = l_other.face
if not f_other.tag:
yield (f, l, f_other)
f_other.tag = True
faces_b.append(f_other)
# Swap.
faces_a, faces_b = faces_b, faces_a
faces_b.clear()
# Utility, only for `walk_edgeloop_all`.
def walk_edgeloop_all_impl_loop(loop_stack, edges_visited, l):
l_other = l.link_loop_next.link_loop_next
l_other_edge = l_other.edge
if l_other_edge not in edges_visited:
edges_visited.add(l_other_edge)
yield l_other_edge
if not l_other_edge.is_boundary:
loop_stack.append(l_other)
def walk_edgeloop_all(e):
# Walks over all edge loops connected by quads (even edges with 3+ users).
# Could make this a generic function.
loop_stack = []
edges_visited = {e}
yield e
# This initial iteration is needed because the loops never walk back over the face they come from.
for l in e.link_loops:
if len(l.face.verts) != 4:
continue
yield from walk_edgeloop_all_impl_loop(loop_stack, edges_visited, l)
while loop_stack and (l_test := loop_stack.pop()):
# Walk around the quad and then onto the next face.
l = l_test
while (l := l.link_loop_radial_next) is not l_test:
if len(l.face.verts) != 4:
continue
yield from walk_edgeloop_all_impl_loop(loop_stack, edges_visited, l)
def extrapolate_uv(
fac,
l_a_outer, l_a_inner,
l_b_outer, l_b_inner,
):
l_b_inner[:] = l_a_inner
l_b_outer[:] = l_a_inner + ((l_a_inner - l_a_outer) * fac)
def apply_uv(_f_prev, l_prev, _f_next):
l_a = [None, None, None, None]
l_b = [None, None, None, None]
l_a[0] = l_prev
l_a[1] = l_a[0].link_loop_next
l_a[2] = l_a[1].link_loop_next
l_a[3] = l_a[2].link_loop_next
# l_b
# +-----------+
# |(3) |(2)
# | |
# |l_next(0) |(1)
# +-----------+
# ^
# l_a |
# +-----------+
# |l_prev(0) |(1)
# | (f) |
# |(3) |(2)
# +-----------+
# Copy from this face to the one above.
# Get the other loops.
l_next = l_prev.link_loop_radial_next
if l_next.vert != l_prev.vert:
l_b[1] = l_next
l_b[0] = l_b[1].link_loop_next
l_b[3] = l_b[0].link_loop_next
l_b[2] = l_b[3].link_loop_next
else:
l_b[0] = l_next
l_b[1] = l_b[0].link_loop_next
l_b[2] = l_b[1].link_loop_next
l_b[3] = l_b[2].link_loop_next
l_a_uv = [l[uv_act].uv for l in l_a]
l_b_uv = [l[uv_act].uv for l in l_b]
if EXTEND_MODE == 'LENGTH_AVERAGE':
d1 = edge_lengths[l_a[1].edge.index][0]
d2 = edge_lengths[l_b[2].edge.index][0]
try:
fac = d2 / d1
except ZeroDivisionError:
fac = 1.0
elif EXTEND_MODE == 'LENGTH':
a0, b0, c0 = l_a[3].vert.co, l_a[0].vert.co, l_b[3].vert.co
a1, b1, c1 = l_a[2].vert.co, l_a[1].vert.co, l_b[2].vert.co
d1 = (a0 - b0).length + (a1 - b1).length
d2 = (b0 - c0).length + (b1 - c1).length
try:
fac = d2 / d1
except ZeroDivisionError:
fac = 1.0
else:
fac = 1.0
extrapolate_uv(
fac,
l_a_uv[3], l_a_uv[0],
l_b_uv[3], l_b_uv[0],
)
extrapolate_uv(
fac,
l_a_uv[2], l_a_uv[1],
l_b_uv[2], l_b_uv[1],
)
# -------------------------------------------
# Calculate average length per loop if needed.
if EXTEND_MODE == 'LENGTH_AVERAGE':
bm.edges.index_update()
edge_lengths = [None] * len(bm.edges)
for f in faces:
# We know it's a quad.
l_quad = f.loops[:]
# The opposite loops `l_quad[2]` & `l_quad[3]` are implicit (walking will handle).
for l_init in (l_quad[0], l_quad[1]):
# No need to check both because the initializing
# one side of the pair will have initialized the second.
l_init_edge = l_init.edge
if edge_lengths[l_init_edge.index] is not None:
continue
edge_length_store = [-1.0]
edge_length_accum = 0.0
edge_length_total = 0
for e in walk_edgeloop_all(l_init_edge):
# Any previously met edges should have expanded into `l_init_edge`
# (which has no length).
assert edge_lengths[e.index] is None
edge_lengths[e.index] = edge_length_store
edge_length_accum += e.calc_length()
edge_length_total += 1
edge_length_store[0] = edge_length_accum / edge_length_total
# done with average length
# ------------------------
walk_face_init(faces, f_act)
for f_triple in walk_face(f_act):
apply_uv(*f_triple)
bmesh.update_edit_mesh(me, loop_triangles=False)
return STATUS_OK
def main(context, operator):
scene = context.scene
use_uv_selection = True
if context.space_data and context.space_data.type == 'VIEW_3D':
use_uv_selection = False # When called from the 3D editor, UV selection is ignored.
num_meshes = 0
num_errors = 0
status = 0
ob_list = context.objects_in_mode_unique_data
for ob in ob_list:
num_meshes += 1
ret = extend(scene, ob, operator.properties.mode, use_uv_selection)
if ret != STATUS_OK:
num_errors += 1
status |= ret
if num_errors == num_meshes:
if status & STATUS_ERR_NOT_QUAD:
operator.report({'ERROR'}, "Active face must be a quad")
elif status & STATUS_ERR_NOT_SELECTED:
operator.report({'ERROR'}, "Active face not selected")
elif status & STATUS_ERR_NO_FACES_SELECTED:
operator.report({'ERROR'}, "No selected faces")
elif status & STATUS_ERR_MISSING_UV_LAYER:
operator.report({'ERROR'}, "No UV layers")
else:
assert status & STATUS_ERR_ACTIVE_FACE != 0
operator.report({'ERROR'}, "No active face")
class FollowActiveQuads(Operator):
"""Follow UVs from active quads along continuous face loops"""
bl_idname = "uv.follow_active_quads"
bl_label = "Follow Active Quads"
bl_options = {'REGISTER', 'UNDO'}
mode: EnumProperty(
name="Edge Length Mode",
description="Method to space UV edge loops",
items=(
('EVEN', "Even", "Space all UVs evenly"),
('LENGTH', "Length", "Average space UVs edge length of each loop"),
('LENGTH_AVERAGE', "Length Average", "Average space UVs edge length of each loop"),
),
default='LENGTH_AVERAGE',
)
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
def execute(self, context):
main(context, self)
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
return wm.invoke_props_dialog(self)
classes = (
FollowActiveQuads,
)

View File

@@ -0,0 +1,690 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import Operator
import mathutils
class prettyface:
__slots__ = (
"uv",
"width",
"height",
"children",
"xoff",
"yoff",
"has_parent",
"rot",
)
def __init__(self, data):
self.has_parent = False
self.rot = False # only used for triangles
self.xoff = 0
self.yoff = 0
if type(data) == list: # list of data
self.uv = None
# join the data
if len(data) == 2:
# 2 vertical blocks
data[1].xoff = data[0].width
self.width = data[0].width * 2
self.height = data[0].height
elif len(data) == 4:
# 4 blocks all the same size
d = data[0].width # dimension x/y are the same
data[1].xoff += d
data[2].yoff += d
data[3].xoff += d
data[3].yoff += d
self.width = self.height = d * 2
# else:
# print(len(data), data)
# raise "Error"
for pf in data:
pf.has_parent = True
self.children = data
elif type(data) == tuple:
# 2 blender faces
# f, (len_min, len_mid, len_max)
self.uv = data
_f1, lens1, lens1ord = data[0]
if data[1]:
_f2, lens2, lens2ord = data[1]
self.width = (lens1[lens1ord[0]] + lens2[lens2ord[0]]) / 2.0
self.height = (lens1[lens1ord[1]] + lens2[lens2ord[1]]) / 2.0
else: # 1 tri :/
self.width = lens1[0]
self.height = lens1[1]
self.children = []
else: # blender face
uv_layer = data.id_data.uv_layers.active.data
self.uv = [uv_layer[i].uv for i in data.loop_indices]
# cos = [v.co for v in data]
cos = [data.id_data.vertices[v].co for v in data.vertices] # XXX25
if len(self.uv) == 4:
self.width = ((cos[0] - cos[1]).length + (cos[2] - cos[3]).length) / 2.0
self.height = ((cos[1] - cos[2]).length + (cos[0] - cos[3]).length) / 2.0
else:
# ngon, note:
# for ngons to calculate the width/height we need to do the
# whole projection, unlike other faces
# we store normalized UVs in the faces coords to avoid
# calculating the projection and rotating it twice.
no = data.normal
r = no.rotation_difference(mathutils.Vector((0.0, 0.0, 1.0)))
cos_2d = [(r @ co).xy for co in cos]
# print(cos_2d)
angle = mathutils.geometry.box_fit_2d(cos_2d)
mat = mathutils.Matrix.Rotation(angle, 2)
cos_2d = [(mat @ co) for co in cos_2d]
xs = [co.x for co in cos_2d]
ys = [co.y for co in cos_2d]
xmin = min(xs)
ymin = min(ys)
xmax = max(xs)
ymax = max(ys)
xspan = xmax - xmin
yspan = ymax - ymin
self.width = xspan
self.height = yspan
# ngons work different, we store projected result
# in UVs to avoid having to re-project later.
if xspan < 0.0000001 or yspan < 0.0000001:
for i in range(len(cos_2d)):
self.uv[i][:] = (0.0, 0.0)
else:
for i, co in enumerate(cos_2d):
self.uv[i][:] = (
(co.x - xmin) / xspan,
(co.y - ymin) / yspan,
)
self.children = []
def spin(self):
if self.uv and len(self.uv) == 4:
self.uv = self.uv[1], self.uv[2], self.uv[3], self.uv[0]
self.width, self.height = self.height, self.width
self.xoff, self.yoff = self.yoff, self.xoff # not needed?
self.rot = not self.rot # only for tri pairs and ngons.
# print("spinning")
for pf in self.children:
pf.spin()
def place(self, xoff, yoff, xfac, yfac, margin_w, margin_h):
from math import pi
xoff += self.xoff
yoff += self.yoff
for pf in self.children:
pf.place(xoff, yoff, xfac, yfac, margin_w, margin_h)
uv = self.uv
if not uv:
return
x1 = xoff
y1 = yoff
x2 = xoff + self.width
y2 = yoff + self.height
# Scale the values
x1 = x1 / xfac + margin_w
x2 = x2 / xfac - margin_w
y1 = y1 / yfac + margin_h
y2 = y2 / yfac - margin_h
# 2 Tri pairs
if len(uv) == 2:
# match the order of angle sizes of the 3d verts with the UV angles and rotate.
def get_tri_angles(v1, v2, v3):
a1 = (v2 - v1).angle(v3 - v1, pi)
a2 = (v1 - v2).angle(v3 - v2, pi)
a3 = pi - (a1 + a2) # a3= (v2 - v3).angle(v1 - v3)
return [(a1, 0), (a2, 1), (a3, 2)]
def set_uv(f, p1, p2, p3):
# cos =
# v1 = cos[0]-cos[1]
# v2 = cos[1]-cos[2]
# v3 = cos[2]-cos[0]
# angles_co = get_tri_angles(*[v.co for v in f])
angles_co = get_tri_angles(*[f.id_data.vertices[v].co for v in f.vertices]) # XXX25
angles_co.sort()
I = [i for a, i in angles_co]
uv_layer = f.id_data.uv_layers.active.data
fuv = [uv_layer[i].uv for i in f.loop_indices]
if self.rot:
fuv[I[2]][:] = p1
fuv[I[1]][:] = p2
fuv[I[0]][:] = p3
else:
fuv[I[2]][:] = p1
fuv[I[0]][:] = p2
fuv[I[1]][:] = p3
f = uv[0][0]
set_uv(f, (x1, y1), (x1, y2 - margin_h), (x2 - margin_w, y1))
if uv[1]:
f = uv[1][0]
set_uv(f, (x2, y2), (x2, y1 + margin_h), (x1 + margin_w, y2))
else: # 1 QUAD
if len(uv) == 4:
uv[1][:] = x1, y1
uv[2][:] = x1, y2
uv[3][:] = x2, y2
uv[0][:] = x2, y1
else:
# NGon
xspan = x2 - x1
yspan = y2 - y1
for uvco in uv:
x, y = uvco
uvco[:] = (
(x1 + (x * xspan)),
(y1 + (y * yspan))
)
def __hash__(self):
# None unique hash
return self.width, self.height
def lightmap_uvpack(
meshes,
PREF_SEL_ONLY=True,
PREF_NEW_UVLAYER=False,
PREF_PACK_IN_ONE=False,
PREF_BOX_DIV=8,
PREF_MARGIN_DIV=512,
):
"""
BOX_DIV if the maximum division of the UV map that
a box may be consolidated into.
A lower value will create more clumpy boxes and more wasted space,
and a higher value will be slower but waste less space
"""
import time
from math import sqrt
if not meshes:
return
t = time.time()
if PREF_PACK_IN_ONE:
face_groups = [[]]
else:
face_groups = []
for me in meshes:
if PREF_SEL_ONLY:
faces = [f for f in me.polygons if f.select]
else:
faces = me.polygons[:]
if PREF_PACK_IN_ONE:
face_groups[0].extend(faces)
else:
face_groups.append(faces)
if PREF_NEW_UVLAYER:
me.uv_layers.new()
# Add face UV if it does not exist.
# All new faces are selected.
if not me.uv_layers:
me.uv_layers.new()
for face_sel in face_groups:
print("\nStarting unwrap")
if not face_sel:
continue
pretty_faces = [prettyface(f) for f in face_sel if f.loop_total >= 4]
# Do we have any triangles?
if len(pretty_faces) != len(face_sel):
# Now add triangles, not so simple because we need to pair them up.
def trylens(f):
# f must be a tri
# cos = [v.co for v in f]
cos = [f.id_data.vertices[v].co for v in f.vertices] # XXX25
lens = [(cos[0] - cos[1]).length, (cos[1] - cos[2]).length, (cos[2] - cos[0]).length]
lens_min = lens.index(min(lens))
lens_max = lens.index(max(lens))
for i in range(3):
if i != lens_min and i != lens_max:
lens_mid = i
break
lens_order = lens_min, lens_mid, lens_max
return f, lens, lens_order
tri_lengths = [trylens(f) for f in face_sel if f.loop_total == 3]
del trylens
# To add triangles into the light-map pack triangles are grouped in pairs to fill rectangular areas.
# In the following for each triangle we add the sorted triangle edge lengths (3d point) into a KD-Tree
# then iterate over all triangles and search for pairs of triangles by looking for the closest
# sorted triangle point.
# Additionally clusters of similar/equal triangles are parsed by searching for ranges in a second step.
kd = mathutils.kdtree.KDTree(len(tri_lengths))
for i, (f, lens, o) in enumerate(tri_lengths):
vector = (lens[o[0]], lens[o[1]], lens[o[2]])
kd.insert(vector, i)
kd.balance()
added_ids = [False] * len(tri_lengths)
pairs_added = 0
tri_equality_threshold = 0.00001 # Add multiple pairs at once that are within this threshold.
for i in range(len(tri_lengths)):
if added_ids[i]:
continue
tri1 = tri_lengths[i]
_f1, lens1, lo1 = tri1
sorted_l = (lens1[lo1[0]], lens1[lo1[1]], lens1[lo1[2]])
added_ids[i] = True
_vec, nearest, dist = kd.find(sorted_l, filter=lambda idx: not added_ids[idx])
if not nearest or nearest < 0:
pretty_faces.append(prettyface((tri1, None)))
break
tri2 = tri_lengths[nearest]
pretty_faces.append(prettyface((tri1, tri2)))
pairs_added = pairs_added + 1
added_ids[nearest] = True
# Look in threshold proximity to add all similar/equal triangles in one go.
# This code is not necessary but acts as a shortcut (~9% performance improvement).
if dist < tri_equality_threshold:
cluster_tri_ids = [
idx for _, idx, _ in kd.find_range(sorted_l, tri_equality_threshold)
if not added_ids[idx]
]
if len(cluster_tri_ids) > 1:
for ci in range(0, len(cluster_tri_ids) - (len(cluster_tri_ids) % 2), 2):
pretty_faces.append(
prettyface((tri_lengths[cluster_tri_ids[ci]], tri_lengths[cluster_tri_ids[ci + 1]]))
)
added_ids[cluster_tri_ids[ci]] = added_ids[cluster_tri_ids[ci + 1]] = True
pairs_added = pairs_added + 1
# Get the min, max and total areas
max_area = 0.0
min_area = 100000000.0
tot_area = 0
for f in face_sel:
area = f.area
if area > max_area:
max_area = area
if area < min_area:
min_area = area
tot_area += area
max_len = sqrt(max_area)
min_len = sqrt(min_area)
side_len = sqrt(tot_area)
# Build widths
curr_len = max_len
print("\tGenerating lengths...", end="")
lengths = []
while curr_len > min_len:
lengths.append(curr_len)
curr_len = curr_len / 2.0
# Don't allow boxes smaller then the margin
# since we contract on the margin, boxes that are smaller will create errors
# print(curr_len, side_len/MARGIN_DIV)
if curr_len / 4.0 < side_len / PREF_MARGIN_DIV:
break
if not lengths:
lengths.append(curr_len)
# convert into ints
lengths_to_ints = {}
l_int = 1
for l in reversed(lengths):
lengths_to_ints[l] = l_int
l_int *= 2
lengths_to_ints = list(lengths_to_ints.items())
lengths_to_ints.sort()
print("done")
# apply quantized values.
for pf in pretty_faces:
w = pf.width
h = pf.height
bestw_diff = 1000000000.0
besth_diff = 1000000000.0
new_w = 0.0
new_h = 0.0
for l, i in lengths_to_ints:
d = abs(l - w)
if d < bestw_diff:
bestw_diff = d
new_w = i # assign the int version
d = abs(l - h)
if d < besth_diff:
besth_diff = d
new_h = i # ditto
pf.width = new_w
pf.height = new_h
if new_w > new_h:
pf.spin()
print("...done")
# Since the boxes are sized in powers of 2, we can neatly group them into bigger squares
# this is done hierarchically, so that we may avoid running the pack function
# on many thousands of boxes, (under 1k is best) because it would get slow.
# Using an odd and even dict is useful because they are packed differently
# where w/h are the same, their packed in groups of 4
# where they are different they are packed in pairs
#
# After this is done an external pack func is done that packs the whole group.
print("\tConsolidating Boxes...", end="")
even_dict = {} # w/h are the same, the key is an int (w)
odd_dict = {} # w/h are different, the key is the (w,h)
for pf in pretty_faces:
w, h = pf.width, pf.height
if w == h:
even_dict.setdefault(w, []).append(pf)
else:
odd_dict.setdefault((w, h), []).append(pf)
# Count the number of boxes consolidated, only used for stats.
c = 0
# This is tricky. the total area of all packed boxes, then sqrt() that to get an estimated size
# this is used then converted into out INT space so we can compare it with
# the ints assigned to the boxes size
# and divided by BOX_DIV, basically if BOX_DIV is 8
# ...then the maximum box consolidation (recursive grouping) will have a max width & height
# ...1/8th of the UV size.
# ...limiting this is needed or you end up with bug unused texture spaces
# ...however if its too high, box-packing is way too slow for high poly meshes.
float_to_int_factor = lengths_to_ints[0][0]
if float_to_int_factor > 0:
max_int_dimension = int(((side_len / float_to_int_factor)) / PREF_BOX_DIV)
ok = True
else:
max_int_dimension = 0.0 # won't be used
ok = False
# RECURSIVE pretty face grouping
while ok:
ok = False
# Tall boxes in groups of 2
for d, boxes in list(odd_dict.items()):
if d[1] < max_int_dimension:
# boxes.sort(key=lambda a: len(a.children))
while len(boxes) >= 2:
# print("foo", len(boxes))
ok = True
c += 1
pf_parent = prettyface([boxes.pop(), boxes.pop()])
pretty_faces.append(pf_parent)
w, h = pf_parent.width, pf_parent.height
assert w <= h
if w == h:
even_dict.setdefault(w, []).append(pf_parent)
else:
odd_dict.setdefault((w, h), []).append(pf_parent)
# Even boxes in groups of 4
for d, boxes in list(even_dict.items()):
if d < max_int_dimension:
boxes.sort(key=lambda a: len(a.children))
while len(boxes) >= 4:
# print("bar", len(boxes))
ok = True
c += 1
pf_parent = prettyface([boxes.pop(), boxes.pop(), boxes.pop(), boxes.pop()])
pretty_faces.append(pf_parent)
w = pf_parent.width # width and weight are the same
even_dict.setdefault(w, []).append(pf_parent)
del even_dict
del odd_dict
# orig = len(pretty_faces)
pretty_faces = [pf for pf in pretty_faces if not pf.has_parent]
# spin every second pretty-face
# if there all vertical you get less efficiently used texture space
i = len(pretty_faces)
d = 0
while i:
i -= 1
pf = pretty_faces[i]
if pf.width != pf.height:
d += 1
if d % 2: # only pack every second
pf.spin()
# pass
print("Consolidated", c, "boxes, done")
# print("done", orig, len(pretty_faces))
# boxes2Pack.append([islandIdx, w,h])
print("\tPacking Boxes", len(pretty_faces), end="...")
boxes2Pack = [[0.0, 0.0, pf.width, pf.height, i] for i, pf in enumerate(pretty_faces)]
packWidth, packHeight = mathutils.geometry.box_pack_2d(boxes2Pack)
# print(packWidth, packHeight)
packWidth = float(packWidth)
packHeight = float(packHeight)
margin_w = ((packWidth) / PREF_MARGIN_DIV) / packWidth
margin_h = ((packHeight) / PREF_MARGIN_DIV) / packHeight
# print(margin_w, margin_h)
print("done")
# Apply the boxes back to the UV coords.
print("\twriting back UVs", end="")
for i, box in enumerate(boxes2Pack):
pretty_faces[i].place(box[0], box[1], packWidth, packHeight, margin_w, margin_h)
# pf.place(box[1][1], box[1][2], packWidth, packHeight, margin_w, margin_h)
print("done")
for me in meshes:
me.update()
print("finished all {:.2f} ".format(time.time() - t))
def unwrap(operator, context, **kwargs):
# switch to object mode
is_editmode = context.object and context.object.mode == 'EDIT'
if is_editmode:
objects = context.objects_in_mode_unique_data
bpy.ops.object.mode_set(mode='OBJECT', toggle=False)
else:
objects = context.selected_objects
# define list of meshes
meshes = list({
me for obj in objects
if obj.type == 'MESH'
if (me := obj.data).polygons and me.is_editable
})
if not meshes:
operator.report({'ERROR'}, "No mesh object")
return {'CANCELLED'}
lightmap_uvpack(meshes, **kwargs)
# switch back to edit mode
if is_editmode:
bpy.ops.object.mode_set(mode='EDIT', toggle=False)
return {'FINISHED'}
from bpy.props import BoolProperty, FloatProperty, IntProperty
class LightMapPack(Operator):
"""Pack each face's UVs into the UV bounds"""
bl_idname = "uv.lightmap_pack"
bl_label = "Lightmap Pack"
# Disable REGISTER flag for now because this operator might create new
# images. This leads to non-proper operator redo because current undo
# stack is local for edit mode and can not remove images created by this
# operator.
# Proper solution would be to make undo stack aware of such things,
# but for now just disable redo. Keep undo here so unwanted changes to uv
# coords might be undone.
# NOTE(@sergey): This fixes infinite image creation reported there #30968.
bl_options = {'UNDO'}
PREF_CONTEXT: bpy.props.EnumProperty(
name="Selection",
items=(
('SEL_FACES', "Selected Faces", "Pack only selected faces"),
('ALL_FACES', "All Faces", "Pack all faces in the mesh"),
),
)
# Image & UVs...
PREF_PACK_IN_ONE: BoolProperty(
name="Share Texture Space",
description=(
"Objects share texture space, map all objects "
"into a single UV map"
),
default=True,
)
PREF_NEW_UVLAYER: BoolProperty(
name="New UV Map",
description="Create a new UV map for every mesh packed",
default=False,
)
# UV Packing...
PREF_BOX_DIV: IntProperty(
name="Pack Quality",
description=(
"Quality of the packing. "
"Higher values will be slower but waste less space"
),
min=1, max=48,
default=12,
)
PREF_MARGIN_DIV: FloatProperty(
name="Margin",
description="Size of the margin as a division of the UV",
min=0.001, max=1.0,
default=0.1,
)
def draw(self, context):
layout = self.layout
layout.use_property_split = True
layout.use_property_decorate = False
is_editmode = context.active_object.mode == 'EDIT'
if is_editmode:
layout.prop(self, "PREF_CONTEXT")
layout.prop(self, "PREF_PACK_IN_ONE")
layout.prop(self, "PREF_NEW_UVLAYER")
layout.prop(self, "PREF_BOX_DIV")
layout.prop(self, "PREF_MARGIN_DIV")
@classmethod
def poll(cls, context):
ob = context.active_object
return ob and ob.type == 'MESH'
def execute(self, context):
kwargs = self.as_keywords()
PREF_CONTEXT = kwargs.pop("PREF_CONTEXT")
is_editmode = context.active_object.mode == 'EDIT'
if not is_editmode:
kwargs["PREF_SEL_ONLY"] = False
elif PREF_CONTEXT == 'SEL_FACES':
kwargs["PREF_SEL_ONLY"] = True
elif PREF_CONTEXT == 'ALL_FACES':
kwargs["PREF_SEL_ONLY"] = False
else:
raise Exception("invalid context")
kwargs["PREF_MARGIN_DIV"] = int(1.0 / (kwargs["PREF_MARGIN_DIV"] / 100.0))
return unwrap(self, context, **kwargs)
def invoke(self, context, _event):
wm = context.window_manager
return wm.invoke_props_dialog(self)
classes = (
LightMapPack,
)

View File

@@ -0,0 +1,551 @@
# SPDX-FileCopyrightText: 2022-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
__all__ = (
"classes",
# While "internal" (not for user scripts) it's used by `uvcalc_follow_active`.
"is_face_uv_selected_fn_from_context",
)
import math
from bpy.types import Operator
from mathutils import Matrix, Vector
from bpy.props import (
BoolProperty,
EnumProperty,
FloatProperty,
FloatVectorProperty,
IntProperty,
)
# ------------------------------------------------------------------------------
# Local Utility Functions
# `sync_valid` functions.
def is_face_uv_selected_for_uv_select_sync_valid(face, any_edge):
if face.hide:
return False
if face.uv_select:
return True
if any_edge:
for loop in face.loops:
if loop.uv_select_edge:
return True
return False
def is_loop_edge_uv_selected_for_uv_select_sync_valid(loop):
if loop.face.hide:
return False
if loop.uv_select_edge:
return True
return False
# `sync_invalid` functions.
def is_face_uv_selected_for_uv_select_sync_invalid(face, any_edge):
if face.hide:
return False
if face.select:
return True
if any_edge:
for loop in face.loops:
if loop.edge.select:
return True
return False
def is_loop_edge_uv_selected_for_uv_select_sync_invalid(loop):
if loop.face.hide:
return False
if loop.edge.select:
return True
return False
# `no_sync` functions.
def is_face_uv_selected_for_uv_select_no_sync(face, any_edge):
if face.hide:
return False
if face.select:
if face.uv_select:
return True
if any_edge:
for loop in face.loops:
if loop.uv_select_edge:
return True
return False
def is_loop_edge_uv_selected_for_uv_select_no_sync(loop):
if loop.face.hide:
return False
if loop.face.select:
if loop.uv_select_edge:
return True
return False
def is_face_uv_selected_fn_from_context(scene, bm):
if scene.tool_settings.use_uv_select_sync:
if bm.uv_select_sync_valid:
return is_face_uv_selected_for_uv_select_sync_valid
return is_face_uv_selected_for_uv_select_sync_invalid
return is_face_uv_selected_for_uv_select_no_sync
def is_loop_edge_uv_selected_fn_from_context(scene, bm):
if scene.tool_settings.use_uv_select_sync:
if bm.uv_select_sync_valid:
return is_loop_edge_uv_selected_for_uv_select_sync_valid
return is_loop_edge_uv_selected_for_uv_select_sync_invalid
return is_loop_edge_uv_selected_for_uv_select_no_sync
def is_island_uv_selected(island, any_edge, face_select_test_fn):
# Returns True if the island is UV selected.
#
# :param island: list of faces to query.
# :type island: Sequence[:class:`BMFace`]
# :param any_edge: use edge selection instead of vertex selection.
# :type any_edge: bool
# :return: list of lists containing polygon indices.
# :rtype: bool
for face in island:
if face_select_test_fn(face, any_edge):
return True
return False
def island_uv_bounds(island, uv_layer):
# The UV bounds of UV island.
#
# :param island: list of faces to query.
# :type island: Sequence[:class:`BMFace`]
# :param uv_layer: the UV layer to source UVs from.
# :return: U-min, V-min, U-max, V-max.
# :rtype: list[float]
minmax = [1e30, 1e30, -1e30, -1e30]
for face in island:
for loop in face.loops:
u, v = loop[uv_layer].uv
minmax[0] = min(minmax[0], u)
minmax[1] = min(minmax[1], v)
minmax[2] = max(minmax[2], u)
minmax[3] = max(minmax[3], v)
return minmax
def island_uv_bounds_center(island, uv_layer):
# The UV bounds center of UV island.
#
# :param island: list of faces to query.
# :type island: Sequence[:class:`BMFace`]
# :param uv_layer: the UV layer to source UVs from.
# :return: U, V center.
# :rtype: tuple[float, float]
minmax = island_uv_bounds(island, uv_layer)
return (
(minmax[0] + minmax[2]) / 2.0,
(minmax[1] + minmax[3]) / 2.0,
)
# ------------------------------------------------------------------------------
# Align UV Rotation Operator
def find_rotation_auto(bm, uv_layer, faces, aspect_y):
del bm
sum_u = 0.0
sum_v = 0.0
for face in faces:
prev_uv = face.loops[-1][uv_layer].uv
for loop in face.loops:
uv = loop[uv_layer].uv
du = uv[0] - prev_uv[0]
dv = uv[1] - prev_uv[1]
edge_angle = math.atan2(dv, du * aspect_y)
edge_angle *= 4.0 # Wrap 4 times around the circle
sum_u += math.cos(edge_angle)
sum_v += math.sin(edge_angle)
prev_uv = uv
# Compute angle.
return -math.atan2(sum_v, sum_u) / 4.0
def find_rotation_edge(bm, uv_layer, faces, aspect_y, loop_edge_select_test_fn):
del bm
sum_u = 0.0
sum_v = 0.0
for face in faces:
prev_uv = face.loops[-1][uv_layer].uv
prev_select = loop_edge_select_test_fn(face.loops[-1])
for loop in face.loops:
uv = loop[uv_layer].uv
if prev_select:
du = uv[0] - prev_uv[0]
dv = uv[1] - prev_uv[1]
edge_angle = math.atan2(dv, du * aspect_y)
edge_angle *= 2.0 # Wrap 2 times around the circle
sum_u += math.cos(edge_angle)
sum_v += math.sin(edge_angle)
prev_uv = uv
prev_select = loop_edge_select_test_fn(loop)
# Add 90 degrees to align along V coordinate.
# Twice, because we divide by two.
sum_u, sum_v = -sum_u, -sum_v
# Compute angle.
return -math.atan2(sum_v, sum_u) / 2.0
def find_rotation_geometry(bm, uv_layer, faces, axis, aspect_y):
del bm
sum_u_co = Vector((0.0, 0.0, 0.0))
sum_v_co = Vector((0.0, 0.0, 0.0))
for face in faces:
# Triangulate.
for fan in range(2, len(face.loops)):
delta_uv0 = face.loops[fan - 1][uv_layer].uv - face.loops[0][uv_layer].uv
delta_uv1 = face.loops[fan][uv_layer].uv - face.loops[0][uv_layer].uv
delta_uv0[0] *= aspect_y
delta_uv1[0] *= aspect_y
mat = Matrix((delta_uv0, delta_uv1))
mat.invert_safe()
delta_co0 = face.loops[fan - 1].vert.co - face.loops[0].vert.co
delta_co1 = face.loops[fan].vert.co - face.loops[0].vert.co
w = delta_co0.cross(delta_co1).length
# U direction in geometry coordinates.
sum_u_co += (delta_co0 * mat[0][0] + delta_co1 * mat[0][1]) * w
# V direction in geometry coordinates.
sum_v_co += (delta_co0 * mat[1][0] + delta_co1 * mat[1][1]) * w
if axis == 'X':
axis_index = 0
elif axis == 'Y':
axis_index = 1
elif axis == 'Z':
axis_index = 2
# Compute angle.
return math.atan2(sum_u_co[axis_index], sum_v_co[axis_index])
def align_uv_rotation_island(bm, uv_layer, faces, method, axis, aspect_y, loop_edge_select_test_fn):
angle = 0.0
if method == 'AUTO':
angle = find_rotation_auto(bm, uv_layer, faces, aspect_y)
elif method == 'EDGE':
angle = find_rotation_edge(bm, uv_layer, faces, aspect_y, loop_edge_select_test_fn)
elif method == 'GEOMETRY':
angle = find_rotation_geometry(bm, uv_layer, faces, axis, aspect_y)
if angle == 0.0:
return False # No change.
# Find bounding box center.
mid_u, mid_v = island_uv_bounds_center(faces, uv_layer)
cos_angle = math.cos(angle)
sin_angle = math.sin(angle)
delta_u = mid_u - cos_angle * mid_u + sin_angle / aspect_y * mid_v
delta_v = mid_v - sin_angle * aspect_y * mid_u - cos_angle * mid_v
# Apply transform.
for face in faces:
for loop in face.loops:
pre_uv = loop[uv_layer].uv
u = cos_angle * pre_uv[0] - sin_angle / aspect_y * pre_uv[1] + delta_u
v = sin_angle * aspect_y * pre_uv[0] + cos_angle * pre_uv[1] + delta_v
loop[uv_layer].uv = u, v
return True
def align_uv_rotation_bmesh(bm, method, axis, aspect_y, loop_edge_select_test_fn, face_select_test_fn):
import bpy_extras.bmesh_utils
uv_layer = bm.loops.layers.uv.active
if not uv_layer:
return False
islands = bpy_extras.bmesh_utils.bmesh_linked_uv_islands(bm, uv_layer)
changed = False
for island in islands:
if is_island_uv_selected(island, method == 'EDGE', face_select_test_fn):
if align_uv_rotation_island(bm, uv_layer, island, method, axis, aspect_y, loop_edge_select_test_fn):
changed = True
return changed
def get_aspect_y(context):
area = context.area
if not area:
return 1.0
space_data = context.area.spaces.active
if not space_data:
return 1.0
if not space_data.image:
return 1.0
image_width = space_data.image.size[0]
image_height = space_data.image.size[1]
if image_height:
return image_width / image_height
return 1.0
def align_uv_rotation(context, method, axis, correct_aspect):
import bmesh
scene = context.scene
aspect_y = 1.0
if correct_aspect:
aspect_y = get_aspect_y(context)
ob_list = context.objects_in_mode_unique_data
for ob in ob_list:
bm = bmesh.from_edit_mesh(ob.data)
if not bm.loops.layers.uv:
continue
loop_edge_select_test_fn = is_loop_edge_uv_selected_fn_from_context(scene, bm)
face_select_test_fn = is_face_uv_selected_fn_from_context(scene, bm)
if align_uv_rotation_bmesh(bm, method, axis, aspect_y, loop_edge_select_test_fn, face_select_test_fn):
bmesh.update_edit_mesh(ob.data)
return {'FINISHED'}
class AlignUVRotation(Operator):
"""Align the UV island's rotation"""
bl_idname = "uv.align_rotation"
bl_label = "Align Rotation"
bl_options = {'REGISTER', 'UNDO'}
method: EnumProperty(
name="Method", description="Method to calculate rotation angle",
items=(
('AUTO', "Auto", "Align from all edges"),
('EDGE', "Edge", "Only selected edges"),
('GEOMETRY', "Geometry", "Align to Geometry axis"),
),
)
axis: EnumProperty(
name="Axis", description="Axis to align to",
items=(
('X', "X", "X axis"),
('Y', "Y", "Y axis"),
('Z', "Z", "Z axis"),
),
)
correct_aspect: BoolProperty(
name="Correct Aspect",
description="Take image aspect ratio into account",
default=False,
)
def execute(self, context):
return align_uv_rotation(context, self.method, self.axis, self.correct_aspect)
def draw(self, _context):
layout = self.layout
layout.prop(self, "method")
if self.method == 'GEOMETRY':
layout.prop(self, "axis")
layout.prop(self, "correct_aspect")
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
# ------------------------------------------------------------------------------
# Randomize UV Operator
def get_random_transform(transform_params, entropy):
from random import uniform
from random import seed as random_seed
(seed, loc, rot, scale, scale_even) = transform_params
# First, seed the RNG.
random_seed(seed + entropy)
# Next, call uniform a known number of times.
offset_u = uniform(0.0, 1.0)
offset_v = uniform(0.0, 1.0)
angle = uniform(0.0, 1.0)
scale_u = uniform(0.0, 1.0)
scale_v = uniform(0.0, 1.0)
# Apply the transform_params.
if loc:
offset_u *= loc[0]
offset_v *= loc[1]
else:
offset_u = 0.0
offset_v = 0.0
if rot:
angle *= rot
else:
angle = 0.0
if scale:
scale_u = scale_u * (2.0 * scale[0] - 2.0) + 2.0 - scale[0]
scale_v = scale_v * (2.0 * scale[1] - 2.0) + 2.0 - scale[1]
else:
scale_u = 1.0
scale_v = 1.0
if scale_even:
scale_v = scale_u
# Results in homogeneous coordinates.
return [[scale_u * math.cos(angle), -scale_v * math.sin(angle), offset_u],
[scale_u * math.sin(angle), scale_v * math.cos(angle), offset_v]]
def randomize_uv_transform_island(uv_layer, faces, transform_params):
# Ensure consistent random values for island, regardless of selection etc.
entropy = min(f.index for f in faces)
transform = get_random_transform(transform_params, entropy)
# Find bounding box center.
mid_u, mid_v = island_uv_bounds_center(faces, uv_layer)
del_u = transform[0][2] + mid_u - transform[0][0] * mid_u - transform[0][1] * mid_v
del_v = transform[1][2] + mid_v - transform[1][0] * mid_u - transform[1][1] * mid_v
# Apply transform.
for face in faces:
for loop in face.loops:
pre_uv = loop[uv_layer].uv
u = transform[0][0] * pre_uv[0] + transform[0][1] * pre_uv[1] + del_u
v = transform[1][0] * pre_uv[0] + transform[1][1] * pre_uv[1] + del_v
loop[uv_layer].uv = (u, v)
def randomize_uv_transform_bmesh(bm, transform_params, face_select_test_fn):
import bpy_extras.bmesh_utils
uv_layer = bm.loops.layers.uv.verify()
islands = bpy_extras.bmesh_utils.bmesh_linked_uv_islands(bm, uv_layer)
for island in islands:
if is_island_uv_selected(island, False, face_select_test_fn):
randomize_uv_transform_island(uv_layer, island, transform_params)
def randomize_uv_transform(context, transform_params):
import bmesh
scene = context.scene
ob_list = context.objects_in_mode_unique_data
for ob in ob_list:
bm = bmesh.from_edit_mesh(ob.data)
if not bm.loops.layers.uv:
continue
# Only needed to access the minimum face index of each island.
bm.faces.index_update()
face_select_test_fn = is_face_uv_selected_fn_from_context(scene, bm)
randomize_uv_transform_bmesh(bm, transform_params, face_select_test_fn)
for ob in ob_list:
bmesh.update_edit_mesh(ob.data)
return {'FINISHED'}
class RandomizeUVTransform(Operator):
"""Randomize the UV island's location, rotation, and scale"""
bl_idname = "uv.randomize_uv_transform"
bl_label = "Randomize"
bl_options = {'REGISTER', 'UNDO'}
random_seed: IntProperty(
name="Random Seed",
description="Seed value for the random generator",
min=0,
max=10000,
default=0,
)
use_loc: BoolProperty(
name="Randomize Location",
description="Randomize the location values",
default=True,
)
loc: FloatVectorProperty(
name="Location",
description="Maximum distance the objects can spread over each axis",
min=-100.0,
max=100.0,
size=2,
subtype='TRANSLATION',
default=(0.0, 0.0),
)
use_rot: BoolProperty(
name="Randomize Rotation",
description="Randomize the rotation value",
default=True,
)
rot: FloatProperty(
name="Rotation",
description="Maximum rotation",
min=-2.0 * math.pi,
max=2.0 * math.pi,
subtype='ANGLE',
default=0.0,
)
use_scale: BoolProperty(
name="Randomize Scale",
description="Randomize the scale values",
default=True,
)
scale_even: BoolProperty(
name="Scale Even",
description="Use the same scale value for both axes",
default=False,
)
scale: FloatVectorProperty(
name="Scale",
description="Maximum scale randomization over each axis",
min=-100.0,
max=100.0,
default=(1.0, 1.0),
size=2,
)
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
def execute(self, context):
seed = self.random_seed
loc = [0.0, 0.0] if not self.use_loc else self.loc
rot = 0.0 if not self.use_rot else self.rot
scale = None if not self.use_scale else self.scale
scale_even = self.scale_even
transform_params = [seed, loc, rot, scale, scale_even]
return randomize_uv_transform(context, transform_params)
classes = (
AlignUVRotation,
RandomizeUVTransform,
)

View File

@@ -0,0 +1,201 @@
# SPDX-FileCopyrightText: 2009 Campbell Barton
#
# SPDX-License-Identifier: GPL-2.0-or-later
def ensure_active_color_attribute(me):
if me.attributes.active_color:
return me.attributes.active_color
return me.color_attributes.new("Color", 'BYTE_COLOR', 'CORNER')
def applyVertexDirt(me, blur_iterations, blur_strength, clamp_dirt, clamp_clean, dirt_only, normalize):
from mathutils import Vector
from math import acos
import array
# We simulate the accumulation of dirt in the creases of geometric surfaces
# by comparing the vertex normal to the average direction of all vertices
# connected to that vertex. We can also simulate surfaces being buffed or
# worn by testing protruding surfaces.
#
# So if the angle between the normal and geometric direction is:
# < 90 - dirt has accumulated in the crease
# > 90 - surface has been worn or buffed
# ~ 90 - surface is flat and is generally unworn and clean
#
# This method is limited by the complexity or lack there of in the geometry.
#
# Original code and method by Keith "Wahooney" Boshoff.
vert_tone = array.array("f", [0.0]) * len(me.vertices)
# create lookup table for each vertex's connected vertices (via edges)
con = [[] for i in range(len(me.vertices))]
# add connected verts
for e in me.edges:
con[e.vertices[0]].append(e.vertices[1])
con[e.vertices[1]].append(e.vertices[0])
for i, v in enumerate(me.vertices):
vec = Vector()
no = v.normal
co = v.co
# get the direction of the vectors between the vertex and it's connected vertices
for c in con[i]:
vec += (me.vertices[c].co - co).normalized()
# average the vector by dividing by the number of connected verts
tot_con = len(con[i])
if tot_con == 0:
ang = pi / 2.0 # assume 90°, i. e. flat
else:
vec /= tot_con
# angle is the acos() of the dot product between normal and connected verts.
# > 90 degrees: convex
# < 90 degrees: concave
ang = acos(no.dot(vec))
# enforce min/max
ang = max(clamp_dirt, ang)
if not dirt_only:
ang = min(clamp_clean, ang)
vert_tone[i] = ang
# blur tones
for i in range(blur_iterations):
# backup the original tones
orig_vert_tone = vert_tone[:]
# use connected verts look up for blurring
for j, c in enumerate(con):
for v in c:
vert_tone[j] += blur_strength * orig_vert_tone[v]
vert_tone[j] /= len(c) * blur_strength + 1
del orig_vert_tone
if normalize:
min_tone = min(vert_tone)
max_tone = max(vert_tone)
else:
min_tone = clamp_dirt
max_tone = clamp_clean
tone_range = max_tone - min_tone
if tone_range < 0.0001:
# weak, don't cancel, see #43345
tone_range = 0.0
else:
tone_range = 1.0 / tone_range
active_color_attribute = ensure_active_color_attribute(me)
if not active_color_attribute:
return {'CANCELLED'}
point_domain = active_color_attribute.domain == 'POINT'
attribute_data = active_color_attribute.data
use_paint_mask = me.use_paint_mask
for i, p in enumerate(me.polygons):
if p.hide:
continue
if use_paint_mask and not p.select:
continue
for loop_index in p.loop_indices:
loop = me.loops[loop_index]
v = loop.vertex_index
col = attribute_data[v if point_domain else loop_index].color
tone = vert_tone[v]
tone = (tone - min_tone) * tone_range
if dirt_only:
tone = min(tone, 0.5) * 2.0
col[0] = tone * col[0]
col[1] = tone * col[1]
col[2] = tone * col[2]
me.update()
return {'FINISHED'}
from bpy.types import Operator
from bpy.props import FloatProperty, IntProperty, BoolProperty
from math import pi
class VertexPaintDirt(Operator):
"""Generate a dirt map gradient based on cavity"""
bl_idname = "paint.vertex_color_dirt"
bl_label = "Dirty Vertex Colors"
bl_options = {'REGISTER', 'UNDO'}
blur_strength: FloatProperty(
name="Blur Strength",
description="Blur strength per iteration",
min=0.01, max=1.0,
default=1.0,
)
blur_iterations: IntProperty(
name="Blur Iterations",
description="Number of times to blur the colors (higher blurs more)",
min=0, max=40,
default=1,
)
clean_angle: FloatProperty(
name="Highlight Angle",
description="Less than 90 limits the angle used in the tonal range",
min=0.0, max=pi,
default=pi,
unit='ROTATION',
)
dirt_angle: FloatProperty(
name="Dirt Angle",
description="Less than 90 limits the angle used in the tonal range",
min=0.0, max=pi,
default=0.0,
unit='ROTATION',
)
dirt_only: BoolProperty(
name="Dirt Only",
description="Don't calculate cleans for convex areas",
default=False,
)
normalize: BoolProperty(
name="Normalize",
description="Normalize the colors, increasing the contrast",
default=True,
)
@classmethod
def poll(cls, context):
obj = context.object
return (obj and obj.type == 'MESH')
def execute(self, context):
obj = context.object
mesh = obj.data
ret = applyVertexDirt(
mesh,
self.blur_iterations,
self.blur_strength,
self.dirt_angle,
self.clean_angle,
self.dirt_only,
self.normalize,
)
return ret
classes = (
VertexPaintDirt,
)

View File

@@ -0,0 +1,316 @@
# SPDX-FileCopyrightText: 2011-2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
from bpy.types import (
Operator,
FileHandler,
)
from bpy.props import (
BoolProperty,
EnumProperty,
)
bl_file_extensions_image_and_movie = ";".join((
*bpy.path.extensions_image,
*bpy.path.extensions_movie,
))
class VIEW3D_OT_edit_mesh_extrude_individual_move(Operator):
"""Extrude each individual face separately along local normals"""
bl_label = "Extrude Individual and Move"
bl_idname = "view3d.edit_mesh_extrude_individual_move"
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
def execute(self, context):
from bpy_extras.object_utils import object_report_if_active_shape_key_is_locked
ob = context.object
if object_report_if_active_shape_key_is_locked(ob, self):
return {'CANCELLED'}
mesh = ob.data
select_mode = context.tool_settings.mesh_select_mode
totface = mesh.total_face_sel
totedge = mesh.total_edge_sel
# totvert = mesh.total_vert_sel
if select_mode[2] and totface == 1:
bpy.ops.mesh.extrude_region_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_translate={
"orient_type": 'NORMAL',
"constraint_axis": (False, False, True),
"release_confirm": False,
},
)
elif select_mode[2] and totface > 1:
bpy.ops.mesh.extrude_faces_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_shrink_fatten={
"release_confirm": False,
},
)
elif select_mode[1] and totedge >= 1:
bpy.ops.mesh.extrude_edges_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_translate={
"release_confirm": False,
},
)
else:
bpy.ops.mesh.extrude_vertices_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_translate={
"release_confirm": False,
},
)
# ignore return from operators above because they are 'RUNNING_MODAL',
# and cause this one not to be freed. #24671.
return {'FINISHED'}
def invoke(self, context, _event):
return self.execute(context)
class VIEW3D_OT_edit_mesh_extrude_move(Operator):
"""Extrude region together along the average normal"""
bl_label = "Extrude and Move on Normals"
bl_idname = "view3d.edit_mesh_extrude_move_normal"
dissolve_and_intersect: BoolProperty(
name="Dissolve and Intersect",
default=False,
description="Dissolves adjacent faces and intersects new geometry",
)
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
@staticmethod
def extrude_region(operator, context, use_vert_normals, dissolve_and_intersect):
from bpy_extras.object_utils import object_report_if_active_shape_key_is_locked
ob = context.object
if object_report_if_active_shape_key_is_locked(ob, operator):
return {'CANCELLED'}
mesh = ob.data
totface = mesh.total_face_sel
totedge = mesh.total_edge_sel
# totvert = mesh.total_vert_sel
if totface >= 1:
if use_vert_normals:
bpy.ops.mesh.extrude_region_shrink_fatten(
'INVOKE_REGION_WIN',
TRANSFORM_OT_shrink_fatten={
"release_confirm": False,
},
)
elif dissolve_and_intersect:
bpy.ops.mesh.extrude_manifold(
'INVOKE_REGION_WIN',
MESH_OT_extrude_region={
"use_dissolve_ortho_edges": True,
},
TRANSFORM_OT_translate={
"orient_type": 'NORMAL',
"constraint_axis": (False, False, True),
"release_confirm": False,
},
)
else:
bpy.ops.mesh.extrude_region_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_translate={
"orient_type": 'NORMAL',
"constraint_axis": (False, False, True),
"release_confirm": False,
},
)
elif totedge == 1:
bpy.ops.mesh.extrude_region_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_translate={
# Don't set the constraint axis since users will expect MMB
# to use the user setting, see: #61637
# "orient_type": 'NORMAL',
# Not a popular choice, too restrictive for retopology.
# "constraint_axis": (True, True, False),
"constraint_axis": (False, False, False),
"release_confirm": False,
})
else:
bpy.ops.mesh.extrude_region_move(
'INVOKE_REGION_WIN',
TRANSFORM_OT_translate={
"release_confirm": False,
},
)
# ignore return from operators above because they are 'RUNNING_MODAL',
# and cause this one not to be freed. #24671.
return {'FINISHED'}
def execute(self, context):
return VIEW3D_OT_edit_mesh_extrude_move.extrude_region(self, context, False, self.dissolve_and_intersect)
def invoke(self, context, _event):
return self.execute(context)
class VIEW3D_OT_edit_mesh_extrude_shrink_fatten(Operator):
"""Extrude region together along local normals"""
bl_label = "Extrude and Move on Individual Normals"
bl_idname = "view3d.edit_mesh_extrude_move_shrink_fatten"
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
def execute(self, context):
return VIEW3D_OT_edit_mesh_extrude_move.extrude_region(self, context, True, False)
def invoke(self, context, _event):
return self.execute(context)
class VIEW3D_OT_edit_mesh_extrude_manifold_normal(Operator):
"""Extrude manifold region along normals"""
bl_label = "Extrude Manifold Along Normals"
bl_idname = "view3d.edit_mesh_extrude_manifold_normal"
@classmethod
def poll(cls, context):
return context.mode == 'EDIT_MESH'
def execute(self, context):
from bpy_extras.object_utils import object_report_if_active_shape_key_is_locked
if object_report_if_active_shape_key_is_locked(context.object, self):
return {'CANCELLED'}
bpy.ops.mesh.extrude_manifold(
'INVOKE_REGION_WIN',
MESH_OT_extrude_region={
"use_dissolve_ortho_edges": True,
},
TRANSFORM_OT_translate={
"orient_type": 'NORMAL',
"constraint_axis": (False, False, True),
"release_confirm": False,
},
)
return {'FINISHED'}
def invoke(self, context, _event):
return self.execute(context)
class VIEW3D_OT_transform_gizmo_set(Operator):
"""Set the current transform gizmo"""
bl_label = "Transform Gizmo Set"
bl_options = {'REGISTER', 'UNDO'}
bl_idname = "view3d.transform_gizmo_set"
extend: BoolProperty(
name="Extend",
default=False,
)
type: EnumProperty(
name="Type",
items=(
('TRANSLATE', "Move", ""),
('ROTATE', "Rotate", ""),
('SCALE', "Scale", ""),
),
options={'ENUM_FLAG'},
)
@classmethod
def poll(cls, context):
area = context.area
return area and (area.type == 'VIEW_3D')
def execute(self, context):
space_data = context.space_data
space_data.show_gizmo = True
attrs = ("show_gizmo_object_translate", "show_gizmo_object_rotate", "show_gizmo_object_scale")
attr_active = tuple(
attrs[('TRANSLATE', 'ROTATE', 'SCALE').index(t)]
for t in self.type
)
if self.extend:
for attr in attrs:
if attr in attr_active:
setattr(space_data, attr, True)
else:
for attr in attrs:
setattr(space_data, attr, attr in attr_active)
return {'FINISHED'}
def invoke(self, context, event):
if not self.properties.is_property_set("extend"):
self.extend = event.shift
return self.execute(context)
class VIEW3D_FH_empty_image(FileHandler):
bl_idname = "VIEW3D_FH_empty_image"
bl_label = "Add empty image"
bl_import_operator = "OBJECT_OT_empty_image_add"
bl_file_extensions = bl_file_extensions_image_and_movie
@classmethod
def poll_drop(cls, context):
if not context.space_data or context.space_data.type != 'VIEW_3D':
return False
rv3d = context.space_data.region_3d
return rv3d.view_perspective == 'PERSP' or rv3d.view_perspective == 'ORTHO'
class VIEW3D_FH_camera_background_image(FileHandler):
bl_idname = "VIEW3D_FH_camera_background_image"
bl_label = "Add camera background image"
bl_import_operator = "VIEW3D_OT_camera_background_image_add"
bl_file_extensions = bl_file_extensions_image_and_movie
@classmethod
def poll_drop(cls, context):
if not context.space_data or context.space_data.type != 'VIEW_3D':
return False
rv3d = context.space_data.region_3d
return rv3d.view_perspective == 'CAMERA'
class VIEW3D_FH_vdb_volume(FileHandler):
bl_idname = "VIEW3D_FH_vdb_volume"
bl_label = "OpenVDB volume"
bl_import_operator = "OBJECT_OT_volume_import"
bl_file_extensions = ".vdb"
@classmethod
def poll_drop(cls, context):
return context.space_data and context.space_data.type == 'VIEW_3D'
classes = (
VIEW3D_OT_edit_mesh_extrude_individual_move,
VIEW3D_OT_edit_mesh_extrude_move,
VIEW3D_OT_edit_mesh_extrude_shrink_fatten,
VIEW3D_OT_edit_mesh_extrude_manifold_normal,
VIEW3D_OT_transform_gizmo_set,
VIEW3D_FH_camera_background_image,
VIEW3D_FH_empty_image,
VIEW3D_FH_vdb_volume,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import bpy
import bmesh
class WORLD_OT_convert_volume_to_mesh(bpy.types.Operator):
"""Convert the volume of a world to a mesh. """ \
"""The world's volume used to be rendered by EEVEE Legacy. Conversion is needed for it to render properly"""
bl_label = "Convert Volume"
bl_options = {'REGISTER', 'UNDO'}
bl_idname = "world.convert_volume_to_mesh"
@classmethod
def poll(cls, context):
world = cls._world_get(context)
if not world:
return False
ntree = world.node_tree
node = ntree.get_output_node('EEVEE')
return bool(node.inputs["Volume"].links)
def execute(self, context):
cls = self.__class__
world = cls._world_get(context)
view_layer = context.view_layer
world_tree = world.node_tree
world_output = world_tree.get_output_node('EEVEE')
name = "{:s}_volume".format(world.name)
collection = bpy.data.collections.new(name)
view_layer.layer_collection.collection.children.link(collection)
# Add World Volume Mesh object to scene
mesh = bpy.data.meshes.new(name)
object = bpy.data.objects.new(name, mesh)
object.display.show_shadows = False
bm = bmesh.new()
bmesh.ops.create_icosphere(bm, subdivisions=0, radius=1e5)
bm.to_mesh(mesh)
bm.free()
# Remove all non-essential attributes
for attribute in mesh.attributes:
if attribute.is_internal or attribute.is_required:
continue
mesh.attributes.remove(attribute)
material = bpy.data.materials.new(name)
mesh.materials.append(material)
volume_tree = material.node_tree
volume_tree.nodes.clear()
volume_tree.nodes.new("ShaderNodeOutputMaterial")
volume_output = volume_tree.get_output_node('EEVEE')
links_to_add = []
self._sync_rna_properties(volume_output, world_output)
self._sync_node_input(
volume_tree,
volume_output,
volume_output.inputs["Volume"],
world_output,
world_output.inputs["Volume"],
links_to_add,
)
self._sync_links(volume_tree, links_to_add)
# Add transparent volume for other render engines
if volume_output.target == 'EEVEE':
all_output = volume_tree.nodes.new(type="ShaderNodeOutputMaterial")
transparent = volume_tree.nodes.new(type="ShaderNodeBsdfTransparent")
volume_tree.links.new(transparent.outputs[0], all_output.inputs[0])
# Remove all volume links from the world node tree.
for link in world_output.inputs["Volume"].links:
world_tree.links.remove(link)
collection.objects.link(object)
object.select_set(True)
view_layer.objects.active = object
world.use_eevee_finite_volume = False
return {'FINISHED'}
@staticmethod
def _world_get(context):
if world := getattr(context, "world", None):
return world
return context.scene.world
def _sync_node_input(
self,
dst_tree, # bpy.types.NodeTree
dst_node, # bpy.types.Node
dst_socket, # bpy.types.NodeSocket
src_node, # bpy.types.Node
src_socket, # bpy.types.NodeSocket
links_to_add,
): # -> None
self._sync_rna_properties(dst_socket, src_socket)
for src_link in src_socket.links:
src_linked_node = src_link.from_node
dst_linked_node = self._sync_node(dst_tree, src_linked_node, links_to_add)
from_socket_index = src_node.outputs.find(src_link.from_socket.name)
dst_tree.links.new(
dst_linked_node.outputs[from_socket_index],
dst_socket,
)
def _sync_node(
self,
dst_tree, # bpy.types.NodeTree
src_node, # bpy.types.Node
links_to_add,
): # -> bpy.types.Node
"""
Find the counter part of the src_node in dst_tree. When found return the counter part. When not found
create the counter part, sync it and return the created node.
"""
if src_node.name in dst_tree.nodes:
return dst_tree.nodes[src_node.name]
dst_node = dst_tree.nodes.new(src_node.bl_idname)
self._sync_rna_properties(dst_node, src_node)
self._sync_node_inputs(dst_tree, dst_node, src_node, links_to_add)
return dst_node
def _sync_rna_properties(self, dst, src): # -> None
for rna_prop in src.bl_rna.properties:
if rna_prop.is_readonly:
continue
attr_name = rna_prop.identifier
if attr_name in {"bl_idname", "bl_static_type"}:
continue
setattr(dst, attr_name, getattr(src, attr_name))
def _sync_node_inputs(
self,
dst_tree, # bpy.types.NodeTree
dst_node, # bpy.types.Node
src_node, # bpy.types.Node
links_to_add,
): # -> None
for index in range(len(src_node.inputs)):
src_socket = src_node.inputs[index]
dst_socket = dst_node.inputs[index]
self._sync_node_input(dst_tree, dst_node, dst_socket, src_node, src_socket, links_to_add)
def _sync_links(
self,
dst_tree, # bpy.types.NodeTree
links_to_add,
): # -> None
pass
classes = (
WORLD_OT_convert_volume_to_mesh,
)