From 5511f3a80e9f3ca31858b93f128bbaf33ecf8704 Mon Sep 17 00:00:00 2001 From: Seth Trowbridge Date: Wed, 29 Oct 2025 14:16:40 -0400 Subject: [PATCH] init --- __init__.py | 92 +++++++++++++++ __pycache__/__init__.cpython-311.pyc | Bin 0 -> 5274 bytes __pycache__/utils.cpython-311.pyc | Bin 0 -> 8637 bytes blender_manifest.toml | 10 ++ utils.py | 166 +++++++++++++++++++++++++++ 5 files changed, 268 insertions(+) create mode 100644 __init__.py create mode 100644 __pycache__/__init__.cpython-311.pyc create mode 100644 __pycache__/utils.cpython-311.pyc create mode 100644 blender_manifest.toml create mode 100644 utils.py 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 0000000000000000000000000000000000000000..f548e38b5e2d3325dfb51eb4c7170c0d0e88f187 GIT binary patch literal 5274 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 nQI2K&E7I!y)t_wIKMj*s?z@hB!or_ literal 0 HcmV?d00001 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") + +