import bpy from bpy.props import * class LnxTilesheetEventListItem(bpy.types.PropertyGroup): """An event triggered on a specific frame within a tilesheet action.""" name: StringProperty( name="Event Name", description="Name of the event to trigger", default="event") frame_prop: IntProperty( name="Frame", description="Frame number when this event triggers", default=0, min=0) class LnxTilesheetActionListItem(bpy.types.PropertyGroup): """An action (animation sequence) within a tilesheet with per-action properties.""" name: StringProperty( name="Name", description="Name of this tilesheet action", default="Untitled") start_prop: IntProperty( name="Start", description="Starting frame index", default=0) end_prop: IntProperty( name="End", description="Ending frame index", default=0) loop_prop: BoolProperty( name="Loop", description="Whether this action should loop", default=True) tilesx_prop: IntProperty( name="Tiles X", description="Number of horizontal tiles for this action", default=1, min=1) tilesy_prop: IntProperty( name="Tiles Y", description="Number of vertical tiles for this action", default=1, min=1) framerate_prop: IntProperty( name="Frame Rate", description="Animation frame rate for this action (frames per second)", default=4, min=1) mesh_prop: StringProperty( name="Mesh", description="Optional mesh data to swap to when playing this action (brings its own material/texture/UVs)", default="") # Events list for this action events: CollectionProperty(type=LnxTilesheetEventListItem) events_index: IntProperty(name="Event Index", default=0) class LNX_UL_TilesheetActionList(bpy.types.UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index): custom_icon = 'PLAY' if self.layout_type in {'DEFAULT', 'COMPACT'}: layout.prop(item, "name", text="", emboss=False, icon=custom_icon) elif self.layout_type in {'GRID'}: layout.alignment = 'CENTER' layout.label(text="", icon=custom_icon) class LnxTilesheetActionListNewItem(bpy.types.Operator): """Add a new action to the tilesheet""" bl_idname = "lnx_tilesheetactionlist.new_item" bl_label = "Add Action" def execute(self, context): obj = context.object if obj is None: return {'CANCELLED'} obj.lnx_tilesheet_actionlist.add() obj.lnx_tilesheet_actionlist_index = len(obj.lnx_tilesheet_actionlist) - 1 return {'FINISHED'} class LnxTilesheetActionListDeleteItem(bpy.types.Operator): """Delete the selected action from the tilesheet""" bl_idname = "lnx_tilesheetactionlist.delete_item" bl_label = "Delete Action" @classmethod def poll(cls, context): obj = context.object return obj is not None and len(obj.lnx_tilesheet_actionlist) > 0 def execute(self, context): obj = context.object action_list = obj.lnx_tilesheet_actionlist index = obj.lnx_tilesheet_actionlist_index action_list.remove(index) if index > 0: index = index - 1 obj.lnx_tilesheet_actionlist_index = index return {'FINISHED'} class LnxTilesheetActionListMoveItem(bpy.types.Operator): """Move an action in the list""" bl_idname = "lnx_tilesheetactionlist.move_item" bl_label = "Move Action" bl_options = {'INTERNAL'} direction: EnumProperty( items=( ('UP', 'Up', ""), ('DOWN', 'Down', "") )) @classmethod def poll(cls, context): obj = context.object return obj is not None and len(obj.lnx_tilesheet_actionlist) > 0 def execute(self, context): obj = context.object action_list = obj.lnx_tilesheet_actionlist index = obj.lnx_tilesheet_actionlist_index list_length = len(action_list) - 1 if self.direction == 'UP': new_index = max(0, index - 1) else: # DOWN new_index = min(list_length, index + 1) action_list.move(index, new_index) obj.lnx_tilesheet_actionlist_index = new_index return {'FINISHED'} class LnxTilesheetEventListNewItem(bpy.types.Operator): """Add a new event to the current action""" bl_idname = "lnx_tilesheetactionlist.new_event" bl_label = "Add Event" @classmethod def poll(cls, context): obj = context.object return obj is not None and len(obj.lnx_tilesheet_actionlist) > 0 def execute(self, context): obj = context.object if obj.lnx_tilesheet_actionlist_index < 0: return {'CANCELLED'} action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index] action.events.add() action.events_index = len(action.events) - 1 return {'FINISHED'} class LnxTilesheetEventListDeleteItem(bpy.types.Operator): """Delete the selected event from the current action""" bl_idname = "lnx_tilesheetactionlist.delete_event" bl_label = "Delete Event" @classmethod def poll(cls, context): obj = context.object if obj is None or len(obj.lnx_tilesheet_actionlist) == 0: return False if obj.lnx_tilesheet_actionlist_index < 0: return False action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index] return len(action.events) > 0 def execute(self, context): obj = context.object action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index] events = action.events index = action.events_index events.remove(index) if index > 0: action.events_index = index - 1 return {'FINISHED'} class LnxTilesheetSlice(bpy.types.Operator): """Slice the UV map based on tile dimensions - scales UVs to fit one tile""" bl_idname = "lnx_tilesheet.slice" bl_label = "Slice" bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context): obj = context.object if obj is None or obj.type != 'MESH': return False if len(obj.lnx_tilesheet_actionlist) == 0: return False if obj.lnx_tilesheet_actionlist_index < 0: return False action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index] # Check if action has a mesh specified, and that mesh has UVs if action.mesh_prop != '': mesh_data = bpy.data.meshes.get(action.mesh_prop) if mesh_data is None or not mesh_data.uv_layers: return False else: # Fall back to object's own mesh if not obj.data.uv_layers: return False return True def execute(self, context): obj = context.object if obj.lnx_tilesheet_actionlist_index < 0: self.report({'ERROR'}, "No action selected") return {'CANCELLED'} action = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index] tiles_x = action.tilesx_prop tiles_y = action.tilesy_prop if tiles_x < 1 or tiles_y < 1: self.report({'ERROR'}, "Tiles X and Y must be at least 1") return {'CANCELLED'} # Get mesh from action's mesh_prop, or fall back to object's mesh if action.mesh_prop != '': mesh = bpy.data.meshes.get(action.mesh_prop) if mesh is None: self.report({'ERROR'}, f"Mesh '{action.mesh_prop}' not found") return {'CANCELLED'} mesh_name = action.mesh_prop else: mesh = obj.data mesh_name = obj.data.name if not mesh.uv_layers: self.report({'ERROR'}, f"Mesh '{mesh_name}' has no UV layers") return {'CANCELLED'} uv_layer = mesh.uv_layers.active.data # Calculate target tile size tile_width = 1.0 / tiles_x tile_height = 1.0 / tiles_y # Find current UV bounding box min_u = min_v = float('inf') max_u = max_v = float('-inf') for loop_uv in uv_layer: min_u = min(min_u, loop_uv.uv[0]) max_u = max(max_u, loop_uv.uv[0]) min_v = min(min_v, loop_uv.uv[1]) max_v = max(max_v, loop_uv.uv[1]) current_width = max_u - min_u current_height = max_v - min_v if current_width == 0 or current_height == 0: self.report({'ERROR'}, f"UV map on '{mesh_name}' has zero dimensions") return {'CANCELLED'} # Scale and position UVs to fit in first tile (0,0) to (tile_width, tile_height) for loop_uv in uv_layer: # Normalize to 0-1 range norm_u = (loop_uv.uv[0] - min_u) / current_width norm_v = (loop_uv.uv[1] - min_v) / current_height # Scale to tile size loop_uv.uv[0] = norm_u * tile_width loop_uv.uv[1] = norm_v * tile_height mesh.update() self.report({'INFO'}, f"UVs sliced to {tiles_x}x{tiles_y} grid (tile size: {tile_width:.3f} x {tile_height:.3f})") return {'FINISHED'} class LNX_PT_TilesheetPanel(bpy.types.Panel): bl_label = "Leenkx Tilesheet" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "object" bl_options = {'DEFAULT_CLOSED'} @classmethod def poll(cls, context): return context.object is not None and context.object.type == 'MESH' def draw_header(self, context): obj = context.object self.layout.prop(obj, "lnx_tilesheet_enabled", text="") def draw(self, context): layout = self.layout layout.use_property_split = True layout.use_property_decorate = False obj = context.object layout.enabled = obj.lnx_tilesheet_enabled # Start action dropdown layout.prop_search(obj, "lnx_tilesheet_default_action", obj, "lnx_tilesheet_actionlist", text="Start Action") row = layout.row() row.prop(obj, "lnx_tilesheet_flipx") row.prop(obj, "lnx_tilesheet_flipy") # Actions list layout.separator() layout.label(text="Actions") rows = 2 if len(obj.lnx_tilesheet_actionlist) > 1: rows = 4 row = layout.row() row.template_list("LNX_UL_TilesheetActionList", "The_List", obj, "lnx_tilesheet_actionlist", obj, "lnx_tilesheet_actionlist_index", rows=rows) col = row.column(align=True) col.operator("lnx_tilesheetactionlist.new_item", icon='ADD', text="") col.operator("lnx_tilesheetactionlist.delete_item", icon='REMOVE', text="") if len(obj.lnx_tilesheet_actionlist) > 1: col.separator() op = col.operator("lnx_tilesheetactionlist.move_item", icon='TRIA_UP', text="") op.direction = 'UP' op = col.operator("lnx_tilesheetactionlist.move_item", icon='TRIA_DOWN', text="") op.direction = 'DOWN' # Selected action details (per-action properties) if obj.lnx_tilesheet_actionlist_index >= 0 and len(obj.lnx_tilesheet_actionlist) > 0: adat = obj.lnx_tilesheet_actionlist[obj.lnx_tilesheet_actionlist_index] box = layout.box() # Grid dimensions row = box.row() row.use_property_split = False row.prop(adat, "tilesx_prop") row.prop(adat, "tilesy_prop") row = box.row() row.operator("lnx_tilesheet.slice", text="Slice") # Frame range row = box.row() row.use_property_split = False row.prop(adat, "start_prop") row.prop(adat, "end_prop") # Framerate and loop box.prop(adat, "framerate_prop") box.prop(adat, "loop_prop") # Optional mesh (dropdown from bpy.data.meshes) box.prop_search(adat, "mesh_prop", bpy.data, "meshes", text="Mesh") # Events section box.separator() box.label(text="Events") row = box.row() col = row.column() for i, evt in enumerate(adat.events): evt_row = col.row(align=True) evt_row.prop(evt, "name", text="") evt_row.prop(evt, "frame_prop", text="Frame") row = box.row(align=True) row.operator("lnx_tilesheetactionlist.new_event", icon='ADD', text="Add Event") row.operator("lnx_tilesheetactionlist.delete_event", icon='REMOVE', text="Remove Event") __REG_CLASSES = ( LnxTilesheetEventListItem, LnxTilesheetActionListItem, LNX_UL_TilesheetActionList, LnxTilesheetActionListNewItem, LnxTilesheetActionListDeleteItem, LnxTilesheetActionListMoveItem, LnxTilesheetEventListNewItem, LnxTilesheetEventListDeleteItem, LnxTilesheetSlice, LNX_PT_TilesheetPanel, ) __reg_classes, unregister = bpy.utils.register_classes_factory(__REG_CLASSES) def register(): __reg_classes() # Tilesheet properties on Object (one tilesheet per object) bpy.types.Object.lnx_tilesheet_enabled = BoolProperty( name="Tilesheet", description="Enable tilesheet animation for this object", default=False) bpy.types.Object.lnx_tilesheet_default_action = StringProperty( name="Start Action", description="Start action to play on spawn", default="") bpy.types.Object.lnx_tilesheet_flipx = BoolProperty( name="Flip X", description="Flip the tilesheet horizontally", default=False) bpy.types.Object.lnx_tilesheet_flipy = BoolProperty( name="Flip Y", description="Flip the tilesheet vertically", default=False) bpy.types.Object.lnx_tilesheet_actionlist = CollectionProperty(type=LnxTilesheetActionListItem) bpy.types.Object.lnx_tilesheet_actionlist_index = IntProperty( name="Action Index", default=0)