commit 5511f3a80e9f3ca31858b93f128bbaf33ecf8704 Author: Seth Trowbridge Date: Wed Oct 29 14:16:40 2025 -0400 init diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6cc4804 --- /dev/null +++ b/__init__.py @@ -0,0 +1,92 @@ +import bpy +from .utils import parse_scene_objects, parse_object, release_object, recompute, unregister_existing_handler, register_handler + +############################################################## + +class OBJECT_OT_parse_all_objects(bpy.types.Operator): + """Collect control ranges for all objects in the scene""" + bl_idname = "object.parse_all_objects" + bl_label = "Collect Control Ranges" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + for obj in context.scene.objects: + parse_scene_objects(obj) + return {'FINISHED'} + + +class OBJECT_OT_parse_object(bpy.types.Operator): + """Parse the active object""" + bl_idname = "object.parse_object" + bl_label = "Parse Object" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + obj = context.active_object + if obj: + parse_object(obj) + return {'FINISHED'} + else: + self.report({'WARNING'}, "No active object to parse") + return {'CANCELLED'} + + +class OBJECT_OT_release_object(bpy.types.Operator): + """Release the active object""" + bl_idname = "object.release_object" + bl_label = "Release Object" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + obj = context.active_object + if obj: + release_object(obj) + return {'FINISHED'} + else: + self.report({'WARNING'}, "No active object to release") + return {'CANCELLED'} + + +class OBJECT_PT_control_tools(bpy.types.Panel): + """Panel for Control Tools""" + bl_label = "Control Tools" + bl_idname = "OBJECT_PT_control_tools" + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = "object" + + def draw(self, context): + layout = self.layout + layout.label(text="Control Range Operations:") + layout.operator("object.collect_control_ranges", text="Collect Control Ranges") + + layout.separator() + layout.label(text="Active Object Operations:") + layout.operator("object.parse_object", text="Parse Object") + layout.operator("object.release_object", text="Release Object") + + +############################################################## + +# Registration + +def execute(): + parse_scene_objects() + recompute() + +def register(): + unregister_existing_handler() + register_handler() + + bpy.utils.register_class(OBJECT_OT_parse_all_objects) + bpy.utils.register_class(OBJECT_OT_parse_object) + bpy.utils.register_class(OBJECT_OT_release_object) + bpy.utils.register_class(OBJECT_PT_control_tools) # Register panel + +def unregister(): + + unregister_existing_handler() + bpy.utils.unregister_class(OBJECT_OT_parse_all_objects) + bpy.utils.unregister_class(OBJECT_OT_parse_object) + bpy.utils.unregister_class(OBJECT_OT_release_object) + bpy.utils.unregister_class(OBJECT_PT_control_tools) # Register panel diff --git a/__pycache__/__init__.cpython-311.pyc b/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f548e38 Binary files /dev/null and b/__pycache__/__init__.cpython-311.pyc differ diff --git a/__pycache__/utils.cpython-311.pyc b/__pycache__/utils.cpython-311.pyc new file mode 100644 index 0000000..26f2973 Binary files /dev/null and b/__pycache__/utils.cpython-311.pyc differ diff --git a/blender_manifest.toml b/blender_manifest.toml new file mode 100644 index 0000000..f63eb6e --- /dev/null +++ b/blender_manifest.toml @@ -0,0 +1,10 @@ +name = "Juggler" +tagline = "animation helper" +type = "add-on" +id = "juggler_dev" +version = "1.0.0" +blender_version_min = "5.0.0" +schema_version = "1.0.0" + +license = ["SPDX:GPL-3.0-or-later"] +maintainer = "Seth Trowbridge " \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..42e8a60 --- /dev/null +++ b/utils.py @@ -0,0 +1,166 @@ +import bpy +from mathutils import Matrix +from typing import List, Optional +from dataclasses import dataclass + +@dataclass +class Snapshot: + m1: Optional[Matrix]; + m2: Optional[Matrix]; + f1: int; + f2: int; + obj: Optional[bpy.types.Object]; + +@dataclass +class TimeCache: + obj: bpy.types.Object; + mat: Matrix; + cache: List[Snapshot]; + +cached_objects:List[TimeCache] = [] +blocking = False + +def sample_transform(obj:bpy.types.Object, frame: int|float)->Matrix: + + """Return the object's world transform matrix at a given frame number.""" + if obj is None: + raise ValueError("No object provided") + + scene = bpy.context.scene + + if frame != scene.frame_current: + + global blocking + blocking = True + + depsgraph = bpy.context.evaluated_depsgraph_get() + + current_frame = scene.frame_current + scene.frame_set(int(frame)) + eval_obj = obj.evaluated_get(depsgraph) + matrix = eval_obj.matrix_world.copy() + scene.frame_set(current_frame) + + blocking = False + + return matrix + + else: + return obj.matrix_world.copy() + +def apply_accumulated_deltas(data:TimeCache, sample_time:int)->None: + """ + obj_data = (obj, base_matrix, cached_segments) + Applies deltas to obj based on sample_time. + """ + # obj, base_matrix, cached_data = obj_data + + + cumulative = Matrix.Identity(4) + last_range_end = None + + for snapshot in data.cache: + if snapshot.obj is None: + continue + if sample_time >= snapshot.f2: + delta = snapshot.m2 @ snapshot.m1.inverted() + cumulative = delta @ cumulative + last_range_end = snapshot.f2 + elif snapshot.f1 <= sample_time < snapshot.f2: + current = sample_transform(snapshot.obj, sample_time) + delta = current @ snapshot.m1.inverted() + cumulative = delta @ cumulative + last_range_end = sample_time + break + else: + break + + if last_range_end is None: + return + + data.obj.matrix_world = cumulative @ data.mat + +def parse_scene_objects() -> None: + """ + Scans all objects in the current scene for Child Of constraints named like 'frame_'. + Records for each object: + - Base matrix captured one frame before the first control + - List of control segments (m1, m2, f1, f2, control) + """ + global cached_objects + cached_objects = [] + for target in bpy.context.scene.objects: + parse_object(target) + +def parse_object(target:bpy.types.Object)->Optional[TimeCache]: + + frame_entries:List[Snapshot] = [] + + for constraint in target.constraints: + if constraint.type == 'CHILD_OF' and constraint.name.startswith("frame_"): + try: + frame = int(constraint.name.split("_")[1]) + frame_entries.append(Snapshot(m1=None, m2=None, f1=frame, f2=frame, obj=constraint.target)) + except Exception as e: + print(f"Invalid constraint name '{constraint.name}' on '{target.name}': {e}") + finally: + constraint.mute = True # disable constraint + + if len(frame_entries) == 0: + return None + + frame_entries.sort(key=lambda x: x.f1) + + # capture baseline before first frame + first_frame:int = frame_entries[0].f1 + base_frame:int = max(first_frame - 1, 0) + base_matrix:Matrix = sample_transform(target, base_frame) + + #for i, (f1, control) in enumerate(frame_entries): + for i, snapshot in enumerate(frame_entries): + check_next = frame_entries[i + 1].f1 if i + 1 < len(frame_entries) else 999999 + snapshot.f2 = check_next - 1 if check_next > snapshot.f1 else snapshot.f1 + + print("frames are:", snapshot.f1, snapshot.f2) + + if snapshot.obj: + snapshot.m1 = sample_transform(snapshot.obj, snapshot.f1) + snapshot.m2 = sample_transform(snapshot.obj, snapshot.f2) + + cached = TimeCache(obj=target, mat=base_matrix, cache=frame_entries) + global cached_objects + cached_objects.append(cached) + return cached + +def release_object(obj:bpy.types.Object)->None: + + global cached_objects + + for i, record in enumerate(cached_objects): + if obj is record.obj: + del cached_objects[i] + return + +def recompute()->None: + for obj_data in cached_objects: + apply_accumulated_deltas(obj_data, bpy.context.scene.frame_current) + +def frame_change_handler(scene:bpy.types.Scene)->None: + if blocking: + print("blocked") + else: + recompute() + +def unregister_existing_handler()->None: + # Remove any previously registered version of this handler + for h in bpy.app.handlers.frame_change_post: + if h.__name__ == "frame_change_handler": + bpy.app.handlers.frame_change_post.remove(h) + print("Removed old frame_change_handler") + +def register_handler()->None: + unregister_existing_handler() + bpy.app.handlers.frame_change_post.append(frame_change_handler) + print("Registered new frame_change_handler") + +