chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
import bpy
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from bpy.types import Operator
|
||||
from bpy.props import FloatProperty
|
||||
from mathutils import (
|
||||
@@ -415,33 +416,507 @@ class CLIP_OT_set_viewport_background(Operator):
|
||||
|
||||
|
||||
class CLIP_OT_constraint_to_fcurve(Operator):
|
||||
"""Create F-Curves for object which will copy """ \
|
||||
"""object's movement caused by this constraint"""
|
||||
"""Bake movement through this constraint and remove it and all preceding constraints"""
|
||||
|
||||
bl_idname = "clip.constraint_to_fcurve"
|
||||
bl_label = "Constraint to F-Curve"
|
||||
bl_options = {'UNDO', 'REGISTER'}
|
||||
|
||||
def _bake_object(self, scene, ob):
|
||||
con = None
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
con = getattr(context, "constraint", None)
|
||||
if con is None:
|
||||
return context.scene is not None
|
||||
ob = context.object
|
||||
if con.type not in {'CAMERA_SOLVER', 'FOLLOW_TRACK', 'OBJECT_SOLVER'}:
|
||||
cls.poll_message_set("A motion tracking constraint is required")
|
||||
return False
|
||||
bone = getattr(context, "pose_bone", None)
|
||||
if ob is None or not ob.is_editable or con.id_data != ob:
|
||||
cls.poll_message_set("An editable constraint object is required")
|
||||
return False
|
||||
if bone is not None and (bone.id_data != ob or ob.pose is None or
|
||||
ob.pose.bones.get(bone.name) != bone or not ob.data.is_editable):
|
||||
cls.poll_message_set("An editable pose bone belonging to the constraint object is required")
|
||||
return False
|
||||
owner = bone if bone is not None else ob
|
||||
if con not in owner.constraints.values():
|
||||
cls.poll_message_set("The constraint must belong to the displayed object or bone")
|
||||
return False
|
||||
reason = cls._isolation_error(owner, con)
|
||||
if reason is None and bone is not None:
|
||||
reason = cls._pose_bake_error(ob, cls._pose_bake_plan(owner, con))
|
||||
if reason:
|
||||
cls.poll_message_set(reason)
|
||||
return False
|
||||
for chain_owner in cls._pose_bake_chain(owner, con):
|
||||
rotation_path = ('rotation_quaternion' if chain_owner.rotation_mode == 'QUATERNION'
|
||||
else 'rotation_axis_angle' if chain_owner.rotation_mode == 'AXIS_ANGLE'
|
||||
else 'rotation_euler')
|
||||
if any(chain_owner.is_property_readonly(path)
|
||||
for path in ('location', rotation_path, 'scale')):
|
||||
cls.poll_message_set("A pose chain transform channel is read-only")
|
||||
return False
|
||||
if (not context.scene.is_editable or context.scene.objects.get(ob.name) != ob or
|
||||
context.view_layer.objects.get(ob.name) != ob):
|
||||
cls.poll_message_set("The constraint object must belong to the current editable scene and view layer")
|
||||
return False
|
||||
if ob.animation_data and ob.animation_data.action and not ob.animation_data.action.is_editable:
|
||||
cls.poll_message_set("The object's Action is read-only")
|
||||
return False
|
||||
reason = cls._tracking_error(context.scene, con)
|
||||
if reason:
|
||||
cls.poll_message_set(reason)
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _tracking_error(scene, con):
|
||||
# Match the resources used by followtrack_context_init and the solver
|
||||
# evaluators. A native no-op must not become a successful destructive bake.
|
||||
clip = scene.active_clip if con.use_active_clip else con.clip
|
||||
if clip is None:
|
||||
return "Movie clip to use tracking data from isn't set"
|
||||
if con.type != 'CAMERA_SOLVER' and (getattr(con, "camera", None) or scene.camera) is None:
|
||||
return "A camera is required to bake this tracking constraint"
|
||||
if con.type == 'OBJECT_SOLVER' or (con.type == 'FOLLOW_TRACK' and con.object):
|
||||
tracking_object = clip.tracking.objects.get(con.object)
|
||||
else:
|
||||
tracking_object = next((obj for obj in clip.tracking.objects if obj.is_camera), None)
|
||||
if tracking_object is None:
|
||||
return "Motion Tracking object not found"
|
||||
if con.type in {'CAMERA_SOLVER', 'OBJECT_SOLVER'}:
|
||||
reconstruction = tracking_object.reconstruction
|
||||
# The native interpolator returns identity for an empty camera array.
|
||||
# Do not bake that fallback and remove a constraint awaiting a solve.
|
||||
if not reconstruction.is_valid:
|
||||
return "Solve the tracking object before baking this constraint"
|
||||
if not reconstruction.cameras:
|
||||
return "The tracking reconstruction has no solved cameras to bake"
|
||||
return None
|
||||
tracks = tracking_object.tracks
|
||||
if con.type == 'FOLLOW_TRACK':
|
||||
track = tracks.get(con.track)
|
||||
if track is None:
|
||||
return "Motion Tracking track not found"
|
||||
if con.use_3d_position:
|
||||
if not track.has_bundle:
|
||||
return "The tracking track has no reconstructed 3D position"
|
||||
return None
|
||||
tracks = (track,)
|
||||
if not any(len(track.markers) for track in tracks):
|
||||
return "The tracking resource has no markers to bake"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _isolation_drivers(owner, following):
|
||||
# RNA supplies the escaped object/pose constraint path. Match the mute
|
||||
# channel only: drivers of influence, transforms and tracking resources
|
||||
# must keep evaluating while we sample the selected constraint.
|
||||
animation_data = owner.id_data.animation_data
|
||||
if animation_data is None:
|
||||
return []
|
||||
paths = {other.path_from_id("mute") for other in following}
|
||||
return [curve for curve in animation_data.drivers if curve.data_path in paths]
|
||||
|
||||
@staticmethod
|
||||
def _mute_channelbags(action, slot_handle, paths):
|
||||
# Blender 5.2 evaluates the assigned slot, not the Action's first slot.
|
||||
for layer in action.layers:
|
||||
for strip in layer.strips:
|
||||
if strip.type != 'KEYFRAME':
|
||||
continue
|
||||
for bag in strip.channelbags:
|
||||
if bag.slot_handle == slot_handle:
|
||||
for curve in bag.fcurves:
|
||||
if curve.data_path in paths:
|
||||
yield bag, curve
|
||||
|
||||
@staticmethod
|
||||
def _isolation_action_bindings(owner, following):
|
||||
animation_data = owner.id_data.animation_data
|
||||
if animation_data is None or not following:
|
||||
return []
|
||||
paths = {other.path_from_id("mute") for other in following}
|
||||
|
||||
def strip_bindings(strips):
|
||||
for strip in strips:
|
||||
if strip.type == 'CLIP':
|
||||
yield strip
|
||||
elif strip.type == 'META':
|
||||
yield from strip_bindings(strip.strips)
|
||||
|
||||
# Include muted/solo-excluded strips too: NLA's evaluation domain visits
|
||||
# their channels and can reset a property even when the strip contributes
|
||||
# no value at this frame. Keep timing, blending and track states intact.
|
||||
bindings = [animation_data]
|
||||
if animation_data.use_nla:
|
||||
for track in animation_data.nla_tracks:
|
||||
bindings.extend(strip_bindings(track.strips))
|
||||
return [binding for binding in bindings if binding.action is not None and
|
||||
any(CLIP_OT_constraint_to_fcurve._mute_channelbags(
|
||||
binding.action, binding.action_slot_handle, paths))]
|
||||
|
||||
@staticmethod
|
||||
def _restore_action_binding(binding, action, slot_handle, last_identifier):
|
||||
if binding.action != action:
|
||||
binding.action = action
|
||||
if binding.action_slot_handle != slot_handle:
|
||||
binding.action_slot_handle = slot_handle
|
||||
if last_identifier is not None and binding.last_slot_identifier != last_identifier:
|
||||
binding.last_slot_identifier = last_identifier
|
||||
if binding.action != action or binding.action_slot_handle != slot_handle:
|
||||
raise RuntimeError("Could not restore the constraint owner's Action/slot")
|
||||
|
||||
@staticmethod
|
||||
def _remove_isolation_action(action):
|
||||
# Never unlink a still-bound copy if RNA restoration failed. The caller
|
||||
# reports failure and the Web transaction restores Main in that case.
|
||||
if action.users:
|
||||
raise RuntimeError("Could not release the temporary bake Action")
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def _isolated_actions(cls, owner, con, view_layer):
|
||||
constraints = list(owner.constraints)
|
||||
following = constraints[constraints.index(con) + 1:]
|
||||
bindings = cls._isolation_action_bindings(owner, following)
|
||||
paths = {other.path_from_id("mute") for other in following}
|
||||
try:
|
||||
with ExitStack() as cleanup:
|
||||
copies = {}
|
||||
# Copy once per Action/slot for the entire sampling range. Shared
|
||||
# objects and other slots keep their original F-Curves untouched.
|
||||
for binding in bindings:
|
||||
action = binding.action
|
||||
handle = binding.action_slot_handle
|
||||
key = (action.as_pointer(), handle)
|
||||
if key not in copies:
|
||||
copy = action.copy()
|
||||
copy.use_fake_user = False
|
||||
cleanup.callback(cls._remove_isolation_action, copy)
|
||||
for bag, curve in list(cls._mute_channelbags(copy, handle, paths)):
|
||||
bag.fcurves.remove(curve)
|
||||
copies[key] = copy
|
||||
for binding in bindings:
|
||||
action = binding.action
|
||||
handle = binding.action_slot_handle
|
||||
cleanup.callback(cls._restore_action_binding, binding, action, handle,
|
||||
getattr(binding, "last_slot_identifier", None))
|
||||
binding.action = copies[(action.as_pointer(), handle)]
|
||||
binding.action_slot_handle = handle
|
||||
if (binding.action != copies[(action.as_pointer(), handle)] or
|
||||
binding.action_slot_handle != handle):
|
||||
raise RuntimeError("Could not isolate the constraint owner's Action/slot")
|
||||
yield
|
||||
finally:
|
||||
# All original bindings are back before evaluating or writing keys.
|
||||
view_layer.update()
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def _bake_action(cls, owner):
|
||||
animation_data = owner.id_data.animation_data_create()
|
||||
action = animation_data.action
|
||||
if action is None or action.users == 1:
|
||||
yield
|
||||
return
|
||||
# Key insertion must not change another object's Action or a retained
|
||||
# NLA strip using it. Keep a local copy only when the bake succeeds.
|
||||
if (animation_data.is_property_readonly("action") or
|
||||
animation_data.is_property_readonly("action_slot_handle")):
|
||||
raise RuntimeError("Exit NLA Tweak Mode or make the bake Action binding editable before baking")
|
||||
handle = animation_data.action_slot_handle
|
||||
last_identifier = animation_data.last_slot_identifier
|
||||
with ExitStack() as rollback:
|
||||
copy = action.copy()
|
||||
copy.use_fake_user = False
|
||||
rollback.callback(cls._remove_isolation_action, copy)
|
||||
rollback.callback(cls._restore_action_binding, animation_data, action, handle, last_identifier)
|
||||
animation_data.action = copy
|
||||
animation_data.action_slot_handle = handle
|
||||
if animation_data.action != copy or animation_data.action_slot_handle != handle:
|
||||
raise RuntimeError("Could not assign the constraint owner's bake Action/slot")
|
||||
yield
|
||||
rollback.pop_all()
|
||||
|
||||
@staticmethod
|
||||
def _isolation_error(owner, con):
|
||||
# Sample the ordered prefix using Blender's solver, then consume that
|
||||
# prefix. Trying to invert predecessors (Limit, for example) is lossy;
|
||||
# retaining them would apply their effects to the baked channels twice.
|
||||
after = False
|
||||
following = []
|
||||
for other in owner.constraints:
|
||||
if not after:
|
||||
if other.is_override_data:
|
||||
return "The baked constraint prefix contains a protected library override constraint"
|
||||
if other == con:
|
||||
after = True
|
||||
elif after:
|
||||
following.append(other)
|
||||
if not other.mute and other.is_property_readonly("mute"):
|
||||
return "A following constraint cannot be temporarily muted for baking"
|
||||
for curve in CLIP_OT_constraint_to_fcurve._isolation_drivers(owner, following):
|
||||
if not curve.mute and curve.is_property_readonly("mute"):
|
||||
return "A following constraint's mute driver is read-only"
|
||||
for binding in CLIP_OT_constraint_to_fcurve._isolation_action_bindings(owner, following):
|
||||
if (binding.is_property_readonly("action") or
|
||||
binding.is_property_readonly("action_slot_handle")):
|
||||
return "Exit NLA Tweak Mode or make the following constraint's Action binding editable before baking"
|
||||
return None if after else "Motion Tracking constraint no longer belongs to this owner"
|
||||
|
||||
@staticmethod
|
||||
def _solver_chain(owner, con):
|
||||
if con.type not in {'IK', 'SPLINE_IK'}:
|
||||
return ()
|
||||
# Match the native solver's tip and chain-count semantics. In particular,
|
||||
# IK without Use Tail starts at the parent; zero means all parents only
|
||||
# for IK, not Spline IK (which has no segments at zero).
|
||||
tip = owner
|
||||
count = con.chain_count
|
||||
if con.type == 'IK':
|
||||
if not con.use_tail and tip.parent is not None:
|
||||
tip = tip.parent
|
||||
count = min(count or 256, 256)
|
||||
else:
|
||||
count = min(count, 255)
|
||||
chain = []
|
||||
while tip is not None and len(chain) < count:
|
||||
chain.append(tip)
|
||||
tip = tip.parent
|
||||
return tuple(chain)
|
||||
|
||||
@classmethod
|
||||
def _pose_bake_plan(cls, owner, con):
|
||||
constraints = list(owner.constraints)
|
||||
plan = [(owner, constraints[:constraints.index(con) + 1])]
|
||||
seen = {owner.name}
|
||||
pose_bones = owner.id_data.pose.bones
|
||||
|
||||
def include(bone, consumed):
|
||||
consumed = list(consumed)
|
||||
for index, (planned_bone, planned_constraints) in enumerate(plan):
|
||||
if planned_bone.name == bone.name:
|
||||
if len(consumed) > len(planned_constraints):
|
||||
plan[index] = (planned_bone, consumed)
|
||||
return True
|
||||
return False
|
||||
plan.append((bone, consumed))
|
||||
seen.add(bone.name)
|
||||
return True
|
||||
|
||||
# A parent's visual local matrix includes its own complete stack. Consume
|
||||
# that stack too, following any of its solvers to their additional parents,
|
||||
# or those constraints would be applied twice to the baked transform.
|
||||
# If another branch solver reaches any planned bone, absorb that owner's
|
||||
# prefix through the last intersecting solver as well. Preserve its suffix
|
||||
# using the same Action/NLA/driver isolation as the selected owner. Grow
|
||||
# both the bone set and prefixes until newly included solvers add nothing.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for bone, consumed in tuple(plan):
|
||||
for other in consumed:
|
||||
for affected in cls._solver_chain(bone, other):
|
||||
if affected.name not in seen:
|
||||
include(affected, affected.constraints)
|
||||
changed = True
|
||||
planned_names = {bone.name for bone, _consumed in plan}
|
||||
for branch in pose_bones:
|
||||
if branch == owner:
|
||||
continue
|
||||
stack = list(branch.constraints)
|
||||
endpoints = [index for index, other in enumerate(stack)
|
||||
if any(affected.name in planned_names
|
||||
for affected in cls._solver_chain(branch, other))]
|
||||
if endpoints:
|
||||
changed = include(branch, stack[:endpoints[-1] + 1]) or changed
|
||||
return plan
|
||||
|
||||
@classmethod
|
||||
def _pose_bake_chain(cls, owner, con):
|
||||
if not isinstance(owner, bpy.types.PoseBone):
|
||||
return (owner,)
|
||||
return tuple(bone for bone, _constraints in cls._pose_bake_plan(owner, con))
|
||||
|
||||
@classmethod
|
||||
def _pose_bake_error(cls, ob, plan):
|
||||
for bone, constraints in plan:
|
||||
if constraints:
|
||||
reason = cls._isolation_error(bone, constraints[-1])
|
||||
if reason:
|
||||
return reason
|
||||
if any(other.is_override_data for other in constraints):
|
||||
return "The baked pose chain contains a protected library override constraint"
|
||||
rotation_path = ('rotation_quaternion' if bone.rotation_mode == 'QUATERNION'
|
||||
else 'rotation_axis_angle' if bone.rotation_mode == 'AXIS_ANGLE'
|
||||
else 'rotation_euler')
|
||||
if any(bone.is_property_readonly(path) for path in ('location', rotation_path, 'scale')):
|
||||
return "A pose chain transform channel is read-only"
|
||||
return None
|
||||
|
||||
def _bake_object(self, scene, ob, con=None, pose_bone=None, view_layer=None):
|
||||
clip = None
|
||||
sfra = None
|
||||
efra = None
|
||||
frame_current = scene.frame_current
|
||||
subframe_current = scene.frame_subframe
|
||||
matrices = []
|
||||
owner = pose_bone if pose_bone is not None else ob
|
||||
bake_chain = (owner,)
|
||||
pose_baker = None
|
||||
|
||||
def visual_matrix(evaluated_ob, target_bone=None):
|
||||
if target_bone is not None:
|
||||
# Same native conversion as bpy_extras.anim_utils.pose_frame_info:
|
||||
# capture the evaluated pose in this bone's local transform space,
|
||||
# including Blender's parent/rest-pose and inheritance semantics.
|
||||
evaluated_bone = evaluated_ob.pose.bones[target_bone.name]
|
||||
return evaluated_ob.convert_space(
|
||||
pose_bone=evaluated_bone, matrix=evaluated_bone.matrix,
|
||||
from_space='POSE', to_space='LOCAL')
|
||||
return evaluated_ob.matrix_world.copy()
|
||||
|
||||
def isolated_matrix(frame=None):
|
||||
following = []
|
||||
drivers = []
|
||||
for target, endpoint in isolation_targets:
|
||||
reason = self._isolation_error(target, endpoint)
|
||||
if reason:
|
||||
raise RuntimeError(reason)
|
||||
stack = list(target.constraints)
|
||||
suffix = stack[stack.index(endpoint) + 1:]
|
||||
following.extend((target, other, other.mute) for other in suffix)
|
||||
drivers.extend((curve, curve.mute)
|
||||
for curve in self._isolation_drivers(target, suffix))
|
||||
try:
|
||||
# BKE_animsys_eval_driver skips FCURVE_MUTED. Suspend only this
|
||||
# planned owners' suffix mute drivers before changing constraint mute;
|
||||
# otherwise depsgraph evaluation can immediately undo isolation.
|
||||
# Driver curves belong to the object, unlike potentially shared
|
||||
# Action channelbags, so this does not mute another Action user.
|
||||
for curve, muted in drivers:
|
||||
if not muted:
|
||||
curve.mute = True
|
||||
for _target, other, muted in following:
|
||||
if not muted:
|
||||
other.mute = True
|
||||
# Use the caller's actual view layer, including pinned Properties
|
||||
# owners. RNA updates evaluate Blender's original constraint code;
|
||||
# no transform/constraint solver is reproduced here.
|
||||
view_layer.update()
|
||||
evaluated_ob = ob.evaluated_get(view_layer.depsgraph)
|
||||
for target, endpoint in isolation_targets:
|
||||
evaluated_owner = (evaluated_ob.pose.bones[target.name]
|
||||
if pose_bone is not None else evaluated_ob)
|
||||
evaluated_constraint = evaluated_owner.constraints.get(endpoint.name)
|
||||
if evaluated_constraint is None:
|
||||
raise RuntimeError("The tracking constraint disappeared during evaluation")
|
||||
reason = self._isolation_error(evaluated_owner, evaluated_constraint)
|
||||
if reason is None and evaluated_constraint.type in {
|
||||
'CAMERA_SOLVER', 'FOLLOW_TRACK', 'OBJECT_SOLVER'}:
|
||||
reason = self._tracking_error(scene, evaluated_constraint)
|
||||
if reason:
|
||||
raise RuntimeError(reason)
|
||||
# Keep checking the evaluated stack after owner-local Action/NLA
|
||||
# isolation, including drivers that could not be suspended;
|
||||
# never present a full-stack sample as a single-constraint bake.
|
||||
if any(not (evaluated_ob.pose.bones[target.name] if pose_bone is not None
|
||||
else evaluated_ob).constraints[other.name].mute
|
||||
for target, other, _muted in following):
|
||||
raise RuntimeError("A following constraint's animation or driver prevents bake isolation")
|
||||
if pose_bone is None:
|
||||
return visual_matrix(evaluated_ob)
|
||||
if pose_baker is not None:
|
||||
# Sample before restoring suffixes; anim_utils reads the same
|
||||
# evaluated graph and writes only the planned pose channels.
|
||||
pose_baker.send(frame)
|
||||
return None
|
||||
return {bone.name: visual_matrix(evaluated_ob, bone) for bone in bake_chain}
|
||||
finally:
|
||||
# Restore the original constraint values before re-enabling
|
||||
# drivers, then evaluate with all original channels active. Nested
|
||||
# cleanup also restores drivers if restoring a constraint raises.
|
||||
try:
|
||||
for _target, other, muted in following:
|
||||
if other.mute != muted:
|
||||
other.mute = muted
|
||||
finally:
|
||||
try:
|
||||
for curve, muted in drivers:
|
||||
if curve.mute != muted:
|
||||
curve.mute = muted
|
||||
finally:
|
||||
view_layer.update()
|
||||
|
||||
def apply_matrix(matrix):
|
||||
if pose_bone is not None:
|
||||
pose_bone.matrix_basis = matrix
|
||||
else:
|
||||
ob.matrix_world = matrix
|
||||
|
||||
# Find constraint which would be converting
|
||||
# TODO: several camera solvers and track followers would fail,
|
||||
# but can't think about real work-flow where it'll be useful
|
||||
for x in ob.constraints:
|
||||
if x.type in {'CAMERA_SOLVER', 'FOLLOW_TRACK', 'OBJECT_SOLVER'}:
|
||||
con = x
|
||||
# A Properties panel supplies its exact (possibly pinned) constraint.
|
||||
# Keep the desktop selection-based fallback for callers without that context.
|
||||
if con is None:
|
||||
for x in owner.constraints:
|
||||
if x.type in {'CAMERA_SOLVER', 'FOLLOW_TRACK', 'OBJECT_SOLVER'}:
|
||||
con = x
|
||||
|
||||
if not con:
|
||||
self.report({'ERROR'}, "Motion Tracking constraint to be converted not found")
|
||||
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Selection-based callers do not have the Properties constraint poll.
|
||||
# Recheck here as well, before creating Actions or consuming any prefix.
|
||||
reason = self._tracking_error(scene, con)
|
||||
if reason:
|
||||
self.report({'ERROR'}, reason)
|
||||
return {'CANCELLED'}
|
||||
|
||||
bake_plan = self._pose_bake_plan(owner, con) if pose_bone is not None else None
|
||||
bake_chain = tuple(bone for bone, _constraints in bake_plan) if bake_plan else (owner,)
|
||||
isolation_targets = ([(bone, constraints[-1]) for bone, constraints in bake_plan
|
||||
if constraints] if bake_plan else [(owner, con)])
|
||||
|
||||
@contextmanager
|
||||
def isolated_actions():
|
||||
# Nested copies restore in reverse order. All owners share the object
|
||||
# Action binding, so each copy inherits earlier suffix exclusions;
|
||||
# original/shared Actions and slots stay untouched on success/error.
|
||||
with ExitStack() as cleanup:
|
||||
for target, endpoint in isolation_targets:
|
||||
cleanup.enter_context(self._isolated_actions(target, endpoint, view_layer))
|
||||
yield
|
||||
|
||||
if view_layer is None:
|
||||
self.report({'ERROR'}, "The constraint owner's view layer is required for baking")
|
||||
return {'CANCELLED'}
|
||||
reason = self._isolation_error(owner, con)
|
||||
if reason is None and bake_plan is not None:
|
||||
reason = self._pose_bake_error(ob, bake_plan)
|
||||
if reason:
|
||||
self.report({'ERROR'}, reason)
|
||||
return {'CANCELLED'}
|
||||
constraints = list(owner.constraints)
|
||||
consumed = constraints[:constraints.index(con) + 1]
|
||||
|
||||
def consume_prefix():
|
||||
# Removal is the final commit, after all sampling, key insertion and
|
||||
# frame restoration succeed. Keep the context constraint alive until
|
||||
# last; the native Properties caller clears its RNA context on return.
|
||||
# Ancestor stacks were included in visual sampling. Commit them first
|
||||
# and leave the context constraint alive until the final removal.
|
||||
if bake_plan is not None:
|
||||
for bone, constraints in reversed(bake_plan[1:]):
|
||||
for other in constraints:
|
||||
bone.constraints.remove(other)
|
||||
for other in consumed:
|
||||
owner.constraints.remove(other)
|
||||
|
||||
# Get clip used for parenting.
|
||||
if con.use_active_clip:
|
||||
clip = scene.active_clip
|
||||
@@ -453,17 +928,43 @@ class CLIP_OT_constraint_to_fcurve(Operator):
|
||||
|
||||
return {'CANCELLED'}
|
||||
|
||||
if con.type == 'FOLLOW_TRACK' and con.use_3d_position:
|
||||
mat = ob.matrix_world.copy()
|
||||
ob.constraints.remove(con)
|
||||
ob.matrix_world = mat
|
||||
static_follow = con.type == 'FOLLOW_TRACK' and con.use_3d_position
|
||||
if static_follow and len(consumed) == 1 and len(bake_chain) == 1:
|
||||
try:
|
||||
with isolated_actions():
|
||||
mat = isolated_matrix()
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({'ERROR'}, str(error))
|
||||
return {'CANCELLED'}
|
||||
# Assign before removal, so failure leaves the constraint available
|
||||
# to the caller's Main rollback and scoped Properties context.
|
||||
try:
|
||||
apply_matrix(mat[pose_bone.name] if pose_bone is not None else mat)
|
||||
consume_prefix()
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({'ERROR'}, str(error))
|
||||
return {'CANCELLED'}
|
||||
|
||||
return {'FINISHED'}
|
||||
|
||||
# Find start and end frames.
|
||||
if con.type == 'CAMERA_SOLVER':
|
||||
# Camera solver constraint is always referring to camera.
|
||||
tracks = clip.tracking.tracks
|
||||
if static_follow:
|
||||
# A 3D track itself is static, but predecessors may animate via RNA,
|
||||
# drivers or NLA. Consume their motion over the scene bake range, not
|
||||
# a single current-frame matrix or an unrelated marker interval.
|
||||
sfra, efra = scene.frame_start, scene.frame_end
|
||||
tracks = ()
|
||||
elif con.type in {'CAMERA_SOLVER', 'OBJECT_SOLVER'}:
|
||||
tracking_object = (next(obj for obj in clip.tracking.objects if obj.is_camera)
|
||||
if con.type == 'CAMERA_SOLVER'
|
||||
else clip.tracking.objects[con.object])
|
||||
# Solver evaluation consumes reconstruction cameras, not 2D markers.
|
||||
# Markers can be removed or cover a different interval after solving.
|
||||
# Sample all intervening frames through Blender's native interpolator;
|
||||
# sparse reconstructed cameras do not imply missing-frame failures.
|
||||
cameras = tracking_object.reconstruction.cameras
|
||||
sfra, efra = cameras[0].frame, cameras[-1].frame
|
||||
tracks = ()
|
||||
elif con.object:
|
||||
tracking_object = clip.tracking.objects.get(con.object, None)
|
||||
if not tracking_object:
|
||||
@@ -475,7 +976,14 @@ class CLIP_OT_constraint_to_fcurve(Operator):
|
||||
else:
|
||||
tracks = clip.tracking.tracks
|
||||
|
||||
if con.type == 'FOLLOW_TRACK' and not static_follow:
|
||||
# The native solver reads only con.track. Other tracks must neither
|
||||
# extend the bake interval nor make an empty selected track usable.
|
||||
tracks = (tracks.get(con.track),)
|
||||
|
||||
for track in tracks:
|
||||
if not track.markers:
|
||||
continue
|
||||
if sfra is None:
|
||||
sfra = track.markers[0].frame
|
||||
else:
|
||||
@@ -487,44 +995,102 @@ class CLIP_OT_constraint_to_fcurve(Operator):
|
||||
efra = max(efra, track.markers[-1].frame)
|
||||
|
||||
if sfra is None or efra is None:
|
||||
self.report({'ERROR'}, "Movie clip has no tracking markers to bake")
|
||||
return {'CANCELLED'}
|
||||
|
||||
# Store object matrices.
|
||||
for x in range(sfra, efra + 1):
|
||||
scene.frame_set(x)
|
||||
matrices.append(ob.matrix_world.copy())
|
||||
# Markers and reconstructed cameras use clip frames; F-Curves use scene frames.
|
||||
# Inverse of the scene -> clip conversion used by the tracking operators.
|
||||
if not static_follow:
|
||||
sfra += clip.frame_start - 1
|
||||
efra += clip.frame_start - 1
|
||||
|
||||
ob.animation_data_create()
|
||||
# Restore the timeline even if an RNA/keyframe operation raises. The Web
|
||||
# caller owns Main rollback for cancelled/error results.
|
||||
try:
|
||||
with self._bake_action(owner):
|
||||
if pose_bone is not None:
|
||||
from bpy_extras.anim_utils import BakeOptions, bake_action_iter
|
||||
pose_baker = bake_action_iter(
|
||||
ob, action=ob.animation_data.action,
|
||||
bake_options=BakeOptions(
|
||||
only_selected=False, do_pose=True, do_object=False,
|
||||
do_visual_keying=True, do_constraint_clear=False,
|
||||
do_parents_clear=False, do_clean=False,
|
||||
do_location=True, do_rotation=True, do_scale=True,
|
||||
do_bbone=False, do_custom_props=False),
|
||||
pose_bone_names=tuple(bone.name for bone in bake_chain),
|
||||
depsgraph=view_layer.depsgraph)
|
||||
pose_baker.send(None)
|
||||
|
||||
# Apply matrices on object and insert key-frames.
|
||||
i = 0
|
||||
for x in range(sfra, efra + 1):
|
||||
scene.frame_set(x)
|
||||
ob.matrix_world = matrices[i]
|
||||
# Sampling finishes before key insertion. Action/NLA isolation
|
||||
# and per-frame suffix/driver isolation stay shared with the
|
||||
# object path, including restoration on any generator failure.
|
||||
with isolated_actions():
|
||||
for x in range(sfra, efra + 1):
|
||||
scene.frame_set(x)
|
||||
matrix = isolated_matrix(x)
|
||||
if pose_baker is None:
|
||||
matrices.append(matrix)
|
||||
|
||||
ob.keyframe_insert("location")
|
||||
if pose_baker is not None:
|
||||
if pose_baker.send(None) is None:
|
||||
raise RuntimeError("Could not create the baked pose Action")
|
||||
else:
|
||||
rotation_previous = None
|
||||
rotation_path = ('rotation_quaternion' if owner.rotation_mode == 'QUATERNION'
|
||||
else 'rotation_axis_angle' if owner.rotation_mode == 'AXIS_ANGLE'
|
||||
else 'rotation_euler')
|
||||
for x, matrix in zip(range(sfra, efra + 1), matrices):
|
||||
scene.frame_set(x)
|
||||
apply_matrix(matrix)
|
||||
if rotation_path in {'rotation_quaternion', 'rotation_euler'}:
|
||||
rotation = getattr(owner, rotation_path).copy()
|
||||
if rotation_previous is not None:
|
||||
rotation.make_compatible(rotation_previous)
|
||||
setattr(owner, rotation_path, rotation)
|
||||
rotation_previous = rotation
|
||||
for path in ('location', rotation_path, 'scale'):
|
||||
if not owner.keyframe_insert(path, frame=x, group="Object Transforms"):
|
||||
raise RuntimeError("Could not insert baked {:s} keyframes".format(path))
|
||||
|
||||
if ob.rotation_mode == 'QUATERNION':
|
||||
ob.keyframe_insert("rotation_quaternion")
|
||||
else:
|
||||
ob.keyframe_insert("rotation_euler")
|
||||
|
||||
i += 1
|
||||
|
||||
ob.constraints.remove(con)
|
||||
|
||||
scene.frame_set(frame_current)
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({'ERROR'}, str(error))
|
||||
return {'CANCELLED'}
|
||||
finally:
|
||||
try:
|
||||
if pose_baker is not None:
|
||||
pose_baker.close()
|
||||
finally:
|
||||
scene.frame_set(frame_current, subframe=subframe_current)
|
||||
|
||||
# Keep the context constraint alive while frame restoration invokes updates.
|
||||
try:
|
||||
consume_prefix()
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
self.report({'ERROR'}, str(error))
|
||||
return {'CANCELLED'}
|
||||
return {'FINISHED'}
|
||||
|
||||
def execute(self, context):
|
||||
scene = context.scene
|
||||
# XXX, should probably use context.selected_editable_objects
|
||||
# since selected objects can be from a lib or in hidden layer!
|
||||
for ob in scene.objects:
|
||||
if ob.select_get():
|
||||
self._bake_object(scene, ob)
|
||||
con = getattr(context, "constraint", None)
|
||||
if con is not None:
|
||||
if not self.poll(context):
|
||||
self.report({'ERROR'}, "Tracking constraint context or resource is unavailable")
|
||||
return {'CANCELLED'}
|
||||
return self._bake_object(scene, context.object, con, getattr(context, "pose_bone", None),
|
||||
context.view_layer)
|
||||
|
||||
# Preserve the desktop selection entry, while propagating failures instead
|
||||
# of reporting success after a missing clip or empty tracking resource.
|
||||
objects = [ob for ob in scene.objects if ob.select_get()]
|
||||
if not objects or any(not ob.is_editable for ob in objects):
|
||||
self.report({'ERROR'}, "Select editable objects with tracking constraints")
|
||||
return {'CANCELLED'}
|
||||
for ob in objects:
|
||||
result = self._bake_object(scene, ob, view_layer=context.view_layer)
|
||||
if result != {'FINISHED'}:
|
||||
return result
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
@@ -286,9 +286,7 @@ class ExecutePreset(Operator):
|
||||
from os.path import basename, splitext
|
||||
filepath = self.filepath
|
||||
|
||||
# change the menu title to the most recently chosen option
|
||||
preset_class = getattr(bpy.types, self.menu_idname)
|
||||
preset_class.bl_label = bpy.path.display_name(basename(filepath), title_case=False)
|
||||
|
||||
ext = splitext(filepath)[1].lower()
|
||||
|
||||
@@ -303,6 +301,7 @@ class ExecutePreset(Operator):
|
||||
bpy.utils.execfile(filepath)
|
||||
except Exception as ex:
|
||||
self.report({'ERROR'}, rpt_("Failed to execute the preset: {:s}").format(repr(ex)))
|
||||
return {'CANCELLED'}
|
||||
|
||||
elif ext == ".xml":
|
||||
import _rna_xml as rna_xml
|
||||
@@ -313,6 +312,9 @@ class ExecutePreset(Operator):
|
||||
|
||||
_call_preset_cb(getattr(preset_class, "post_cb", None), context, filepath)
|
||||
|
||||
# Python class state is outside Main's failure rollback.
|
||||
# Publish the chosen title only after the preset has executed successfully.
|
||||
preset_class.bl_label = bpy.path.display_name(basename(filepath), title_case=False)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
||||
@@ -493,6 +493,11 @@ class ToolSelectPanelHelper:
|
||||
|
||||
@classmethod
|
||||
def _km_action_simple(cls, kc_default, kc, context_descr, label, keymap_fn):
|
||||
if (startup := bpy.utils._web_startup_state) is not None:
|
||||
# Includes dynamic ToolDefs first materialized during startup. Keep
|
||||
# their callable until the native keymap transaction is published.
|
||||
startup.track_tool_helper(cls)
|
||||
startup.track_tool_container(keymap_fn)
|
||||
km_idname = "{:s} {:s}, {:s}".format(cls.keymap_prefix, context_descr, label)
|
||||
km = kc.keymaps.get(km_idname)
|
||||
km_kwargs = dict(space_type=cls.bl_space_type, region_type='WINDOW', tool=True)
|
||||
@@ -516,6 +521,8 @@ class ToolSelectPanelHelper:
|
||||
|
||||
@classmethod
|
||||
def register(cls):
|
||||
if (startup := bpy.utils._web_startup_state) is not None:
|
||||
startup.track_tool_helper(cls)
|
||||
wm = bpy.context.window_manager
|
||||
# Write into defaults, users may modify in preferences.
|
||||
kc_default = wm.keyconfigs.default
|
||||
@@ -1038,12 +1045,18 @@ def _activate_by_item(context, space_type, item, index, *, as_fallback=False):
|
||||
|
||||
idname_fallback = (item_fallback and item_fallback.idname) or ""
|
||||
keymap_fallback = (item_fallback and item_fallback.keymap and item_fallback.keymap[0]) or ""
|
||||
# `from_dict` stores a tuple key-map as `[builder_callable]` and only swaps in
|
||||
# the real key-map name once the tool's key-map is registered. A workspace
|
||||
# entered before that registration still holds the callable here, so only
|
||||
# extend a real name instead of raising on `callable + str`.
|
||||
if keymap_fallback and not isinstance(keymap_fallback, str):
|
||||
keymap_fallback = ""
|
||||
if keymap_fallback:
|
||||
keymap_fallback = keymap_fallback + " (fallback)"
|
||||
|
||||
tool.setup(
|
||||
idname=item.idname,
|
||||
keymap=item.keymap[0] if item.keymap is not None else "",
|
||||
keymap=item.keymap[0] if item.keymap is not None and isinstance(item.keymap[0], str) else "",
|
||||
cursor=item.cursor or 'DEFAULT',
|
||||
options=item.options or set(),
|
||||
gizmo_group=gizmo_group,
|
||||
|
||||
Reference in New Issue
Block a user