From 299017c4eadd6c7d6f174faddfc44456dab77c32 Mon Sep 17 00:00:00 2001 From: Gabe Busto Date: Wed, 7 Jan 2026 09:38:30 -0500 Subject: [PATCH 1/6] feat(animation): import fixes, linker support, and animate command logic --- README.md | 6 + blocksmith/cli.py | 133 +++++ blocksmith/client.py | 43 ++ blocksmith/converters/__init__.py | 12 +- blocksmith/converters/bbmodel/exporter.py | 2 +- blocksmith/converters/gltf/exporter.py | 657 +++++++++++++++++++++- blocksmith/converters/python/exporter.py | 75 ++- blocksmith/converters/python/importer.py | 144 ++++- blocksmith/generator/SYSTEM_PROMPT.md | 109 +++- blocksmith/generator/engine.py | 4 +- blocksmith/generator/prompts.py | 53 ++ blocksmith/schema/blockjson.py | 3 +- 12 files changed, 1209 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 608c0d5..ae2485f 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,12 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate # Install in editable mode pip install -e . + +# Updating to latest version: +# git pull origin main +# pip install -e . # Re-run if dependencies changed +# If you hit weird import errors: +# deactivate && source .venv/bin/activate ``` ### 2. Set Up API Key diff --git a/blocksmith/cli.py b/blocksmith/cli.py index 61ba0e0..154c323 100644 --- a/blocksmith/cli.py +++ b/blocksmith/cli.py @@ -89,6 +89,62 @@ def generate(prompt, output, model, image, verbose): traceback.print_exc() sys.exit(1) +@cli.command() +@click.argument("prompt") +@click.option("--model-file", "-m", required=True, type=click.Path(exists=True), help="Path to existing model Python file") +@click.option("--output", "-o", default="anim.py", help="Output file path (default: anim.py)") +@click.option("--model", help="LLM model override") +@click.option('--verbose', '-v', is_flag=True, help='Show detailed statistics') +def animate(prompt, model_file, output, model, verbose): + """ + Generate animations for an existing model. + + Examples: + blocksmith animate "walk cycle" -m steve.py -o walk.py + blocksmith animate "wave hand" -m robot.py -o wave.py + """ + try: + # Initialize client + bs = Blocksmith(default_model=model) if model else Blocksmith() + + if verbose: + click.echo(f"Animating: {prompt}") + click.echo(f"Base Model: {model_file}") + + # Read model code + with open(model_file, 'r') as f: + model_code = f.read() + + # Generate animation + result = bs.animate(prompt, model_code, model=model) + + # Save output + click.echo(f"Saving animation to: {output}") + # Force saving as python file + if not output.endswith('.py'): + output += '.py' + + with open(output, 'w') as f: + f.write(result.dsl) + + # Show stats if verbose + if verbose: + click.echo("\nGeneration Statistics:") + click.echo(f" Tokens: {result.tokens.total_tokens}") + if result.cost is not None: + click.echo(f" Cost: ${result.cost:.4f}") + click.echo(f" Model: {result.model}") + + click.secho(f"✓ Success! Animation saved to {output}", fg='green') + + except Exception as e: + click.secho(f"Error: {e}", fg='red', err=True) + if verbose: + import traceback + traceback.print_exc() + sys.exit(1) + + @cli.command() @click.argument('input_path') @@ -131,6 +187,83 @@ def convert(input_path, output_path, verbose): sys.exit(1) +@cli.command() +@click.option('-m', '--model', 'model_path', required=True, help='Path to the base model file (Python DSL).') +@click.option('-a', '--animation', 'anim_paths', multiple=True, help='Path to animation Python file(s). Can be specified multiple times.') +@click.option('-o', '--output', required=True, help='Output file path (.glb only supported for now).') +@click.option('--verbose', '-v', is_flag=True, help='Show detailed linking info') +def link(model_path, anim_paths, output, verbose): + """ + Link a base model with one or more animation files. + + This allows you to generate a static model first, generate animations separately, + and then combine them into a single animated GLB file. + + Example: + blocksmith link -m robot.py -a walk.py -a wave.py -o robot.glb + """ + try: + from blocksmith.converters.python.importer import import_python_from_file, import_animation_only + from blocksmith.converters.gltf.exporter import export_glb + + # 1. Load Base Model + if verbose: + click.echo(f"Loading base model: {model_path}") + + if not Path(model_path).exists(): + raise FileNotFoundError(f"Model file not found: {model_path}") + + # We primarily support Python DSL for the linker as per design + if not model_path.endswith('.py'): + click.secho("Warning: Linker is designed for .py model files. Other formats might not work as expected.", fg='yellow') + + # Load model structure + model_data = import_python_from_file(model_path) + + # Initialize animations list if missing + if 'animations' not in model_data or model_data['animations'] is None: + model_data['animations'] = [] + + # 2. Load Animations + for anim_path in anim_paths: + if verbose: + click.echo(f"Linking animation file: {anim_path}") + + if not Path(anim_path).exists(): + raise FileNotFoundError(f"Animation file not found: {anim_path}") + + with open(anim_path, 'r', encoding='utf-8') as f: + code = f.read() + + # Extract animations using the helper + anims = import_animation_only(code) + + if verbose: + click.echo(f" Found {len(anims)} animations: {[a['name'] for a in anims]}") + + model_data['animations'].extend(anims) + + # 3. Export + if verbose: + click.echo(f"Exporting combined model to: {output}") + + if output.endswith('.glb'): + glb_bytes = export_glb(model_data) + with open(output, 'wb') as f: + f.write(glb_bytes) + else: + raise ValueError("Linker currently only supports .glb output.") + + click.secho(f"✓ Success! Linked model saved to {output}", fg='green') + + except Exception as e: + click.secho(f"Error linking model: {e}", fg='red', err=True) + if verbose: + import traceback + traceback.print_exc() + sys.exit(1) + + def main(): """Entry point for the CLI""" cli() diff --git a/blocksmith/client.py b/blocksmith/client.py index 5b600e0..7d1e7e0 100644 --- a/blocksmith/client.py +++ b/blocksmith/client.py @@ -13,6 +13,7 @@ class for handling conversions and saving. from blocksmith.generator.engine import ModelGenerator from blocksmith.converters import import_python, export_glb, export_gltf, export_bbmodel from blocksmith.llm.client import TokenUsage +from blocksmith.generator.prompts import ANIMATION_SYSTEM_PROMPT @dataclass @@ -180,6 +181,48 @@ def generate( _bs=self ) + def animate( + self, + prompt: str, + model_code: str, + model: Optional[str] = None + ) -> GenerationResult: + """ + Generate animations for an existing model structure. + + Args: + prompt: Description of the animation (e.g., "walk cycle") + model_code: The Python source code of the existing model (defines IDs) + model: Optional LLM model override + + Returns: + GenerationResult: Contains the generated create_animations() code + """ + # Construct specific prompt for animation + # We embed the model code so the LLM knows the IDs + full_prompt = f"""# Animation Request +{prompt} + +# Existing Model Structure +Use the Group IDs defined in this code: +{model_code} +""" + + # Call generator with specific system prompt + gen_response = self.generator.generate( + prompt=full_prompt, + model=model, + system_prompt=ANIMATION_SYSTEM_PROMPT + ) + + return GenerationResult( + dsl=gen_response.code, + tokens=gen_response.tokens, + cost=gen_response.cost, + model=gen_response.model, + _bs=self + ) + def get_stats(self): """ Get session statistics across all generations. diff --git a/blocksmith/converters/__init__.py b/blocksmith/converters/__init__.py index b54c722..1d253b9 100644 --- a/blocksmith/converters/__init__.py +++ b/blocksmith/converters/__init__.py @@ -1,11 +1,11 @@ """Format converters for BlockSmith models""" -from blocksmith.converters.python.importer import import_python -from blocksmith.converters.python.exporter import export_python -from blocksmith.converters.gltf.exporter import export_glb, export_gltf -from blocksmith.converters.gltf.importer_wrapper import import_gltf, import_glb -from blocksmith.converters.bbmodel.exporter import export_bbmodel -from blocksmith.converters.bbmodel.importer import import_bbmodel +from .python.importer import import_python +from .python.exporter import export_python +from .gltf.exporter import export_glb, export_gltf +from .gltf.importer_wrapper import import_gltf, import_glb +from .bbmodel.exporter import export_bbmodel +from .bbmodel.importer import import_bbmodel __all__ = [ "import_python", diff --git a/blocksmith/converters/bbmodel/exporter.py b/blocksmith/converters/bbmodel/exporter.py index d799590..1bf2689 100644 --- a/blocksmith/converters/bbmodel/exporter.py +++ b/blocksmith/converters/bbmodel/exporter.py @@ -14,7 +14,7 @@ # Import BlockJSON schema from blocksmith.schema.blockjson import ModelDefinition, CuboidEntity, GroupEntity -from blocksmith.converters.uv_mapper import to_bbmodel +from ..uv_mapper import to_bbmodel logger = logging.getLogger(__name__) diff --git a/blocksmith/converters/gltf/exporter.py b/blocksmith/converters/gltf/exporter.py index 8308f57..23f0ae1 100644 --- a/blocksmith/converters/gltf/exporter.py +++ b/blocksmith/converters/gltf/exporter.py @@ -366,9 +366,16 @@ def blender_entrypoint() -> None: obj_map[entity["id"]].parent = obj_map[parent_id] # Debug: Print object transforms before export - print("\nObject transforms before export:") + print("\nObject transforms before export (BIND POSE CAPTURE):") + BIND_POSE = {} for obj_name, obj in obj_map.items(): if obj.type == 'MESH' or obj.type == 'EMPTY': + BIND_POSE[obj.name] = { + "location": obj.location.copy(), + "rotation_quaternion": obj.rotation_quaternion.copy(), + "scale": obj.scale.copy(), + "rotation_mode": obj.rotation_mode + } print(f"{obj.name}: location={list(obj.location)}, rotation_quaternion={list(obj.rotation_quaternion)}, scale={list(obj.scale)}") # Export GLTF/GLB @@ -397,6 +404,253 @@ def blender_entrypoint() -> None: except Exception as e: print(f"Warning: Could not enable GLTF_EMBEDDED: {e}") + # ============================================================================ + # ANIMATION PROCESSING + # ============================================================================ + + def _map_interpolation(interp: str) -> str: + """Map V3 interpolation to Blender interpolation mode.""" + if interp == 'step': + return 'CONSTANT' + elif interp == 'cubic': + return 'BEZIER' + return 'LINEAR' + + animations = v3.get("animations", []) + + # Pre-build entity map for fast lookup of bind pose data (Rest Pose) + entity_map = {e.get("id"): e for e in v3.get("entities", [])} + + def _reset_scene_state(obj_map_local: dict, entity_map_local: dict) -> None: + """ + Reset all objects to their V3 bind pose and clear animation state. + This enforces a 'Clean Room' protocol between animations to prevent 'Dirty Canvas' leakage. + """ + for eid, obj_reset in obj_map_local.items(): + # 1. Reset Transforms to V3 Bind Pose + ent_data = entity_map_local.get(eid, {}) + + piv = ent_data.get("pivot", [0, 0, 0]) + rot = ent_data.get("rotation", [1, 0, 0, 0]) + scl = ent_data.get("scale", [1, 1, 1]) + + obj_reset.location = transform_position_v3_to_blender(piv) + + obj_reset.rotation_mode = 'QUATERNION' + rot_norm = normalize_quaternion(rot) + q_list = transform_quaternion_v3_to_blender(rot_norm) + obj_reset.rotation_quaternion = Quaternion(q_list) + + obj_reset.scale = (scl[0], scl[1], scl[2]) + + # 2. Clear Active Action + if obj_reset.animation_data: + obj_reset.animation_data.action = None + + # 3. Mute all NLA tracks to ensure they don't influence the next record/evaluation + if obj_reset.animation_data.nla_tracks: + for t in obj_reset.animation_data.nla_tracks: + t.mute = True + t.is_solo = False + + # Force update + bpy.context.view_layer.update() + + if animations: + print(f"Processing {len(animations)} animations...") + + for anim in animations: + # CRITICAL: Clean the canvas before processing this animation + _reset_scene_state(obj_map, entity_map) + + anim_name = anim.get("name", "animation") + loop_mode = anim.get("loop_mode", "repeat") + + # CRITICAL FIX: Prevent "Duration Leak" (Time Contamination) + # Calculate the actual duration of THIS animation from its keys. + # If we don't do this, Blender defaults to the longest previous animation (e.g., Explode=60), + # causing short animations (Drive=30) to have 30 frames of dead air. + max_anim_frame = 0.0 + for ch in anim.get("channels", []): + frames = ch.get("frames", []) + if frames: + last_time = frames[-1].get("time", 0.0) + if last_time > max_anim_frame: + max_anim_frame = last_time + + # Sanity check: If animation is empty, default to 1 + if max_anim_frame == 0: + max_anim_frame = 1.0 + + # Set the Scene Timeline limits for the Exporter + # We extend the scene to fit the longest animation seen so far. + # Individual animations are clamped by their NLA Strip length (see below). + bpy.context.scene.frame_start = 0 + current_end = bpy.context.scene.frame_end + new_end = max(current_end, int(max_anim_frame)) + bpy.context.scene.frame_end = new_end + print(f" Configured Timeline for '{anim_name}': End Frame = {new_end} (Local Max: {max_anim_frame})") + + # Group channels by target object + channels_by_target = {} + for channel in anim.get("channels", []): + target_id = channel.get("target_id") + if target_id not in obj_map: + print(f" Warning: Animation target {target_id} not found, skipping channel") + continue + if target_id not in channels_by_target: + channels_by_target[target_id] = [] + channels_by_target[target_id].append(channel) + + # Create Actions and NLA Tracks for each affected object + for target_id, channels in channels_by_target.items(): + obj = obj_map[target_id] + + # Create a new Action for this object-animation pair + # Name it carefully so debugging is invalid, but NLA track name matters more for GLTF + action_name = f"{anim_name}_{target_id}" + action = bpy.data.actions.new(name=action_name) + + # Ensure object has animation data + if not obj.animation_data: + obj.animation_data_create() + + print(f"Creating Action '{action_name}' for object '{target_id}'") + + # Process channels + for channel in channels: + prop = channel.get("property") + print(f" Processing channel: property={prop}, frames={len(channel.get('frames', []))}") + interpolation = _map_interpolation(channel.get("interpolation", "linear")) + frames = channel.get("frames", []) + + data_path = "" + num_indices = 0 + + if prop == "position": + data_path = "location" + num_indices = 3 + elif prop == "rotation": + data_path = "rotation_quaternion" + num_indices = 4 + elif prop == "scale": + data_path = "scale" + num_indices = 3 + else: + print(f" Warning: Unknown animation property {prop}") + continue + + # Create F-Curves + fcurves = [] + for i in range(num_indices): + fc = action.fcurves.find(data_path, index=i) + if not fc: + fc = action.fcurves.new(data_path, index=i) + fcurves.append(fc) + + # Insert Keyframes + for kf in frames: + time = kf.get("time", 0) + val_v3 = kf.get("value") + val_b = [] + + # Apply Coordinate Transformations + if prop == "position": + # V3 Pos -> Blender Pos + val_b = transform_position_v3_to_blender(val_v3) + elif prop == "rotation": + # V3 Quat -> Blender Quat + val_b = transform_quaternion_v3_to_blender(val_v3) + elif prop == "scale": + # V3 Scale [x, y, z] -> Blender Scale [x, z, -y]? + # Scale is magnitude. Just swap axes. Y(up) becomes Z(up). Z(forward) becomes Y(forward). + # Since scale is unsigned size, we ignore the 'negative' direction of Z->-Y mapping. + # So [sx, sy, sz] -> [sx, sz, sy] + val_b = [val_v3[0], val_v3[2], val_v3[1]] + + # Insert frame data + for i in range(num_indices): + # We use 'FAST' for initial sparse creation + kp = fcurves[i].keyframe_points.insert(time, val_b[i], options={'FAST'}) + kp.interpolation = interpolation + + # 1. Stash the Sparse Action directly to NLA + # We do NOT bake. This preserves the sparse nature of the V3 data. + # "Drive" -> Rotation keys only. "Explode" -> Position keys only. + # This allows runtime composition (blending) in the game engine. + + action.name = anim_name + + track = obj.animation_data.nla_tracks.new() + track.name = anim_name # Final GLTF Name + + start_frame = 0 + strip = track.strips.new(name=anim_name, start=start_frame, action=action) + + # CRITICAL: Clamp the strip to the actual animation length. + # This ensures that short animations don't inherit the scene's global end frame. + strip.frame_end = float(max_anim_frame) + + # Mute to prevent it affecting the viewport or other exports during this session loop + # The GLTF exporter works with NLA tracks even if muted (usually), + # but to be safe and match standard Blender-GLTF workflows where we want distinct clips: + # Muting essentially "stashes" it. + track.mute = True + + # Clear active action so the object is clean for the next channel/animation + obj.animation_data.action = None + + print(f" > Stashed sparse action '{anim_name}' to NLA track.") + + + # CRITICAL: Reset all objects to their V3 bind pose before export. + # To prevent NLA tracks from overriding the bind pose during the "Node" export phase, + # we temporarily disable NLA evaluation on the objects. + # The GLTF exporter's 'NLA_TRACKS' mode should still be able to find and export the tracks + # stored in obj.animation_data.nla_tracks, even if use_nla is False for the scene. + + print("\nPreparing for export: Disabling NLA evaluation and resetting bind pose (CAPTURE & RESTORE)...") + + # 0. RESTORE BIND POSE (Fixes 'Zero Fallacy') + # Instead of blindly zeroing transforms (which flattens pyramids), we restore the + # captured BIND_POSE state associated with the static model structure. + for obj_name, data in BIND_POSE.items(): + if obj_name in bpy.data.objects: + obj = bpy.data.objects[obj_name] + try: + obj.location = data["location"] + obj.rotation_mode = data["rotation_mode"] + obj.rotation_quaternion = data["rotation_quaternion"] + obj.scale = data["scale"] + except Exception as e: + print(f"Warning: Failed to restore bind pose for {obj_name}: {e}") + + # NEW STEP 7 (CRITICAL): UNMUTE ALL TRACKS FOR EXPORT + # We unmute all tracks so the exporter sees them as active. + # This causes blending/crosstalk, but we fix that with the _sanitize_gltf post-processor. + for entity in v3.get("entities", []): + entity_id = entity.get("id") + if entity_id in obj_map: + obj_unmute = obj_map[entity_id] + if obj_unmute.animation_data and obj_unmute.animation_data.nla_tracks: + print(f" Unmuting NLA tracks for {entity_id}...") + for t in obj_unmute.animation_data.nla_tracks: + t.mute = False + t.is_solo = False + + # DEBUG: Inspect Actions before export + print("\n[DEBUG] Inspecting Blender Actions before Export:") + for action in bpy.data.actions: + print(f" Action '{action.name}':") + paths = set() + for fc in action.fcurves: + paths.add(fc.data_path) + for p in paths: + print(f" - {p}") + + # Ensure all manual changes are propagated + bpy.context.view_layer.update() + bpy.ops.export_scene.gltf( filepath=output_path, export_format=export_format, @@ -406,8 +660,20 @@ def blender_entrypoint() -> None: export_apply=False, # Don't apply modifiers export_attributes=True, use_visible=True, - export_yup=True, # Ensure Y-up coordinate system (matches v3 and GLTF) - export_animations=False, # Disable animations to simplify + export_yup=True, + export_animations=True, + + # NOTE: We allow sampling (default) because we are exporting Muted tracks. + # The exporter handles the evaluation. + # export_force_sampling=False, + + # NLA Merging settings + # Force NLA_TRACKS mode to ensure merging by Track Name + export_animation_mode='NLA_TRACKS', + export_nla_strips=True, # Explicitly enable NLA export + export_def_bones=True, # Ensure bones are exported + export_anim_single_armature=False, # We are animating separate objects, not a single armature + use_mesh_edges=False, use_mesh_vertices=True, export_cameras=False, @@ -449,6 +715,11 @@ def _run_blender_export(v3_json_path: str, output_path: str, fmt_arg: str) -> No text=True, check=False, ) + # DEBUG: Always print Blender output to see debug prints + print(f"Blender STDOUT:\n{result.stdout}") + if result.stderr: + print(f"Blender STDERR:\n{result.stderr}") + if result.returncode != 0: logger.error("Blender exporter failed: rc=%s\nSTDOUT:\n%s\nSTDERR:\n%s", result.returncode, result.stdout, result.stderr) raise RuntimeError("Blender V3 export failed") @@ -469,19 +740,337 @@ def _run_blender_export(v3_json_path: str, output_path: str, fmt_arg: str) -> No raise RuntimeError("Blender V3 export produced no output file") -def export_glb(model_json: object) -> bytes: + + +def _sanitize_gltf(output_path: str, v3_json: object) -> None: """ - Export a v3 model (dict or JSON string) to GLB bytes by invoking Blender. + Post-process the GLB to remove unauthored animation channels AND trim time domains. + Fixes "Duration Leak" where short animations inherit long timelines from Blender. """ + try: + from pygltflib import GLTF2, BufferView, Accessor, Buffer + import struct + except ImportError: + print("Warning: pygltflib not found, skipping sanitization.") + return + + # Ensure v3_json is a dict + if isinstance(v3_json, str): + try: + v3_json = json.loads(v3_json) + except Exception: + return + if not isinstance(v3_json, dict): + return + + try: + gltf = GLTF2().load(output_path) + except Exception as e: + print(f"Warning: Failed to load GLB for sanitization: {e}") + return + + # 1. Build Maps: AnimName -> AllowedChannels, AnimName -> Duration + allowed_channels = {} + anim_durations = {} + + for anim in (v3_json.get("animations") or []): + anim_name = anim.get("name") + allowed_channels[anim_name] = set() + max_t = 0.0 + + for ch in anim.get("channels", []): + target_id = ch.get("target_id") + prop = ch.get("property") + path = "translation" if prop == "position" else "rotation" if prop == "rotation" else "scale" + allowed_channels[anim_name].add((target_id, path)) + + # Track duration + frames = ch.get("frames", []) + if frames: + t = frames[-1].get("time", 0.0) + if t > max_t: max_t = t + + if max_t == 0: max_t = 24.0 # Default 1 second (24 frames) + anim_durations[anim_name] = max_t + + # Helper to read accessor data + def read_accessor(acc_idx): + acc = gltf.accessors[acc_idx] + bv = gltf.bufferViews[acc.bufferView] + buf = gltf.buffers[bv.buffer] + + # Safe blob access + blob = gltf.binary_blob + if callable(blob): blob = blob() + + data = gltf.get_data_from_buffer_uri(buf.uri) if buf.uri else (blob or b"") + start = (bv.byteOffset or 0) + (acc.byteOffset or 0) + + # Determine format + comp_count = { + "SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16 + }.get(acc.type, 1) + + # Assume float for animation (5126) + fmt = "<" + ("f" * comp_count) + stride = struct.calcsize(fmt) + + values = [] + for i in range(acc.count): + offset = start + (i * stride) # Note: tightly packed assumption (no bufferView stride) + if bv.byteStride and bv.byteStride > 0: + offset = start + (i * bv.byteStride) + values.append(struct.unpack_from(fmt, data, offset)) + return values + + # Helper to append new data + def append_data(values, type_str): + comp_count = { + "SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4 + }.get(type_str, 1) + fmt = "<" + ("f" * comp_count) + new_bytes = b"".join([struct.pack(fmt, *v) for v in values]) + + # Align to 4 bytes + padding = (4 - (len(new_bytes) % 4)) % 4 + new_bytes += b"\x00" * padding + + # Safe blob access/setup + blob = gltf.binary_blob + if callable(blob): blob = blob() + if blob is None: blob = b"" + + offset = len(blob) + final_blob = blob + new_bytes + + # Hack: pygltflib expects binary_blob to be a callable method in some versions. + # So we overwrite it with a lambda that returns our new data. + gltf.binary_blob = lambda: final_blob + + # Return (offset, length) + return offset, len(new_bytes) + + # 2. Iterate GLTF Animations + cleaned_count = 0 + trimmed_count = 0 + + if gltf.animations: + for gltf_anim in gltf.animations: + if gltf_anim.name not in allowed_channels: + continue + + allowed_set = allowed_channels[gltf_anim.name] + target_duration_frames = anim_durations.get(gltf_anim.name, 24.0) + target_duration_sec = target_duration_frames / 24.0 + + new_channels = [] + + # Filter Channels + for ch in gltf_anim.channels: + if ch.target.node is None: continue + + # Resolve Node Name + node_name = f"node_{ch.target.node}" + if gltf.nodes and ch.target.node < len(gltf.nodes): + if gltf.nodes[ch.target.node].name: + node_name = gltf.nodes[ch.target.node].name + + path = ch.target.path + + if (node_name, path) in allowed_set: + new_channels.append(ch) + + # PROCESS SAMPLER trimming + sampler = gltf_anim.samplers[ch.sampler] + + # Check if already processed (heuristic: if min/max on input matches target?) + # Or simpler: Just re-slice always. + # Optimization: Only slice if max time > target + epsilon + + input_times = read_accessor(sampler.input) # List of (t,) tuples + times_flat = [t[0] for t in input_times] + if not times_flat: continue + + max_t_in = max(times_flat) + + if max_t_in > target_duration_sec + 0.01: # Epsilon + # Need to Trim + # Find cutoff index + cutoff_idx = 0 + for i, t in enumerate(times_flat): + if t <= target_duration_sec + 0.001: + cutoff_idx = i + + # Inclusive slice + slice_len = cutoff_idx + 1 + + # Read Output Values + output_vals = read_accessor(sampler.output) + + # Check compatibility + if len(output_vals) != len(input_times): + print("Warning: Accessor count mismatch, skipping trim.") + continue + + new_times = input_times[:slice_len] + new_outputs = output_vals[:slice_len] + + # Create New Accessors + # 1. Output (Keys) + out_vals_flat = new_outputs # List of tuples + # Look up type from old accessor + old_out_acc = gltf.accessors[sampler.output] + out_offset, out_len = append_data(out_vals_flat, old_out_acc.type) + + # Create BufferView + out_bv = BufferView(buffer=0, byteOffset=out_offset, byteLength=out_len) + gltf.bufferViews.append(out_bv) + out_bv_idx = len(gltf.bufferViews) - 1 + + # Create Accessor + new_out_acc = Accessor( + bufferView=out_bv_idx, + componentType=5126, # FLOAT + count=slice_len, + type=old_out_acc.type + ) + gltf.accessors.append(new_out_acc) + new_out_acc_idx = len(gltf.accessors) - 1 + + # 2. Input (Times) + in_vals_flat = new_times + in_offset, in_len = append_data(in_vals_flat, "SCALAR") + + in_bv = BufferView(buffer=0, byteOffset=in_offset, byteLength=in_len) + gltf.bufferViews.append(in_bv) + in_bv_idx = len(gltf.bufferViews) - 1 + + new_in_acc = Accessor( + bufferView=in_bv_idx, + componentType=5126, # FLOAT + count=slice_len, + type="SCALAR", + min=[new_times[0][0]], + max=[new_times[-1][0]] + ) + gltf.accessors.append(new_in_acc) + new_in_acc_idx = len(gltf.accessors) - 1 + + # Update Sampler + # CRITICAL: We must make a NEW sampler if shared? + # Using 'append' to sampler list? + # To be safe, we perform IN-PLACE update if unique, or copy? + # For simplicity, we update in place. This fixes THIS channel. + # If another channel uses it, it gets trimmed too (correct, if same anim). + # But wait, samplers are per-animation struct. They are NOT cross-animation shared usually. + # (Unless blender reuses them). + # We will assume unique sampler per anim-channel-group for now. + + sampler.input = new_in_acc_idx + sampler.output = new_out_acc_idx + trimmed_count += 1 + + else: + cleaned_count += 1 + + gltf_anim.channels = new_channels + + if cleaned_count > 0 or trimmed_count > 0: + print(f"Sanitized GLTF: Removed {cleaned_count} channels, Trimmed {trimmed_count} samplers.") + gltf.save(output_path) + +def _merge_gltf_single_animation(target_bytes: bytes, source_bytes: bytes) -> bytes: + """ + Merges the animation from source_bytes into target_bytes. + Assumes source_bytes contains ONE animation (and geometry). + We append the source's binary buffer to the target, remap indices, and add the animation. + This creates file size overhead (duplicate geometry in buffer) but ensures total isolation. + """ + try: + from pygltflib import GLTF2 + except ImportError: + # If pygltflib is missing, we can't merge. Just return target. + # Ideally this should log a warning. + return target_bytes + + target = GLTF2.load_from_bytes(target_bytes) + source = GLTF2.load_from_bytes(source_bytes) + + if not source.animations: + return target_bytes + + # 1. Prepare Binary Blob Concatenation + t_blob = target.binary_blob() or b"" + s_blob = source.binary_blob() or b"" + + # Calculate offset for source bufferViews + blob_offset = len(t_blob) + + # Concatenate blobs + target_blob_final = t_blob + s_blob + target.set_binary_blob(target_blob_final) + + # 2. Remap and Append BufferViews + bv_map = {} + if not target.buffers: # Should exist for GLB + target.buffers.append(source.buffers[0]) + + base_bv_idx = len(target.bufferViews) + base_acc_idx = len(target.accessors) + + for i, bv in enumerate(source.bufferViews): + bv.byteOffset = (bv.byteOffset or 0) + blob_offset + target.bufferViews.append(bv) + bv_map[i] = base_bv_idx + i + + # 3. Remap and Append Accessors + acc_map = {} + for i, acc in enumerate(source.accessors): + if acc.bufferView is not None: + acc.bufferView = bv_map[acc.bufferView] + target.accessors.append(acc) + acc_map[i] = base_acc_idx + i + + # 4. Remap and Append Animation + src_anim = source.animations[0] + + for sampler in src_anim.samplers: + if sampler.input is not None: + sampler.input = acc_map[sampler.input] + if sampler.output is not None: + sampler.output = acc_map[sampler.output] + + # No node remapping needed (Assumption: Identical Hierarchy) + target.animations.append(src_anim) + + result = target.save_to_bytes() + if isinstance(result, list): + # Join list of bytes segments + return b"".join(result) + return result + + +def _export_glb_single_pass(model_json: object, output_path: str = None) -> bytes: + """ + Internal function: Runs a single blender export pass. + Renamed from original export_glb to support multi-pass strategy. + """ + # Create temp dir tmp_dir = tempfile.mkdtemp(prefix="v3_export_") input_path = os.path.join(tmp_dir, "model_v3.json") + # Create a unique temp file path for Blender to write into - fd, output_path = tempfile.mkstemp(suffix=".glb", dir=tmp_dir) + # If output_path is provided, we still use a temp one for blender (then move/read) + # or just use it directly? Original logic used temp file inside tmp_dir. + # Let's stick to original logic: write to temp, then read. + fd, temp_output_path = tempfile.mkstemp(suffix=".glb", dir=tmp_dir) os.close(fd) + try: - os.remove(output_path) # Ensure Blender can create it fresh + os.remove(temp_output_path) except FileNotFoundError: pass + try: if isinstance(model_json, dict): with open(input_path, "w") as f: @@ -492,10 +1081,14 @@ def export_glb(model_json: object) -> bytes: else: raise ValueError(f"Unsupported model_json type: {type(model_json)}") - written_path = _run_blender_export(input_path, output_path, "glb") or output_path + written_path = _run_blender_export(input_path, temp_output_path, "glb") or temp_output_path + + # Post-process to remove crosstalk (still need sanitization per-pass) + _sanitize_gltf(written_path, model_json) with open(written_path, "rb") as f: data = f.read() + return data finally: if os.environ.get("KEEP_V3_GLTF_TMP") != "1": @@ -505,6 +1098,54 @@ def export_glb(model_json: object) -> bytes: pass +def export_glb(model_json: object) -> bytes: + """ + Orchestrates the export process. + If multiple animations are present, it performs a "Clean Room" Multi-Pass Export + and merges them at the GLTF level using pygltflib. + """ + if isinstance(model_json, str): + try: + model_data = json.loads(model_json) + except: + # Fallback if valid JSON string but we need dict for logic + return _export_glb_single_pass(model_json) + else: + model_data = model_json + + animations = model_data.get('animations') or [] + + # Case A: 0 or 1 Animation -> Single Pass + if len(animations) <= 1: + return _export_glb_single_pass(model_data) + + print(f"[Multi-Pass Export] Detected {len(animations)} animations. Using Clean Room Merge.") + + # Case B: Multi-Pass + # 1. Export Master (Geometry + Anim 0) + master_scope = json.loads(json.dumps(model_data)) + master_scope['animations'] = [animations[0]] if animations else [] + + print(f"[Pass 0] Exporting Master Base...") + master_bytes = _export_glb_single_pass(master_scope) + + # 2. Loop & Merge + for i in range(1, len(animations)): + anim = animations[i] + anim_name = anim.get('name', f'anim_{i}') + print(f"[Pass {i}] Exporting Isolated ({anim_name})...") + + pass_scope = json.loads(json.dumps(model_data)) + pass_scope['animations'] = [anim] + + source_bytes = _export_glb_single_pass(pass_scope) + + print(f" Merging {anim_name} into Master...") + master_bytes = _merge_gltf_single_animation(master_bytes, source_bytes) + + return master_bytes + + def export_gltf(model_json: object) -> str: """ Export a v3 model (dict or JSON string) to GLTF (embedded) text by invoking Blender. diff --git a/blocksmith/converters/python/exporter.py b/blocksmith/converters/python/exporter.py index ff9ac81..297913d 100644 --- a/blocksmith/converters/python/exporter.py +++ b/blocksmith/converters/python/exporter.py @@ -11,8 +11,8 @@ import json # Import centralized rotation utilities -from blocksmith.converters.rotation_utils import quaternion_to_euler, is_gimbal_lock -from blocksmith.schema.blockjson import ModelDefinition, CuboidEntity, GroupEntity +from ..rotation_utils import quaternion_to_euler, is_gimbal_lock +from blocksmith.schema.blockjson import ModelDefinition, CuboidEntity, GroupEntity, Animation, Channel logger = logging.getLogger(__name__) @@ -69,10 +69,13 @@ def convert(self, schema_data: Dict[str, Any]) -> str: self._build_entity_map(model.entities) # Generate code - self._generate_header() + self._generate_header(model.meta.fps) self._generate_entities() self._generate_footer() + if model.animations: + self._generate_animations(model.animations, model.meta.fps) + return '\n'.join(self.code_lines) def _build_entity_map(self, entities: List[Any]) -> None: @@ -82,7 +85,7 @@ def _build_entity_map(self, entities: List[Any]) -> None: if entity.parent is None: self.root_entities.append(entity.id) - def _generate_header(self) -> None: + def _generate_header(self, fps: int = 24) -> None: """Generate Python code header.""" self.code_lines.extend([ '"""', @@ -90,6 +93,7 @@ def _generate_header(self) -> None: 'Uses XYZ Euler order for consistent, LLM-friendly rotations', '"""', '', + f'TICKS_PER_SEC = {fps}', 'UNIT = 0.0625', '', 'def create_model():', @@ -231,6 +235,69 @@ def _generate_group(self, entity: GroupEntity, indent: int) -> None: self.code_lines.append(line) self.code_lines.append(f'{spaces}),') + def _generate_animations(self, animations: List[Animation], fps: int = 24) -> None: + """Generate create_animations() function.""" + if not animations: + return + + self.code_lines.extend([ + '', + 'def create_animations():', + ' animations = []' + ]) + + for anim in animations: + channel_vars = [] + + for i, ch in enumerate(anim.channels): + # Generate keyframes + kf_str = [] + for frame in ch.frames: + # Convert ticks to seconds + time_sec = frame['time'] / fps + + value = frame['value'] + val_str = "" + + if ch.property == 'rotation': + # Convert quaternion to euler + euler = quaternion_to_euler(value) + val_str = format_list(euler) + else: + val_str = format_list(value) + + kf_str.append(f"({format_number(time_sec)}, {val_str})") + + # Create channel + var_name = f"ch_{i}" + channel_vars.append(var_name) + + self.code_lines.extend([ + f' {var_name} = channel(', + f' "{ch.target_id}", "{ch.property}",', + f' [{", ".join(kf_str)}],', + f' interpolation="{ch.interpolation}"', + f' )' + ]) + + # Create animation + ch_list = f"[{', '.join(channel_vars)}]" + + # Calculate duration in seconds + dur_sec = anim.duration / fps + + self.code_lines.extend([ + f' animations.append(animation(', + f' "{anim.name}",', + f' description="{anim.name}",', # description is not in model, maybe name? + f' duration={format_number(dur_sec)},', + f' loop="{anim.loop_mode}",', + f' channels={ch_list}', + f' ))', + '' + ]) + + self.code_lines.append(' return animations') def export_python( v3_data: Dict[str, Any], diff --git a/blocksmith/converters/python/importer.py b/blocksmith/converters/python/importer.py index 0b2a8a7..246afdf 100644 --- a/blocksmith/converters/python/importer.py +++ b/blocksmith/converters/python/importer.py @@ -18,8 +18,9 @@ from pydantic import ValidationError # Import centralized rotation utilities and models -from blocksmith.converters.rotation_utils import euler_to_quaternion -from blocksmith.schema.blockjson import Entity, CuboidEntity, GroupEntity, MetaModel, ModelDefinition +# Import centralized rotation utilities and models +# from blocksmith.converters.rotation_utils import euler_to_quaternion # Moved to inner scope +from blocksmith.schema.blockjson import Entity, CuboidEntity, GroupEntity, MetaModel, ModelDefinition, Animation, Channel logger = logging.getLogger(__name__) @@ -53,6 +54,9 @@ class PythonExecutor: """Executes Python code safely and extracts entities.""" def __init__(self): + # Local import to prevent circular dependency + from blocksmith.converters.rotation_utils import euler_to_quaternion + self.safe_importer = SafeImporter() # Helper functions for entity creation @@ -145,9 +149,84 @@ def group(id, position=None, **kwargs): 'scale': scale, } + def animation(name, duration, channels, loop_mode='repeat', **kwargs): + """Create an animation definition.""" + return { + 'name': name, + 'duration': duration, + 'channels': channels, + 'loop_mode': loop_mode + } + + def channel(target_id, property, frames, interpolation='linear', metadata=None, **kwargs): + """Create an animation channel.""" + # Handle frames input: Dict[int, val] or List[Dict] + processed_frames = [] + + if isinstance(frames, dict): + # Convert {0: val, 10: val} to [{'time': 0, 'value': val}, ...] + for t, v in frames.items(): + # Check for rotation conversion + if property == 'rotation': + # If value is length 3, assume Euler and convert to Quat + if isinstance(v, (list, tuple)) and len(v) == 3: + v = euler_to_quaternion(v) + + processed_frames.append({'time': int(t), 'value': v}) + elif isinstance(frames, list): + # Handle list of tuples [(time, val), ...] OR list of dicts + for f in frames: + time = 0 + val = None + + if isinstance(f, (list, tuple)) and len(f) == 2: + # Tuple format + time = f[0] + val = f[1] + elif isinstance(f, dict): + # Dict format + time = f.get('time') + val = f.get('value') + else: + # Skip unknown formats for now or raise + continue + + # Process Value (Euler -> Quat) + if property == 'rotation': + if isinstance(val, (list, tuple)) and len(val) == 3: + val = euler_to_quaternion(val) + + # Store as integer ticks (assuming input is ticks for now as per schema, + # OR handle seconds conversion if we want to be fancy. + # The prompt says input is seconds, but internal schema is ticks. + # Wait, prompt says: "Time: Float seconds". + # But the schema/importer usually expects ticks. + # The previous 'importer.py' handled TICKS_PER_SEC conversion. + # This one from ANIMATION_CONTEXT seems to lack it? + # Let's check 'TICKS_PER_SEC' in globals. Yes line 220. + # So we should convert seconds -> ticks here! + + # Convert seconds to ticks + TICKS_PER_SEC = self.safe_globals.get('TICKS_PER_SEC', 24) + time_ticks = int(round(time * TICKS_PER_SEC)) + + processed_frames.append({'time': time_ticks, 'value': val}) + else: + raise ValueError("frames must be a dict {time: value} or list of dicts") + + return { + 'target_id': target_id, # matching schema + 'property': property, + 'frames': processed_frames, + 'interpolation': interpolation, + 'metadata': metadata + } + # Store functions as instance variables so they can be referenced in safe_globals self.cuboid = cuboid self.group = group + self.animation = animation + self.channel = channel # Create safe execution environment self.safe_globals = { @@ -166,7 +245,13 @@ def group(id, position=None, **kwargs): # Helper functions 'cuboid': self.cuboid, 'group': self.group, + 'animation': self.animation, + 'channel': self.channel, + # Schema shortcuts + 'Animation': self.animation, + 'Channel': self.channel, 'UNIT': 1.0 / 16, + 'TICKS_PER_SEC': 24, # Pre-imported common modules 'math': math, 'random': random, @@ -216,6 +301,33 @@ def execute_python_code(self, code: str) -> List[Dict[str, Any]]: except Exception as e: logger.error(f"Error executing Python code: {e}") logger.error("Traceback:", exc_info=True) + def execute_python_code_for_animations(self, code: str) -> List[Dict[str, Any]]: + """Execute Python code and extract animations.""" + try: + local_namespace = {} + exec(code, self.safe_globals, local_namespace) + + # Look for animations + animations = None + + # Method 1: 'create_animations()' function + if 'create_animations' in local_namespace: + animations = local_namespace['create_animations']() + # Method 2: 'animations' variable + elif 'animations' in local_namespace: + animations = local_namespace['animations'] + + if animations is None: + raise ValueError("No animations found. Code should define 'create_animations()' or 'animations'") + + if not isinstance(animations, list): + raise ValueError(f"Animations return value must be a list, got {type(animations)}") + + return animations + + except Exception as e: + logger.error(f"Error executing Animation Python code: {e}") + logger.error("Traceback:", exc_info=True) raise @@ -321,3 +433,31 @@ def import_python_from_file( raise Exception("Error generating v3 schema from Python code") return model_json +def import_animation_only(python_code: str) -> List[Dict[str, Any]]: + """ + Import Python code containing only animation definitions. + Returns list of Animation dictionaries (schema-ready). + """ + try: + executor = PythonExecutor() + anim_dicts = executor.execute_python_code_for_animations(python_code) + + valid_anims = [] + for ad in anim_dicts: + # Validate against schema + # But wait, channels are nested dicts. + # Channel(**dict) handles nested? No, Channel frames are List[Dict], helper produced that. + # Channel(target_id=..., property=..., frames=[...]) + # Animation(channels=[Channel(...)]) + # The helper returns dicts, not Pydantic objects. + # So we need to convert nested channel dicts to Channel objects? + # Or just let Pydantic model_validate handle the nested dict structure? + # Pydantic handles nested dicts fine! + + anim = Animation.model_validate(ad) + valid_anims.append(anim.model_dump(exclude_none=False)) + + return valid_anims + except Exception as e: + logger.error(f"Error importing animation code: {e}") + raise diff --git a/blocksmith/generator/SYSTEM_PROMPT.md b/blocksmith/generator/SYSTEM_PROMPT.md index 6416bd5..bccb4c9 100644 --- a/blocksmith/generator/SYSTEM_PROMPT.md +++ b/blocksmith/generator/SYSTEM_PROMPT.md @@ -37,16 +37,65 @@ These helpers are **pre-defined**. Just call them - **DO NOT redefine or copy th **`group(id, pivot, **kwargs)`** * `id`: Unique string ID (also used as label) -* `pivot`: `[x, y, z]` - the rotation/anchor point in world coords +* `pivot`: `[x, y, z]` - the rotation/anchor point in world coords. **CRITICAL:** Rotations happen around this point. To rotate around the center, set this to the center of the object. * Optional kwargs: `parent="grp_id"`, `rotation=[x,y,z]` -**Example usage:** -```python group("grp_head", [0, 1.5, 0], parent="grp_body", rotation=[10, 0, 0]) cuboid("geo_head", [-0.25, 0, -0.25], [0.5, 0.5, 0.5], parent="grp_head", material="mat-skin") ``` -## 4. Core Principles +## 4. The Animation API (Declarative) +If an animation is requested, define `create_animations()` alongside `create_model()`. + +**`animation(name, duration, loop, channels)`** +* `name`: Animation identifier (e.g. "walk") +* `duration`: Length in seconds (float) +* `loop`: `'once'`, `'repeat'`, `'pingpong'` +* `channels`: List of channels + +**`channel(target_id, property, kf, interpolation)`** +* `target_id`: ID of the Group or Cuboid to animate. +* `property`: `'position'`, `'rotation'`, or `'scale'`. +* `interpolation`: `'linear'`, `'step'`, or `'cubic'`. +* `kf`: List of keyframes `[(time_sec, [x,y,z]), ...]`. + * **Time**: Float seconds (e.g. `0.0`, `0.5`, `1.5`) + * **Value**: + * For Position/Scale: `[x, y, z]` + * For Rotation: **Euler Angles `[x, y, z]` (Degrees)**. Do not uses Quaternions manually. + +**Rotation Rules:** +1. **Pivots:** Animations rotate around the `pivot` defined in `group()`. + * To spin a cube around its center, the `pivot` MUST be at the center of the cube (e.g. `[0, 0.5, 0]`), NOT the bottom corner. +2. **Forward:** -Z is Forward (North). + * **Positive Pitch (+X Rotation):** Tilts the front (-Z) **UP**. + * **Positive Yaw (+Y Rotation):** Turns the front (-Z) to the **LEFT** (-X). + * **Positive Roll (+Z Rotation):** Tilts the **RIGHT** (+X) side **DOWN**. + +**Rules:** +1. **Always animate Groups (`grp_*`)** for proper pivots. Avoid animating raw geometry. +2. **Start at 0.0s** with the Bind Pose values (from `create_model`). +3. **Use TICKS_PER_SEC** constant (default 24) if you need precise frame logic, but Seconds are preferred. + +**Example usage:** +```python +def create_animations(): + anims = [] + + # 1. Walk Cycle (1 second) + ch_leg_l = channel("grp_leg_l", "rotation", kf=[ + (0.0, [0, 0, 0]), # Rest + (0.25, [30, 0, 0]), # Forward + (0.75, [-30, 0, 0]),# Back + (1.0, [0, 0, 0]) # Rest + ], interpolation="cubic") + + anims.append(animation("walk", duration=1.0, loop="repeat", channels=[ch_leg_l])) + + return anims +``` + + +## 5. Core Principles * **BLOCKY, NOT VOXEL:** Build models with **large, distinct blocks** like Minecraft. Do NOT try to approximate smooth curves with many tiny cubes. @@ -88,7 +137,7 @@ Certain details MUST be separate 2D plane cuboids so they can be textured precis -## 5. Few-Shot Examples +## 6. Few-Shot Examples ### Example 1: Tripod Camera (North/-Z Facing) *Demonstrates: Correct North orientation, "Apex Pivot" rotation, and complex angles.* @@ -137,6 +186,20 @@ def create_model(): cuboid("geo_flash_pan", [0.3125, 0.5, -0.125], [0.125, 0.25, 0.1875], parent="grp_cam_pivot", material="mat-metal") ] +def create_animations(): + # Animate the camera panning left/right + anims = [] + + # Rotate Y axis from -45 to 45 + ch_pan = channel("grp_root", "rotation", kf=[ + (0.0, [0, 0, 0]), + (1.0, [0, 45, 0]), + (2.0, [0, -45, 0]), + (4.0, [0, 0, 0]) + ], interpolation="cubic") + + anims.append(animation("scan", duration=4.0, loop="pingpong", channels=[ch_pan])) + return anims ``` ### Example 2: Simple Sword (Hand-Held Origin) @@ -205,11 +268,41 @@ def create_model(): group("grp_arm_l", [-0.375, 0.5, 0], parent="grp_body"), cuboid("geo_arm_l", [-0.125, -0.5, -0.125], [0.25, 0.625, 0.25], parent="grp_arm_l", material="mat-skin"), - # Right Arm (+X) - group("grp_arm_r", [0.375, 0.5, 0], parent="grp_body"), - cuboid("geo_arm_r", [-0.125, -0.5, -0.125], [0.25, 0.625, 0.25], parent="grp_arm_r", material="mat-skin") ] +def create_animations(): + # Standard Zombie Walk + anims = [] + + # Arms: Zombie arms raised (Hold pose) + slight bob + # Start at [90, 0, 0] (Arms up) + ch_arms = [] + for side in ["l", "r"]: + ch_arms.append(channel(f"grp_arm_{side}", "rotation", kf=[ + (0.0, [90, 0, 0]), + (1.0, [95, 0, 0]), # Bob down slightly + (2.0, [90, 0, 0]) + ], interpolation="cubic")) + + # Legs: Slow shuffle + ch_legs = [] + # Left Forward + ch_legs.append(channel("grp_leg_l", "rotation", kf=[ + (0.0, [0, 0, 0]), + (0.5, [15, 0, 0]), + (1.5, [-15, 0, 0]), + (2.0, [0, 0, 0]) + ], interpolation="linear")) + # Right Backward + ch_legs.append(channel("grp_leg_r", "rotation", kf=[ + (0.0, [0, 0, 0]), + (0.5, [-15, 0, 0]), # Inverse of left + (1.5, [15, 0, 0]), + (2.0, [0, 0, 0]) + ], interpolation="linear")) + + anims.append(animation("shamble", duration=2.0, loop="repeat", channels=ch_arms + ch_legs)) + return anims ``` ### Example 4: Horse (Quadruped with Side-Facing Eyes) diff --git a/blocksmith/generator/engine.py b/blocksmith/generator/engine.py index feec749..30d5642 100644 --- a/blocksmith/generator/engine.py +++ b/blocksmith/generator/engine.py @@ -75,7 +75,7 @@ def _extract_code(self, response_text: str) -> str: # If no code blocks, assume entire response is code return response_text.strip() - def generate(self, prompt: str, model: Optional[str] = None, image: Optional[str] = None) -> GenerationResponse: + def generate(self, prompt: str, model: Optional[str] = None, image: Optional[str] = None, system_prompt: Optional[str] = None) -> GenerationResponse: """ Generate Python DSL code from a text prompt with optional image. @@ -114,7 +114,7 @@ def generate(self, prompt: str, model: Optional[str] = None, image: Optional[str messages = [ { "role": "system", - "content": SYSTEM_PROMPT + "content": system_prompt or SYSTEM_PROMPT }, { "role": "user", diff --git a/blocksmith/generator/prompts.py b/blocksmith/generator/prompts.py index bef6208..bd91095 100644 --- a/blocksmith/generator/prompts.py +++ b/blocksmith/generator/prompts.py @@ -7,3 +7,56 @@ _prompt_path = Path(__file__).parent / "SYSTEM_PROMPT.md" with open(_prompt_path, 'r') as f: SYSTEM_PROMPT = f.read() + +ANIMATION_SYSTEM_PROMPT = """ +# BlockSmith Animation Generator +You are an expert 3D animator for block-based models. +Your goal is to generate Python code that defines animations for a specific 3D model. + +## Input +1. **User Prompt:** Description of the animation (e.g., "walk cycle", "wave hand"). +2. **Model Structure:** The existing Python code defining the model's geometry and Group IDs. + +## Output +* **Raw Python Code ONLY.** +* No preamble, no markdown blocks. +* Define a single function: `def create_animations():` +* Do NOT import anything. + +## Critical Rules +1. **Target Existing IDs:** You MUST use the exact `id` strings found in the provided "Model Structure". + * If the model has `group("grp_arm_l", ...)`, you must animate `"grp_arm_l"`. + * Do NOT invent new IDs. if an ID is missing, try to guess the most likely equivalent from the provided code. +2. **Animate Groups, Not Geometry:** Always target the parent `group()` nodes (e.g., `grp_leg_l`) rather than the `cuboid()` geometry. This ensures proper pivoting. +3. **Coordinate System (Y-Up, -Z Forward):** + * **Forward:** -Z (North) + * **Up:** +Y + * **Right:** +X + * **Rotations (Euler Angles in Degrees):** + * `[x, 0, 0]` : Pitch (Positive = Tilt Up/Back) + * `[0, y, 0]` : Yaw (Positive = Turn Left) + * `[0, 0, z]` : Roll (Positive = Tilt Right Down) +4. **Format:** + * Use `channel(target_id, property, kf, interpolation)` + * `property` should be `"rotation"` (most common) or `"position"`. + * `kf` is a list of tuples: `[(time_sec, [x, y, z]), ...]`. + * `interpolation`: `"linear"` (robotic), `"cubic"` (smooth), `"step"` (instant). + +## Example Output +```python +def create_animations(): + # Walk Cycle + kf_leg = [ + (0.0, [0, 0, 0]), + (0.5, [45, 0, 0]), + (1.0, [0, 0, 0]) + ] + + # Note: "grp_leg_l" comes from the provided model structure + ch_leg = channel("grp_leg_l", "rotation", kf_leg, interpolation="linear") + + return [ + animation("walk", duration=1.0, loop="repeat", channels=[ch_leg]) + ] +``` +""" diff --git a/blocksmith/schema/blockjson.py b/blocksmith/schema/blockjson.py index fb55480..07e6c87 100644 --- a/blocksmith/schema/blockjson.py +++ b/blocksmith/schema/blockjson.py @@ -222,7 +222,7 @@ class Animation(BaseModel): A single animation clip containing channels. """ name: str = Field(..., description="Animation name (e.g., 'walk').") - duration: int = Field(..., description="Total duration in ticks/frames.") + duration: float = Field(..., description="Total duration in seconds (or ticks).") loop_mode: Optional[Literal['once', 'repeat', 'pingpong']] = Field('repeat', description="Playback mode.") channels: List[Channel] = Field(..., description="Channels in this animation.") @@ -234,6 +234,7 @@ class MetaModel(BaseModel): model_config = ConfigDict(extra='forbid') schema_version: str = Field('3.0', description="Schema version.") + fps: int = Field(24, description="Animation ticks per second.") texel_density: int = Field(16, description="Pixels per unit for scaling.") atlases: Dict[str, AtlasDefinition] = Field(..., description="Embedded atlases (at least one, e.g., 'main').") import_source: Optional[Literal['bbmodel', 'gltf', 'bedrock']] = Field(None, description="Source for round-trips.") From 4c38ae3dc2aacc3cf3cf30c8d9c6fcf1f51ef521 Mon Sep 17 00:00:00 2001 From: Gabe Busto Date: Wed, 7 Jan 2026 10:57:51 -0500 Subject: [PATCH 2/6] fix(animation): sync system prompt with upstream context, revert validation to int, update importer for legacy support --- blocksmith/converters/python/importer.py | 17 +- blocksmith/generator/prompts.py | 260 +++++++++++++++++++---- blocksmith/schema/blockjson.py | 2 +- 3 files changed, 233 insertions(+), 46 deletions(-) diff --git a/blocksmith/converters/python/importer.py b/blocksmith/converters/python/importer.py index 246afdf..1d379f6 100644 --- a/blocksmith/converters/python/importer.py +++ b/blocksmith/converters/python/importer.py @@ -158,8 +158,16 @@ def animation(name, duration, channels, loop_mode='repeat', **kwargs): 'loop_mode': loop_mode } - def channel(target_id, property, frames, interpolation='linear', metadata=None, **kwargs): - """Create an animation channel.""" + def channel(target_id, property, frames=None, kf=None, interpolation='linear', metadata=None, **kwargs): + """ + Create an animation channel. + Args: + frames: List of frames (preferred) + kf: Alias for frames (used in some prompts) + """ + # Handle alias + if frames is None and kf is not None: + frames = kf # Handle frames input: Dict[int, val] or List[Dict] processed_frames = [] @@ -208,7 +216,10 @@ def channel(target_id, property, frames, interpolation='linear', metadata=None, # Convert seconds to ticks TICKS_PER_SEC = self.safe_globals.get('TICKS_PER_SEC', 24) - time_ticks = int(round(time * TICKS_PER_SEC)) + if isinstance(time, float): + time_ticks = int(round(time * TICKS_PER_SEC)) + else: + time_ticks = int(time) processed_frames.append({'time': time_ticks, 'value': val}) else: diff --git a/blocksmith/generator/prompts.py b/blocksmith/generator/prompts.py index bd91095..d189375 100644 --- a/blocksmith/generator/prompts.py +++ b/blocksmith/generator/prompts.py @@ -10,53 +10,229 @@ ANIMATION_SYSTEM_PROMPT = """ # BlockSmith Animation Generator -You are an expert 3D animator for block-based models. -Your goal is to generate Python code that defines animations for a specific 3D model. - -## Input -1. **User Prompt:** Description of the animation (e.g., "walk cycle", "wave hand"). -2. **Model Structure:** The existing Python code defining the model's geometry and Group IDs. - -## Output -* **Raw Python Code ONLY.** -* No preamble, no markdown blocks. -* Define a single function: `def create_animations():` -* Do NOT import anything. - -## Critical Rules -1. **Target Existing IDs:** You MUST use the exact `id` strings found in the provided "Model Structure". - * If the model has `group("grp_arm_l", ...)`, you must animate `"grp_arm_l"`. - * Do NOT invent new IDs. if an ID is missing, try to guess the most likely equivalent from the provided code. -2. **Animate Groups, Not Geometry:** Always target the parent `group()` nodes (e.g., `grp_leg_l`) rather than the `cuboid()` geometry. This ensures proper pivoting. -3. **Coordinate System (Y-Up, -Z Forward):** - * **Forward:** -Z (North) - * **Up:** +Y - * **Right:** +X - * **Rotations (Euler Angles in Degrees):** - * `[x, 0, 0]` : Pitch (Positive = Tilt Up/Back) - * `[0, y, 0]` : Yaw (Positive = Turn Left) - * `[0, 0, z]` : Roll (Positive = Tilt Right Down) -4. **Format:** - * Use `channel(target_id, property, kf, interpolation)` - * `property` should be `"rotation"` (most common) or `"position"`. - * `kf` is a list of tuples: `[(time_sec, [x, y, z]), ...]`. - * `interpolation`: `"linear"` (robotic), `"cubic"` (smooth), `"step"` (instant). - -## Example Output +You are an expert 3D animation engineer for a block-based modeling tool (Blockbench style). +Your goal is to generate Python DSL code that defines animation tracks for a provided 3D model hierarchy. + +## The Model +You will be given the "Entities DSL" which defines the structure of the model (cuboids and groups). +Pay attention to the structure, especially **Group hierarchies**, as you will need to reference `target_id`s. +- **Pivots**: Animations rotate around the entity's pivot point. If animating a leg, ensure the target entity (usually a Group) has its pivot at the joint connection (e.g., hip/shoulder). +- **Hierarchy**: Animate Groups (`grp_*`) whenever possible, rather than raw Cuboids, to ensure proper hierarchical movement. + +## The DSL Format +You must output a single Python function `create_animations()` that returns a list of `Animation` objects. + +### Helper Functions Available +You have access to the following helper functions and classes: +- `Animation(name, duration, loop_mode, channels)` +- `Channel(target_id, property, interpolation, frames)` +- `TICKS_PER_SEC = 24` (Always use this constant for time calculations) +- `euler_to_quat(x, y, z)`: **REQUIRED** for all rotations. + +### Critical Syntax Rules +1. **Time**: Must be an **INTEGER** in ticks. Use `int(seconds * TICKS_PER_SEC)`. +2. **Keyframes**: Must be a **LIST of DICTIONARIES**: `[{'time': t, 'value': v}, ...]`. +3. **Rotations**: Must be **Quaternions** `[w, x, y, z]`. Use the helper: `value=euler_to_quat(x, y, z)`. +4. **Looping**: Ensure the last keyframe matches the first for smooth loops. +5. **Clean Code**: Do NOT import any external modules. Use standard Python math if needed (`math.sin`, etc., are available). +6. **Interpolation**: Must be one of: `"linear"`, `"step"`, `"cubic"`. Do NOT use "catmullrom" or others. +7. **Syntax Safety**: Ensure all parentheses `()` and brackets `[]` are closed. Avoid breaking lines in the middle of function calls if possible, or use explicit line continuation carefully. +8. **Simplicity**: Prefer clear, readable code over complex one-liners. +9. **Limits**: Maximum **128 keyframes** per channel. Do not exceed this. +10. **Types**: Ensure `time` is always an `int` (use `int()`), and values are lists of floats. +11. **STARTING POSITIONS (CRITICAL)**: You **MUST** strictly copy the `pivot` value from the Entity DSL as your Frame 0 value for `position` channels. + - **CORRECT**: `{'time': 0, 'value': [0.0, 0.375, 0.0625]}` (If the DSL says `pivot: [0.0, 0.375, 0.0625]`). + - **ALWAYS** check the `pivot` field of the target entity before animating position. +12. **Rest Pose Constraint**: Unless the user specifically asks for a "one-shot" or "transition" animation, your animation **MUST begin and end at the object's Bind Pose (rest position)**. For example, if parts explode outward, they must return to their original positions by the last frame to ensure a seamless loop. +13. **Loop Mode Values**: The `loop_mode` argument in `Animation()` **MUST** be one of: `'once'`, `'repeat'`, or `'pingpong'`. Do **NOT** use `'loop'`, `'cycle'`, or defaults. +14. **Shared Language & Coordinate System**: + - **World Up**: +Y Axis. (Gravity pulls down along -Y). + - **Forward**: +Z Axis. (Characters face +Z). + - **Right**: +X Axis. + - **Pivot Points**: All rotations occur around the object's `pivot` defined in the Entity DSL. A "Center" rotation means rotating around this pivot. + - **"Reset"**: Returning to the values defined in the Entity DSL (Bind Pose). +15. **ISOLATION (CRITICAL)**: You are generating a SINGLE, ISOLATED animation action. Do NOT include channels for parts that are not explicitly involved in this specific movement. Do NOT assume other animations are playing or that you need to merge with previous states. Output ONLY the channels required for the requested motion. + +### Example Pattern +```python +def create_animations(): + # Helper for rotations (YOU MUST INCLUDE THIS IN YOUR CODE IF YOU USE IT) + def euler_to_quat(x_deg, y_deg, z_deg): + # ... (standard implementation provided below in examples) ... + pass + + anims = [] + + # Example: Simple Rotation + rot_channel = Channel( + target_id="grp_propeller", + property="rotation", + interpolation="linear", + frames=[ + {'time': 0, 'value': euler_to_quat(0, 0, 0)}, + {'time': int(1.0 * TICKS_PER_SEC), 'value': euler_to_quat(0, 360, 0)}, + ] + ) + anims.append(Animation(name="spin", duration=int(1.0 * TICKS_PER_SEC), loop_mode="repeat", channels=[rot_channel])) + + return anims +``` + +## Best Practices +1. **Modular Animations**: + - Keep animations focused. A "Run" animation should just be the run cycle. + - For complex vehicles, you can animate multiple parts (e.g., all 4 wheels) in one "Drive" animation unless requested otherwise. + - **NO EXTRA ANIMATIONS**: Do NOT generate an "Idle", "Rest", or "T-Pose" animation unless the user specifically asks for it. Only generate the animation described in the prompt. +2. **Oscillation**: + - For **walking/swinging limbs**: Cycle `0 -> Angle -> -Angle -> 0`. + - For **flaps/extensions**: Cycle `Rest -> Extended -> Rest`. Do NOT overextend into the body. +3. **Clearance**: + - Ensure moving parts (like a pump shotgun slide) do not clip into other geometry. Calculate positions carefully based on object size. +4. **Pivots**: + - If a part rotates weirdly (e.g., around its center instead of a joint), it likely lacks a Group with a proper pivot. Ideally, request a model update, but for now, animate what you have. + +--- + +## Few-Shot Examples + +### 1. Shotgun Pump Action (Linear Translation) +*Scenario: A pump shotgun. The pump (`grp_pump`) slides back along Z to eject a shell, then returns forward.* +```python +def create_animations(): + # Helper required for any rotations (even if unused in this specific anim, good practice) + import math + def euler_to_quat(x_deg, y_deg, z_deg): + cx = math.cos(math.radians(x_deg) * 0.5); sx = math.sin(math.radians(x_deg) * 0.5) + cy = math.cos(math.radians(y_deg) * 0.5); sy = math.sin(math.radians(y_deg) * 0.5) + cz = math.cos(math.radians(z_deg) * 0.5); sz = math.sin(math.radians(z_deg) * 0.5) + return [cx * cy * cz + sx * sy * sz, sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz, cx * cy * sz - sx * sy * cz] + + # Pump slides back 0.75 units (Z axis) + pump_channel = Channel( + target_id="grp_pump", + property="position", + interpolation="linear", + frames=[ + {'time': 0, 'value': [0, 0.125, -1.0]}, # Rest Position + {'time': int(0.3 * TICKS_PER_SEC), 'value': [0, 0.125, -0.25]}, # Slide Back (Rest + 0.75) + {'time': int(0.4 * TICKS_PER_SEC), 'value': [0, 0.125, -0.25]}, # Hold + {'time': int(0.6 * TICKS_PER_SEC), 'value': [0, 0.125, -1.0]}, # Return + {'time': int(1.0 * TICKS_PER_SEC), 'value': [0, 0.125, -1.0]}, # End at Rest for seamless loop/transition + ] + ) + return [Animation(name="pump_action", duration=int(1.0 * TICKS_PER_SEC), loop_mode="once", channels=[pump_channel])] +``` + +### 2. Puppy Walk Cycle (Quadruped Limb Rotation) +*Scenario: A quadruped. Legs need to swing. Diagonal pairs move together.* ```python def create_animations(): - # Walk Cycle - kf_leg = [ - (0.0, [0, 0, 0]), - (0.5, [45, 0, 0]), - (1.0, [0, 0, 0]) + import math + def euler_to_quat(x_deg, y_deg, z_deg): + cx = math.cos(math.radians(x_deg) * 0.5); sx = math.sin(math.radians(x_deg) * 0.5) + cy = math.cos(math.radians(y_deg) * 0.5); sy = math.sin(math.radians(y_deg) * 0.5) + cz = math.cos(math.radians(z_deg) * 0.5); sz = math.sin(math.radians(z_deg) * 0.5) + return [cx * cy * cz + sx * sy * sz, sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz, cx * cy * sz - sx * sy * cz] + + anims = [] + + # 1. Walk Cycle (Legs swinging +/- 30 degrees) + # Pivot matches hip joint. + legs = [ + ("geo_leg_front_left", 30), # Forward + ("geo_leg_front_right", -30), # Backward + ("geo_leg_back_left", -30), # Backward (matches opposite front) + ("geo_leg_back_right", 30), # Forward ] - # Note: "grp_leg_l" comes from the provided model structure - ch_leg = channel("grp_leg_l", "rotation", kf_leg, interpolation="linear") + walk_channels = [] + for leg_id, angle in legs: + walk_channels.append(Channel( + target_id=leg_id, + property="rotation", + interpolation="cubic", # Smooth usage + frames=[ + {'time': 0, 'value': euler_to_quat(0, 0, 0)}, + {'time': int(0.25 * TICKS_PER_SEC), 'value': euler_to_quat(angle, 0, 0)}, + {'time': int(0.75 * TICKS_PER_SEC), 'value': euler_to_quat(-angle, 0, 0)}, + {'time': int(1.0 * TICKS_PER_SEC), 'value': euler_to_quat(0, 0, 0)}, + ] + )) + anims.append(Animation(name="walk", duration=int(1.0 * TICKS_PER_SEC), loop_mode="repeat", channels=walk_channels)) + + return anims +``` + +### 3. Bird Wing Flap (Rest -> Extend -> Rest) +*Scenario: Bird wings flapping. Flap OUTWARD from body, then return. Do NOT clip into body.* +```python +def create_animations(): + import math + def euler_to_quat(x_deg, y_deg, z_deg): + cx = math.cos(math.radians(x_deg) * 0.5); sx = math.sin(math.radians(x_deg) * 0.5) + cy = math.cos(math.radians(y_deg) * 0.5); sy = math.sin(math.radians(y_deg) * 0.5) + cz = math.cos(math.radians(z_deg) * 0.5); sz = math.sin(math.radians(z_deg) * 0.5) + return [cx * cy * cz + sx * sy * sz, sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz, cx * cy * sz - sx * sy * cz] + + channels = [] + + # Left Wing: Flaps UP (negative Z rotation) + channels.append(Channel( + target_id="grp_wing_l", + property="rotation", + interpolation="cubic", + frames=[ + {'time': 0, 'value': euler_to_quat(0, 0, 0)}, # Rest + {'time': int(0.25 * TICKS_PER_SEC), 'value': euler_to_quat(0, 0, -60)}, # Extended Out/Up + {'time': int(0.5 * TICKS_PER_SEC), 'value': euler_to_quat(0, 0, 0)}, # Return to Rest + ] + )) - return [ - animation("walk", duration=1.0, loop="repeat", channels=[ch_leg]) + # Right Wing: Flaps UP (positive Z rotation) + channels.append(Channel( + target_id="grp_wing_r", + property="rotation", + interpolation="cubic", + frames=[ + {'time': 0, 'value': euler_to_quat(0, 0, 0)}, + {'time': int(0.25 * TICKS_PER_SEC), 'value': euler_to_quat(0, 0, 60)}, # Extended Out/Up + {'time': int(0.5 * TICKS_PER_SEC), 'value': euler_to_quat(0, 0, 0)}, # Return to Rest + ] + )) + + return [Animation(name="fly", duration=int(0.5 * TICKS_PER_SEC), loop_mode="repeat", channels=channels)] +``` + +### 4. Car Wheels (Continuous Rotation) +*Scenario: Vehicle wheels spinning forward.* +```python +def create_animations(): + import math + def euler_to_quat(x_deg, y_deg, z_deg): + cx = math.cos(math.radians(x_deg) * 0.5); sx = math.sin(math.radians(x_deg) * 0.5) + cy = math.cos(math.radians(y_deg) * 0.5); sy = math.sin(math.radians(y_deg) * 0.5) + cz = math.cos(math.radians(z_deg) * 0.5); sz = math.sin(math.radians(z_deg) * 0.5) + return [cx * cy * cz + sx * sy * sz, sx * cy * cz - cx * sy * sz, cx * sy * cz + sx * cy * sz, cx * cy * sz - sx * sy * cz] + + wheels = ["grp_wheel_fl", "grp_wheel_fr", "grp_wheel_bl", "grp_wheel_br"] + drive_channels = [] + + # Full 360 spin over 1 second + # Note: Linear interpolation with Quaternions handles large rotations best by splitting or using specific logic, + # but for simple spinning, 0 -> 180 -> 360 works well. + # Here we rotate -360 on X axis (driven forward). + frames_rot = [ + {'time': 0, 'value': euler_to_quat(0, 0, 0)}, + {'time': int(0.5 * TICKS_PER_SEC), 'value': euler_to_quat(-180, 0, 0)}, + {'time': int(1.0 * TICKS_PER_SEC), 'value': euler_to_quat(-360, 0, 0)}, ] + + for w in wheels: + drive_channels.append(Channel(target_id=w, property="rotation", interpolation="linear", frames=frames_rot)) + + return [Animation(name="drive", duration=int(1.0 * TICKS_PER_SEC), loop_mode="repeat", channels=drive_channels)] ``` + +## Task +Generate the `create_animations` function for the user's model based on the request. """ diff --git a/blocksmith/schema/blockjson.py b/blocksmith/schema/blockjson.py index 07e6c87..d4a42e1 100644 --- a/blocksmith/schema/blockjson.py +++ b/blocksmith/schema/blockjson.py @@ -222,7 +222,7 @@ class Animation(BaseModel): A single animation clip containing channels. """ name: str = Field(..., description="Animation name (e.g., 'walk').") - duration: float = Field(..., description="Total duration in seconds (or ticks).") + duration: int = Field(..., description="Total duration in ticks/frames.") loop_mode: Optional[Literal['once', 'repeat', 'pingpong']] = Field('repeat', description="Playback mode.") channels: List[Channel] = Field(..., description="Channels in this animation.") From 7b63b1c019807b2e720e7167164cc12889023e96 Mon Sep 17 00:00:00 2001 From: Gabe Busto Date: Wed, 7 Jan 2026 11:08:51 -0500 Subject: [PATCH 3/6] fix(animation): align Rule 14 forward direction (-Z) with system standards --- blocksmith/generator/prompts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blocksmith/generator/prompts.py b/blocksmith/generator/prompts.py index d189375..7fb0f9d 100644 --- a/blocksmith/generator/prompts.py +++ b/blocksmith/generator/prompts.py @@ -47,7 +47,7 @@ 13. **Loop Mode Values**: The `loop_mode` argument in `Animation()` **MUST** be one of: `'once'`, `'repeat'`, or `'pingpong'`. Do **NOT** use `'loop'`, `'cycle'`, or defaults. 14. **Shared Language & Coordinate System**: - **World Up**: +Y Axis. (Gravity pulls down along -Y). - - **Forward**: +Z Axis. (Characters face +Z). + - **Forward**: -Z Axis. (Characters face -Z). - **Right**: +X Axis. - **Pivot Points**: All rotations occur around the object's `pivot` defined in the Entity DSL. A "Center" rotation means rotating around this pivot. - **"Reset"**: Returning to the values defined in the Entity DSL (Bind Pose). From a93a68cbe6a89b734c30544c480bf0217d1f419c Mon Sep 17 00:00:00 2001 From: Gabe Busto Date: Wed, 7 Jan 2026 11:29:10 -0500 Subject: [PATCH 4/6] fix(animation): add explicit rotation handedness rules to system prompt --- blocksmith/generator/prompts.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/blocksmith/generator/prompts.py b/blocksmith/generator/prompts.py index 7fb0f9d..0c549be 100644 --- a/blocksmith/generator/prompts.py +++ b/blocksmith/generator/prompts.py @@ -53,6 +53,11 @@ - **"Reset"**: Returning to the values defined in the Entity DSL (Bind Pose). 15. **ISOLATION (CRITICAL)**: You are generating a SINGLE, ISOLATED animation action. Do NOT include channels for parts that are not explicitly involved in this specific movement. Do NOT assume other animations are playing or that you need to merge with previous states. Output ONLY the channels required for the requested motion. +### Rotation Rules (Handedness) +* **Positive Pitch (+X Rotation)**: Tilts the front (-Z) **UP**. (Used to raise arms forward). +* **Positive Yaw (+Y Rotation)**: Turns the front (-Z) to the **LEFT** (-X). +* **Positive Roll (+Z Rotation)**: Tilts the **RIGHT** (+X) side **DOWN**. + ### Example Pattern ```python def create_animations(): From b84be5714fdeb5db84a931b7a3501f836702a9b9 Mon Sep 17 00:00:00 2001 From: Gabe Busto Date: Wed, 7 Jan 2026 12:49:39 -0500 Subject: [PATCH 5/6] docs: add animation workflow commands and caveats including pivot/timing notes --- README.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ae2485f..3ee0797 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,14 @@ BlockSmith is a powerful Python library for generating block-style 3D models tha **Generate from anything:** ```bash # Text to 3D model +# Text to 3D model blocksmith generate "a castle" -o castle.glb + +# Create animations +blocksmith animate "walk cycle" -m castle.py -o walk.py + +# Link them together +blocksmith link -m castle.py -a walk.py -o castle_animated.glb ``` ![Castle Example](castle-example-model.jpg) @@ -178,6 +185,10 @@ blocksmith generate "turn this into blocks" --image photo.jpg -o model.glb # Convert between formats blocksmith convert castle.bbmodel castle.glb + +# Animation Workflow +blocksmith animate "wave hand" -m steve.py -o wave.py +blocksmith link -m steve.py -a wave.py -o steve_animated.glb ``` ### Python SDK @@ -226,9 +237,40 @@ blocksmith convert tree.bbmodel tree.json ```bash blocksmith --help blocksmith generate --help +blocksmith --help +blocksmith generate --help blocksmith convert --help +blocksmith animate --help +blocksmith link --help +``` +### Animation Workflow + +**1. Generate Animation Code:** +```bash +# Creates a Python file containing just the animation logic +blocksmith animate "make it run" -m model.py -o run.py +``` + +**2. Link to Model:** +```bash +# Merges the model and animation(s) into a GLB +blocksmith link -m model.py -a run.py -o final.glb + +# You can stick multiple animations together! +blocksmith link -m model.py -a walk.py -a run.py -a jump.py -o final.glb ``` +### ⚠️ Animation Caveats & Best Practices + +1. **Centered Pivots:** If you want an object to rotate around its center (like a floating cube), the **Model** must have a Group pivot at its geometric center. + * *Bad:* A cube at `[0,0,0]` will rotate around the bottom-left corner. + * *Good:* A cube inside a Group at `[0, 0.5, 0]` will rotate around its center. + * *Fix:* Ask the generator for "a cube centered at 0,0,0 ready for rotation". +2. **Speed**: LLMs tend to generate very fast animations (1.0s). For smooth loops, try asking for "slow, smooth rotation" or specifically "2 to 4 seconds duration". +3. **Forward Direction**: In BlockSmith, **North is -Z**. + * Arms raising "Forward" will rotate towards -Z. + * The LLM knows this, but sometimes needs reminders for complex moves. + ### Python SDK Usage **Basic generation:** @@ -523,7 +565,8 @@ This is an alpha release focused on core features. Current limitations: - ✅ Format conversion API **v0.2 (Planned)** -- [ ] Animation support +**v0.2 (Planned)** +- [x] Animation support (Basic) - [ ] Blockbench plugin - [ ] Web UI From 0afa6e9bf51cbb392cd9eb5d551d75469f9a93b6 Mon Sep 17 00:00:00 2001 From: Gabe Busto Date: Wed, 7 Jan 2026 13:14:24 -0500 Subject: [PATCH 6/6] docs & feat: finalize animation guidelines in prompt and update README with workflow caveats --- README.md | 19 ++++++++++++------- blocksmith/generator/prompts.py | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3ee0797..55c0004 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,24 @@ BlockSmith is a powerful Python library for generating block-style 3D models tha **Generate from anything:** ```bash # Text to 3D model -# Text to 3D model blocksmith generate "a castle" -o castle.glb - -# Create animations -blocksmith animate "walk cycle" -m castle.py -o walk.py - -# Link them together -blocksmith link -m castle.py -a walk.py -o castle_animated.glb ``` ![Castle Example](castle-example-model.jpg) > **Tip:** You can view your generated GLB/GLTF files for free at [sandbox.babylonjs.com](https://sandbox.babylonjs.com/). +**Bring it to life:** +```bash +# Generate a character +blocksmith generate "a blocky robot" -o robot.py + +# Animate it +blocksmith animate "walk cycle" -m robot.py -o walk.py + +# Link together +blocksmith link -m robot.py -a walk.py -o robot_animated.glb +``` + ```bash # Image to 3D model blocksmith generate "blocky version" --image photo.jpg -o model.glb diff --git a/blocksmith/generator/prompts.py b/blocksmith/generator/prompts.py index 0c549be..194cd0b 100644 --- a/blocksmith/generator/prompts.py +++ b/blocksmith/generator/prompts.py @@ -95,8 +95,22 @@ def euler_to_quat(x_deg, y_deg, z_deg): - Ensure moving parts (like a pump shotgun slide) do not clip into other geometry. Calculate positions carefully based on object size. 4. **Pivots**: - If a part rotates weirdly (e.g., around its center instead of a joint), it likely lacks a Group with a proper pivot. Ideally, request a model update, but for now, animate what you have. +5. **Timing & Speed**: + - **Avoid Hyper-Speed**: A full 360-degree spin should generally take **2.0 to 4.0 seconds**. 1.0 second is usually too fast and looks "spastic". + - **Start Immediately**: Always have a keyframe at `time: 0` unless a delay is explicitly requested. + - **Smoothness**: Use `cubic` interpolation for organic movements. Use `linear` for mechanical spins. + + - **Smoothness**: Use `cubic` interpolation for organic movements. Use `linear` for mechanical spins. + +### Common Pitfalls (Self-Correction) +* **"Walking Backwards"**: For a forward step, the leg must swing **Forward (+X Pitch)** first. +* **"Natural Joint Constraints"**: + - **Knees**: Bend **BACKWARDS (-X Rotation)**. (Positive rotation breaks the knee forward). + - **Elbows/Fingers**: Bend **FORWARD (+X Rotation)**. + - **Torso**: Leans Forward with **+X Rotation**. +* **"Off-Center Rotation"**: If requested to "rotate around center", check if the target Group's `pivot` matches its geometric center. If not, visualize where the pivot *actually* is (usually bottom) and animate accordingly, or compensate with position keys. + ---- ## Few-Shot Examples