diff --git a/__init__.py b/__init__.py index a2e21c8..afc8389 100644 --- a/__init__.py +++ b/__init__.py @@ -13,11 +13,13 @@ from .fast64_internal.sm64.sm64_geolayout_parser import generateMetarig from .fast64_internal.oot import OOT_Properties, oot_register, oot_unregister from .fast64_internal.oot.oot_level import OOT_ObjectProperties +from .fast64_internal.utility_anim import utility_anim_register, utility_anim_unregister, ArmatureApplyWithMeshOperator from .fast64_internal.f3d.f3d_material import mat_register, mat_unregister from .fast64_internal.f3d.f3d_render_engine import render_engine_register, render_engine_unregister from .fast64_internal.f3d.f3d_writer import f3d_writer_register, f3d_writer_unregister from .fast64_internal.f3d.f3d_parser import f3d_parser_register, f3d_parser_unregister +from .fast64_internal.f3d.flipbook import flipbook_register, flipbook_unregister from .fast64_internal.f3d_material_converter import ( MatUpdateConvert, @@ -51,63 +53,6 @@ gameEditorEnum = ( ) -class ArmatureApplyWithMesh(bpy.types.Operator): - # set bl_ properties - bl_description = ( - "Applies current pose as default pose. Useful for " - + "rigging an armature that is not in T/A pose. Note that when using " - + " with an SM64 armature, you must revert to the default pose after " - + "skinning." - ) - bl_idname = "object.armature_apply_w_mesh" - bl_label = "Apply As Rest Pose" - bl_options = {"REGISTER", "UNDO", "PRESET"} - - # Called on demand (i.e. button press, menu item) - # Can also be called from operator search menu (Spacebar) - def execute(self, context): - try: - if context.mode != "OBJECT": - bpy.ops.object.mode_set(mode="OBJECT") - - if len(context.selected_objects) == 0: - raise PluginError("Armature not selected.") - elif type(context.selected_objects[0].data) is not bpy.types.Armature: - raise PluginError("Armature not selected.") - - armatureObj = context.selected_objects[0] - for child in armatureObj.children: - if type(child.data) is not bpy.types.Mesh: - continue - armatureModifier = None - for modifier in child.modifiers: - if isinstance(modifier, bpy.types.ArmatureModifier): - armatureModifier = modifier - if armatureModifier is None: - continue - print(armatureModifier.name) - bpy.ops.object.select_all(action="DESELECT") - context.view_layer.objects.active = child - bpy.ops.object.modifier_copy(modifier=armatureModifier.name) - print(len(child.modifiers)) - attemptModifierApply(armatureModifier) - - bpy.ops.object.select_all(action="DESELECT") - context.view_layer.objects.active = armatureObj - bpy.ops.object.mode_set(mode="POSE") - bpy.ops.pose.armature_apply() - if context.mode != "OBJECT": - bpy.ops.object.mode_set(mode="OBJECT") - except Exception as e: - if context.mode != "OBJECT": - bpy.ops.object.mode_set(mode="OBJECT") - raisePluginError(self, e) - return {"CANCELLED"} - - self.report({"INFO"}, "Applied armature with mesh.") - return {"FINISHED"} # must return a set - - class AddBoneGroups(bpy.types.Operator): # set bl_ properties bl_description = ( @@ -193,7 +138,7 @@ class SM64_ArmatureToolsPanel(SM64_Panel): # called every frame def draw(self, context): col = self.layout.column() - col.operator(ArmatureApplyWithMesh.bl_idname) + col.operator(ArmatureApplyWithMeshOperator.bl_idname) col.operator(AddBoneGroups.bl_idname) col.operator(CreateMetarig.bl_idname) col.operator(SM64_AddWaterBox.bl_idname) @@ -277,7 +222,7 @@ class Fast64_GlobalToolsPanel(bpy.types.Panel): # called every frame def draw(self, context): col = self.layout.column() - col.operator(ArmatureApplyWithMesh.bl_idname) + col.operator(ArmatureApplyWithMeshOperator.bl_idname) # col.operator(CreateMetarig.bl_idname) addon_updater_ops.update_notice_box_ui(self, context) @@ -435,7 +380,6 @@ classes = ( Fast64_Properties, Fast64_BoneProperties, Fast64_ObjectProperties, - ArmatureApplyWithMesh, AddBoneGroups, CreateMetarig, SM64_AddWaterBox, @@ -497,6 +441,7 @@ def register(): register_class(ExampleAddonPreferences) addon_updater_ops.register(bl_info) + utility_anim_register() mat_register() render_engine_register() bsdf_conv_register() @@ -508,6 +453,7 @@ def register(): bsdf_conv_panel_regsiter() f3d_writer_register() + flipbook_register() f3d_parser_register() # ROM @@ -534,6 +480,8 @@ def register(): # called on add-on disabling def unregister(): + utility_anim_unregister() + flipbook_unregister() f3d_writer_unregister() f3d_parser_unregister() sm64_unregister(True) diff --git a/fast64_internal/f3d/f3d_gbi.py b/fast64_internal/f3d/f3d_gbi.py index 8856427..775eeb1 100644 --- a/fast64_internal/f3d/f3d_gbi.py +++ b/fast64_internal/f3d/f3d_gbi.py @@ -1,4 +1,5 @@ # Macros are all copied over from gbi.h +from typing import Sequence import bpy, os, enum from ..utility import * @@ -2265,7 +2266,7 @@ class FModel: self.texturesSavedLastExport = 0 # hacky # Called before SPEndDisplayList - def onMaterialCommandsBuilt(self, gfxList, revertList, material, drawLayer): + def onMaterialCommandsBuilt(self, fMaterial, material, drawLayer): return def getTextureSuffixFromFormat(self, texFmt): @@ -2320,7 +2321,16 @@ class FModel: # Check if texture is in self if imageKey in self.textures: fImage = self.textures[imageKey] - fPalette = self.textures[fImage.paletteKey] if fImage.paletteKey is not None else None + if fImage.paletteKey is not None: + if fImage.paletteKey in self.textures: + fPalette = self.textures[fImage.paletteKey] + else: + print(f"Can't find {str(fImage.paletteKey)}") + fPalette = None + else: + # print("Palette key is None") + fPalette = None + return fImage, fPalette if self.parentModel is not None: @@ -2974,9 +2984,17 @@ class Vp: class Light: - def __init__(self, color, normal): - self.color = color - self.normal = normal + def __init__(self, color: Sequence, normal: Sequence): + self.color: Sequence = color + self.normal: Sequence = normal + + def __eq__(self, other): + if not isinstance(other, Light): + return False + return self.color == other.color and self.normal == other.normal + + def __hash__(self): + return hash((self.color[:], self.normal[:])) def to_binary(self): return bytearray(self.color + [0x00] + self.color + [0x00] + self.normal + [0x00] + [0x00] * 4) @@ -3024,8 +3042,16 @@ class Light: class Ambient: - def __init__(self, color): - self.color = color + def __init__(self, color: Sequence): + self.color: Sequence = color + + def __eq__(self, other): + if not isinstance(other, Ambient): + return False + return self.color == other.color + + def __hash__(self): + return hash(self.color[:]) def to_binary(self): return bytearray(self.color + [0x00] + self.color + [0x00]) diff --git a/fast64_internal/f3d/f3d_material.py b/fast64_internal/f3d/f3d_material.py index 6c61578..a0fdedc 100644 --- a/fast64_internal/f3d/f3d_material.py +++ b/fast64_internal/f3d/f3d_material.py @@ -11,8 +11,9 @@ from ..utility import * from ..render_settings import Fast64RenderSettings_Properties, update_scene_props_from_render_settings from .f3d_material_helpers import F3DMaterial_UpdateLock from bpy.app.handlers import persistent -from typing import Generator, Optional, Tuple +from typing import Generator, Optional, Tuple, Any +F3DMaterialHash = Any # giant tuple logging.basicConfig(format="%(asctime)s: %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p") logger = logging.getLogger(__name__) @@ -212,6 +213,9 @@ class DrawLayerProperty(bpy.types.PropertyGroup): sm64: bpy.props.EnumProperty(items=sm64EnumDrawLayers, default="1", update=update_draw_layer) oot: bpy.props.EnumProperty(items=ootEnumDrawLayers, default="Opaque", update=update_draw_layer) + def key(self): + return (self.sm64, self.oot) + def getTmemWordUsage(texFormat, width, height): texelsPerLine = 64 / bitSizeDict[texBitSizeOf[texFormat]] @@ -2166,12 +2170,18 @@ class TextureFieldProperty(bpy.types.PropertyGroup): update=update_tex_field_prop, ) + def key(self): + return (self.clamp, self.mirror, round(self.low * 4), round(self.high * 4), self.mask, self.shift) + class SetTileSizeScrollProperty(bpy.types.PropertyGroup): s: bpy.props.IntProperty(min=-4095, max=4095, default=0) t: bpy.props.IntProperty(min=-4095, max=4095, default=0) interval: bpy.props.IntProperty(min=1, soft_max=1000, default=1) + def key(self): + return (self.s, self.t, self.interval) + class TextureProperty(bpy.types.PropertyGroup): tex: bpy.props.PointerProperty( @@ -2241,6 +2251,26 @@ class TextureProperty(bpy.types.PropertyGroup): return self.tex_reference_size return [0, 0] + def key(self): + texSet = self.tex_set + isCI = self.tex_format == "CI8" or self.tex_format == "CI4" + useRef = self.use_tex_reference + return ( + self.tex_set, + self.tex if texSet else None, + self.tex_format if texSet else None, + self.ci_format if texSet and isCI else None, + self.S.key() if texSet else None, + self.T.key() if texSet else None, + self.autoprop if texSet else None, + self.tile_scroll.key() if texSet else None, + self.use_tex_reference if texSet else None, + self.tex_reference if texSet and useRef else None, + self.tex_reference_size if texSet and useRef else None, + self.pal_reference if texSet and useRef and isCI else None, + self.pal_reference_size if texSet and useRef and isCI else None, + ) + def on_tex_autoprop(texProperty, context): if texProperty.autoprop and texProperty.tex is not None: @@ -2332,6 +2362,18 @@ class CombinerProperty(bpy.types.PropertyGroup): update=update_combiner_connections_and_preset, ) + def key(self): + return ( + self.A, + self.B, + self.C, + self.D, + self.A_alpha, + self.B_alpha, + self.C_alpha, + self.D_alpha, + ) + class ProceduralAnimProperty(bpy.types.PropertyGroup): speed: bpy.props.FloatProperty(name="Speed", default=1) @@ -2343,6 +2385,19 @@ class ProceduralAnimProperty(bpy.types.PropertyGroup): animate: bpy.props.BoolProperty() animType: bpy.props.EnumProperty(name="Type", items=enumTexScroll) + def key(self): + anim = self.animate + return ( + self.animate, + round(self.speed, 4) if anim else None, + round(self.amplitude, 4) if anim else None, + round(self.frequency, 4) if anim else None, + round(self.spaceFrequency, 4) if anim else None, + round(self.offset, 4) if anim else None, + round(self.noiseAmplitude, 4) if anim else None, + self.animType if anim else None, + ) + class ProcAnimVectorProperty(bpy.types.PropertyGroup): x: bpy.props.PointerProperty(type=ProceduralAnimProperty) @@ -2352,6 +2407,16 @@ class ProcAnimVectorProperty(bpy.types.PropertyGroup): angularSpeed: bpy.props.FloatProperty(default=1, name="Angular Speed") menu: bpy.props.BoolProperty() + def key(self): + return ( + self.x.key(), + self.y.key(), + self.z.key(), + round(self.pivot[0], 4), + round(self.pivot[1], 4), + round(self.angularSpeed, 4), + ) + class PrimDepthSettings(bpy.types.PropertyGroup): z: bpy.props.IntProperty( @@ -2380,6 +2445,9 @@ class PrimDepthSettings(bpy.types.PropertyGroup): ), ) + def key(self): + return (self.z, self.dz) + class RDPSettings(bpy.types.PropertyGroup): g_zbuffer: bpy.props.BoolProperty( @@ -2640,6 +2708,59 @@ class RDPSettings(bpy.types.PropertyGroup): update=update_node_values_with_preset, ) + def key(self): + setRM = self.set_rendermode + rmAdv = self.rendermode_advanced_enabled + prim = self.g_mdsft_zsrcsel == "G_ZS_PRIM" + return ( + self.g_zbuffer, + self.g_shade, + self.g_cull_front, + self.g_cull_back, + self.g_fog, + self.g_lighting, + self.g_tex_gen, + self.g_tex_gen_linear, + self.g_shade_smooth, + self.g_clipping, + self.g_mdsft_alpha_dither, + self.g_mdsft_rgb_dither, + self.g_mdsft_combkey, + self.g_mdsft_textconv, + self.g_mdsft_text_filt, + self.g_mdsft_textlod, + self.g_mdsft_textdetail, + self.g_mdsft_textpersp, + self.g_mdsft_cycletype, + self.g_mdsft_color_dither, + self.g_mdsft_pipeline, + self.g_mdsft_alpha_compare, + self.g_mdsft_zsrcsel, + self.prim_depth.key() if prim else None, + self.clip_ratio, + self.set_rendermode, + self.aa_en if setRM and rmAdv else None, + self.z_cmp if setRM and rmAdv else None, + self.z_upd if setRM and rmAdv else None, + self.im_rd if setRM and rmAdv else None, + self.clr_on_cvg if setRM and rmAdv else None, + self.cvg_dst if setRM and rmAdv else None, + self.zmode if setRM and rmAdv else None, + self.cvg_x_alpha if setRM and rmAdv else None, + self.alpha_cvg_sel if setRM and rmAdv else None, + self.force_bl if setRM and rmAdv else None, + self.blend_p1 if setRM and rmAdv else None, + self.blend_p2 if setRM and rmAdv else None, + self.blend_m1 if setRM and rmAdv else None, + self.blend_m2 if setRM and rmAdv else None, + self.blend_a1 if setRM and rmAdv else None, + self.blend_a2 if setRM and rmAdv else None, + self.blend_b1 if setRM and rmAdv else None, + self.blend_b2 if setRM and rmAdv else None, + self.rendermode_preset_cycle_1 if setRM and not rmAdv else None, + self.rendermode_preset_cycle_2 if setRM and not rmAdv else None, + ) + class DefaultRDPSettingsPanel(bpy.types.Panel): bl_label = "RDP Default Settings" @@ -2668,7 +2789,7 @@ def getOptimalFormat(tex, curFormat, isMultitexture): texFormat = "RGBA16" if isMultitexture: return curFormat - if (tex.size[0] * tex.size[1] > 8192): # Image too big + if tex.size[0] * tex.size[1] > 8192: # Image too big return curFormat isGreyscale = True @@ -3322,6 +3443,60 @@ class F3DMaterialProperty(bpy.types.PropertyGroup): draw_layer: bpy.props.PointerProperty(type=DrawLayerProperty) use_large_textures: bpy.props.BoolProperty(name="Large Texture Mode") + def key(self) -> F3DMaterialHash: + useDefaultLighting = self.set_lights and self.use_default_lighting + return ( + self.scale_autoprop, + self.uv_basis, + self.UVanim0.key(), + self.UVanim1.key(), + tuple([round(value, 4) for value in self.tex_scale]), + self.tex0.key(), + self.tex1.key(), + self.rdp_settings.key(), + self.draw_layer.key(), + self.use_large_textures, + self.use_default_lighting, + self.set_blend, + self.set_prim, + self.set_env, + self.set_key, + self.set_k0_5, + self.set_combiner, + self.set_lights, + self.set_fog, + tuple([round(value, 4) for value in self.blend_color]) if self.set_blend else None, + tuple([round(value, 4) for value in self.prim_color]) if self.set_prim else None, + round(self.prim_lod_frac, 4) if self.set_prim else None, + round(self.prim_lod_min, 4) if self.set_prim else None, + tuple([round(value, 4) for value in self.env_color]) if self.set_env else None, + tuple([round(value, 4) for value in self.key_center]) if self.set_key else None, + tuple([round(value, 4) for value in self.key_scale]) if self.set_key else None, + tuple([round(value, 4) for value in self.key_width]) if self.set_key else None, + round(self.k0, 4) if self.set_k0_5 else None, + round(self.k1, 4) if self.set_k0_5 else None, + round(self.k2, 4) if self.set_k0_5 else None, + round(self.k3, 4) if self.set_k0_5 else None, + round(self.k4, 4) if self.set_k0_5 else None, + round(self.k5, 4) if self.set_k0_5 else None, + self.combiner1.key() if self.set_combiner else None, + self.combiner2.key() if self.set_combiner else None, + tuple([round(value, 4) for value in self.fog_color]) if self.set_fog else None, + tuple([round(value, 4) for value in self.fog_position]) if self.set_fog else None, + tuple([round(value, 4) for value in self.default_light_color]) if useDefaultLighting else None, + self.set_ambient_from_light if useDefaultLighting else None, + tuple([round(value, 4) for value in self.ambient_light_color]) + if useDefaultLighting and not self.set_ambient_from_light + else None, + self.f3d_light1 if not useDefaultLighting else None, + self.f3d_light2 if not useDefaultLighting else None, + self.f3d_light3 if not useDefaultLighting else None, + self.f3d_light4 if not useDefaultLighting else None, + self.f3d_light5 if not useDefaultLighting else None, + self.f3d_light6 if not useDefaultLighting else None, + self.f3d_light7 if not useDefaultLighting else None, + ) + class UnlinkF3DImage0(bpy.types.Operator): bl_idname = "image.tex0_unlink" @@ -3423,7 +3598,7 @@ class F3DRenderSettingsPanel(bpy.types.Panel): gameSettingsBox.prop(renderSettings, "useObjectRenderPreview", text="Use Scene for Preview") gameSettingsBox.prop(renderSettings, "ootSceneObject") - + if renderSettings.ootSceneObject is not None: b = gameSettingsBox.column() r = b.row().split(factor=0.4) @@ -3434,21 +3609,22 @@ class F3DRenderSettingsPanel(bpy.types.Panel): False, ) if header is None: - r.label(text = "Header does not exist.", icon="QUESTION") + r.label(text="Header does not exist.", icon="QUESTION") else: numLightsNeeded = 1 if header.skyboxLighting == "Custom": r2 = b.row() r2.prop(renderSettings, "ootForceTimeOfDay") if renderSettings.ootForceTimeOfDay: - r2.label(text = "Light Index sets first of four lights.", icon="INFO") + r2.label(text="Light Index sets first of four lights.", icon="INFO") numLightsNeeded = 4 if header.skyboxLighting != "0x00": r.prop(renderSettings, "ootLightIdx") if renderSettings.ootLightIdx + numLightsNeeded > len(header.lightList): - b.label(text = "Light does not exist.", icon="QUESTION") + b.label(text="Light does not exist.", icon="QUESTION") if header.skyboxLighting == "0x00" or ( - header.skyboxLighting == "Custom" and renderSettings.ootForceTimeOfDay): + header.skyboxLighting == "Custom" and renderSettings.ootForceTimeOfDay + ): r.prop(renderSettings, "ootTime") case _: pass diff --git a/fast64_internal/f3d/f3d_parser.py b/fast64_internal/f3d/f3d_parser.py index 07904d6..c42078d 100644 --- a/fast64_internal/f3d/f3d_parser.py +++ b/fast64_internal/f3d/f3d_parser.py @@ -1,4 +1,5 @@ -import bmesh, bpy, mathutils, pprint, re, math, traceback +from typing import Union +import bmesh, bpy, mathutils, re, math, traceback from bpy.utils import register_class, unregister_class from .f3d_gbi import * from .f3d_material import ( @@ -6,10 +7,15 @@ from .f3d_material import ( update_preset_manual, all_combiner_uses, ootEnumDrawLayers, + TextureProperty, + F3DMaterialProperty, + update_node_values_of_material, + F3DMaterialHash, ) -from .f3d_writer import BufferVertex +from .f3d_writer import BufferVertex, F3DVert from ..utility import * import ast, operator +from .f3d_material_helpers import F3DMaterial_UpdateLock colorCombinationCommands = [ 0x03, # load lighting data @@ -397,7 +403,7 @@ def math_eval(s, f3d): def bytesToNormal(normal): - return [int.from_bytes([value], "big", signed=True) / 128 if value > 0 else value / 128 for value in normal] + return [int.from_bytes([round(value)], "big", signed=True) / 128 if value > 0 else value / 128 for value in normal] def getTileFormat(value, f3d): @@ -435,13 +441,19 @@ def renderModeMask(rendermode, cycle, blendOnly): def convertF3DUV(value, maxSize): try: - valueBytes = int.to_bytes(value, 2, "big", signed=True) + valueBytes = int.to_bytes(round(value), 2, "big", signed=True) except OverflowError: - valueBytes = int.to_bytes(value, 2, "big", signed=False) + valueBytes = int.to_bytes(round(value), 2, "big", signed=False) return ((int.from_bytes(valueBytes, "big", signed=True) / 32) + 0.5) / (maxSize if maxSize > 0 else 1) +class F3DTextureReference: + def __init__(self, name, width): + self.name = name + self.width = width + + class F3DParsedCommands: def __init__(self, name, commands, index): self.name = name @@ -453,27 +465,34 @@ class F3DParsedCommands: class F3DContext: - def __init__(self, f3d, basePath, materialContext): - self.f3d = f3d - self.vertexBuffer = [None] * f3d.vert_load_size - self.basePath = basePath - self.materialContext = materialContext + def __init__(self, f3d: F3D, basePath: str, materialContext: bpy.types.Material): + self.f3d: F3D = f3d + self.basePath: str = basePath + self.materialContext: bpy.types.Material = materialContext + self.materialContext.f3d_update_flag = True # Don't want visual updates while parsing + # If this is not disabled, then tex_scale will auto-update on manual node update. + self.materialContext.f3d_mat.scale_autoprop = False + self.initContext() + # This is separate as we want to call __init__ in clearGeometry, but don't want same behaviour for child classes + def initContext(self): + self.vertexBuffer: list[None | BufferVertex] = [None] * self.f3d.vert_load_size self.clearMaterial() - mat = self.mat() + mat: F3DMaterialProperty = self.mat() mat.set_combiner = False - self.materials = [] # saved materials - self.triMatIndices = [] # material indices per triangle - self.materialChanged = True - self.lastMaterialIndex = None + self.materials: list[bpy.types.Material] = [] # current material list + self.materialDict: dict[F3DMaterialHash, bpy.types.Material] = {} # cached materials for all imports + self.triMatIndices: list[int] = [] # material indices per triangle + self.materialChanged: bool = True + self.lastMaterialIndex: bool = None - self.vertexData = {} # c name : parsed data - self.textureData = {} # c name : blender texture + self.vertexData: dict[str, list[F3DVert]] = {} # c name : parsed data + self.textureData: dict[str, bpy.types.Image] = {} # c name : blender texture - self.tlutAppliedTextures = [] # c name - self.currentTextureName = None - self.imagesDontApplyTlut = set() # image + self.tlutAppliedTextures: str = [] # c name + self.currentTextureName: str | None = None + self.imagesDontApplyTlut: set[bpy.types.Image] = set() # image # Determines if images in CI formats loaded from png files, # should have the TLUT set by the dlist applied on top of them (False), @@ -481,29 +500,31 @@ class F3DContext: # OoT64 and SM64 stores CI images as pngs in actual colors (with the TLUT accounted for), # So for now this can be always True. # In the future this could be an option if for example pngs for CI images were grayscale to represent the palette index. - self.ciImageFilesStoredAsFullColor = True # determines whether to apply tlut to file or import as is + self.ciImageFilesStoredAsFullColor: bool = True # determines whether to apply tlut to file or import as is # This macro has all the tile setting properties, so we reuse it - self.tileSettings = [ + self.tileSettings: list[DPSetTile] = [ DPSetTile("G_IM_FMT_RGBA", "G_IM_SIZ_16b", 5, 0, i, 0, [False, False], 0, 0, [False, False], 0, 0) for i in range(8) ] - self.tileSizes = [DPSetTileSize(i, 0, 0, 32, 32) for i in range(8)] + self.tileSizes: list[DPSetTileSize] = [DPSetTileSize(i, 0, 0, 32, 32) for i in range(8)] - # When a tile is loaded, store dict of tmem : texture - self.tmemDict = {} + # When a tile is loaded, store dict of tmem : texture name + self.tmemDict: dict[int, str] = {} # This should be modified before parsing f3d - self.matrixData = {} # bone name : matrix - self.currentTransformName = None - self.limbToBoneName = {} # limb name (c variable) : bone name (blender vertex group) + self.matrixData: dict[str, mathutils.Matrix] = {} # bone name : matrix + self.currentTransformName: str | None = None + self.limbToBoneName: dict[str, str] = {} # limb name (c variable) : bone name (blender vertex group) # data for Mesh.from_pydata, list of BufferVertex tuples # use BufferVertex to also form uvs / normals / colors - self.verts = [] - self.limbGroups = {} # dict of groupName : vertex indices + self.verts: list[F3DVert] = [] + self.limbGroups: dict[str : list[int]] = {} # dict of groupName : vertex indices - self.lights = Lights("lights_context") + self.lights: Lights = Lights("lights_context") + + # Here these are ints, but when parsing the values will be normalized. self.lights.l = [ Light([0, 0, 0], [0x28, 0x28, 0x28]), Light([0, 0, 0], [0x28, 0x28, 0x28]), @@ -514,10 +535,29 @@ class F3DContext: Light([0, 0, 0], [0x28, 0x28, 0x28]), ] self.lights.a = Ambient([0, 0, 0]) - self.numLights = 0 - self.lightData = {} # (color, normal) : list of blender light objects + self.numLights: int = 0 + self.lightData: dict[Light, bpy.types.Object] = {} # Light : blender light object + + """ + Restarts context, but keeps cached materials/textures. + Warning: calls initContext, make sure to save/restore preserved fields + """ + + def clearGeometry(self): + savedMaterialDict = self.materialDict + savedTextureData = self.textureData + savedTlutAppliedTextures = self.tlutAppliedTextures + savedImagesDontApplyTlut = self.imagesDontApplyTlut + savedLightData = self.lightData + + self.initContext() + + self.materialDict = savedMaterialDict + self.textureData = savedTextureData + self.tlutAppliedTextures = savedTlutAppliedTextures + self.imagesDontApplyTlut = savedImagesDontApplyTlut + self.lightData = savedLightData - # MAKE SURE TO CALL THIS BETWEEN parseF3D() CALLS def clearMaterial(self): mat = self.mat() @@ -529,15 +569,14 @@ class F3DContext: mat.set_key = False mat.set_k0_5 = False - mat.prim_color = [1, 1, 1, 1] - mat.env_color = [1, 1, 1, 1] - mat.blend_color = [1, 1, 1, 1] for i in range(1, 8): setattr(mat, "f3d_light" + str(i), None) mat.tex0.tex = None mat.tex1.tex = None mat.tex0.tex_set = False mat.tex1.tex_set = False + mat.tex0.autoprop = False + mat.tex1.autoprop = False mat.tex0.tex_format = "RGBA16" mat.tex1.tex_format = "RGBA16" @@ -566,7 +605,7 @@ class F3DContext: mat.presetName = "Custom" - def mat(self): + def mat(self) -> F3DMaterialProperty: return self.materialContext.f3d_mat def vertexFormatPatterns(self, data): @@ -592,7 +631,7 @@ class F3DContext: def setCurrentTransform(self, name): self.currentTransformName = name - def getTransformedVertex(self, index): + def getTransformedVertex(self, index: int): bufferVert = self.vertexBuffer[index] # NOTE: The groupIndex here does NOT correspond to a vertex group, but to the name of the limb (c variable) @@ -605,30 +644,33 @@ class F3DContext: mat = self.mat() f3dVert = bufferVert.f3dVert - position = transform @ mathutils.Vector(f3dVert[0]) + position = transform @ mathutils.Vector(f3dVert.position) if mat.tex0.tex is not None: - uv = [convertF3DUV(f3dVert[1][i], mat.tex0.tex.size[i]) for i in range(2)] - # uv = [1 - (f3dVert[1][i] / (self.materialContext.f3d_mat.tex0.tex.size[i] * 32)) for i in range(2)] - # uv = [((f3dVert[1][i] / 32) + 0.5) / mat.tex0.tex.size[i] for i in range(2)] + texDimensions = mat.tex0.tex.size + elif mat.tex0.use_tex_reference: + texDimensions = mat.tex0.tex_reference_size + elif mat.tex1.tex is not None: + texDimensions = mat.tex1.tex.size + elif mat.tex1.use_tex_reference: + texDimensions = mat.tex1.tex_reference_size else: - uv = [convertF3DUV(f3dVert[1][i], 32) for i in range(2)] - # uv = [1 - (f3dVert[1][i] / (32 * 32)) for i in range(2)] - # uv = [((f3dVert[1][i] / 32) + 0.5) / 32 for i in range(2)] + texDimensions = [32, 32] + + uv = [convertF3DUV(f3dVert.uv[i], texDimensions[i]) for i in range(2)] uv[1] = 1 - uv[1] color = [ value / 256 if value > 0 - else int.from_bytes(value.to_bytes(1, "big", signed=True), "big", signed=False) / 256 - for value in f3dVert[2] + else int.from_bytes(round(value).to_bytes(1, "big", signed=True), "big", signed=False) / 256 + for value in f3dVert.getColorOrNormal() ] - normal = bytesToNormal(f3dVert[2][:3]) + [0] + normal = bytesToNormal(f3dVert.getColorOrNormal()[:3]) + [0] normal = (transform.inverted().transposed() @ mathutils.Vector(normal)).normalized()[:3] - # This is not usual format of f3dVert, but we need separate color/normal data. # NOTE: The groupIndex here does NOT correspond to a vertex group, but to the name of the limb (c variable) - return BufferVertex([position, uv, color, normal], bufferVert.groupIndex, bufferVert.materialIndex) + return BufferVertex(F3DVert(position, uv, color, normal), bufferVert.groupIndex, bufferVert.materialIndex) def addVertices(self, num, start, vertexDataName, vertexDataOffset): vertexData = self.vertexData[vertexDataName] @@ -640,7 +682,7 @@ class F3DContext: if start + count > len(self.vertexBuffer): raise PluginError( "Vertex buffer of size " - + len(self.vertexBuffer) + + str(len(self.vertexBuffer)) + " too small, attempting load into " + str(start) + ", " @@ -659,14 +701,14 @@ class F3DContext: if tileSettings.tmem in self.tmemDict: textureName = self.tmemDict[tileSettings.tmem] self.loadTexture(dlData, textureName, region, tileSettings, False) - self.applyTileToMaterial(0, tileSettings, tileSizeSettings) + self.applyTileToMaterial(0, tileSettings, tileSizeSettings, dlData) tileSettings = self.tileSettings[1] tileSizeSettings = self.tileSizes[1] if tileSettings.tmem in self.tmemDict: textureName = self.tmemDict[tileSettings.tmem] self.loadTexture(dlData, textureName, region, tileSettings, False) - self.applyTileToMaterial(1, tileSettings, tileSizeSettings) + self.applyTileToMaterial(1, tileSettings, tileSizeSettings, dlData) self.applyLights() @@ -690,16 +732,20 @@ class F3DContext: for i in range(int(len(indices) / 3)): self.triMatIndices.append(self.lastMaterialIndex) - def getMaterialIndex(self): - # We do this for now to update tile settings for S and T. - # Right now we don't handle those, so we need the auto-calculator to set it correctly. - overrideContext = bpy.context.copy() - overrideContext["material"] = self.materialContext - bpy.ops.material.update_f3d_nodes(overrideContext) + # Add items to this tuple in child classes + def getMaterialKey(self, material: bpy.types.Material): + return material.f3d_mat.key() - for material in self.materials: - if propertyGroupEquals(self.materialContext.f3d_mat, material.f3d_mat): + def getMaterialIndex(self): + + key = self.getMaterialKey(self.materialContext) + if key in self.materialDict: + material = self.materialDict[key] + if material in self.materials: return self.materials.index(material) + else: + self.materials.append(material) + return len(self.materials) - 1 self.addMaterial() return len(self.materials) - 1 @@ -710,25 +756,31 @@ class F3DContext: return name return None + # override this to handle applying tlut to other texture + # ex. in oot, apply to flipbook textures + def handleApplyTLUT( + self, + material: bpy.types.Material, + texProp: TextureProperty, + tlut: bpy.types.Image, + index: int, + ): + self.applyTLUT(texProp.tex, tlut) + self.tlutAppliedTextures.append(texProp.tex) + + # we only want to apply tlut to an existing image under specific conditions. + # however we always want to record the changing tlut for texture references. def applyTLUTToIndex(self, index): mat = self.mat() texProp = getattr(mat, "tex" + str(index)) combinerUses = all_combiner_uses(mat) - if ( - combinerUses["Texture " + str(index)] - and (texProp.tex is not None or texProp.use_tex_reference) - and texProp.tex_set - and texProp.tex_format[:2] == "CI" - and (texProp.tex not in self.tlutAppliedTextures or texProp.use_tex_reference) - and ( - texProp.tex not in self.imagesDontApplyTlut or not self.ciImageFilesStoredAsFullColor - ) # oot currently stores CI textures in full color pngs - ): + if texProp.tex_format[:2] == "CI": # Only handles TLUT at 256 tlutName = self.tmemDict[256] if 256 in self.tmemDict and tlutName is not None: tlut = self.textureData[tlutName] + # print(f"TLUT: {tlutName}, {isinstance(tlut, F3DTextureReference)}") if isinstance(tlut, F3DTextureReference) or texProp.use_tex_reference: if not texProp.use_tex_reference: texProp.use_tex_reference = True @@ -745,9 +797,19 @@ class F3DContext: texProp.pal_reference = tlutName texProp.pal_reference_size = min(tlut.size[0] * tlut.size[1], 256) - else: - self.applyTLUT(texProp.tex, tlut) - self.tlutAppliedTextures.append(texProp.tex) + if ( + not isinstance(tlut, F3DTextureReference) + and combinerUses["Texture " + str(index)] + and (texProp.tex is not None) + and texProp.tex_set + and (texProp.tex not in self.tlutAppliedTextures or texProp.use_tex_reference) + and ( + texProp.tex not in self.imagesDontApplyTlut or not self.ciImageFilesStoredAsFullColor + ) # oot currently stores CI textures in full color pngs + ): + # print(f"Apply tlut {tlutName} ({str(tlut)}) to {self.getImageName(texProp.tex)}") + # print(f"Size: {str(tlut.size[0])} x {str(tlut.size[1])}, Data: {str(len(tlut.pixels))}") + self.handleApplyTLUT(self.materialContext, texProp, tlut, index) else: print("Ignoring TLUT.") @@ -760,11 +822,17 @@ class F3DContext: self.applyTLUTToIndex(0) self.applyTLUTToIndex(1) - material = self.materialContext.copy() - overrideContext = bpy.context.copy() - overrideContext["material"] = material - bpy.ops.material.update_f3d_nodes(overrideContext) + materialCopy = self.materialContext.copy() + + # disable flag so that we can lock it, then unlock after update + materialCopy.f3d_update_flag = False + + with F3DMaterial_UpdateLock(materialCopy) as material: + update_node_values_of_material(material, bpy.context) + material.f3d_mat.presetName = "Custom" + self.materials.append(material) + self.materialDict[self.getMaterialKey(materialCopy)] = materialCopy self.materialChanged = False self.postMaterialChanged() @@ -1065,9 +1133,8 @@ class F3DContext: else int(lightCountString[-1:]) ) - def getLightObj(self, light): - lightKey = (tuple(light.color), tuple(light.normal)) - if lightKey not in self.lightData: + def getLightObj(self, light: Light): + if light not in self.lightData: lightName = "Light" bLight = bpy.data.lights.new(lightName, "SUN") lightObj = bpy.data.objects.new(lightName, bLight) @@ -1082,15 +1149,15 @@ class F3DContext: bLight.color = light.color bpy.context.scene.collection.objects.link(lightObj) - self.lightData[lightKey] = lightObj - return self.lightData[lightKey] + self.lightData[light] = lightObj + return self.lightData[light] def applyLights(self): mat = self.mat() allCombinerUses = all_combiner_uses(mat) if allCombinerUses["Shade"] and mat.rdp_settings.g_lighting and mat.set_lights: mat.use_default_lighting = False - mat.ambient_light_color = self.lights.a.color + ([1] if len(self.lights.a.color) == 3 else []) + mat.ambient_light_color = tuple(self.lights.a.color[:]) + ((1,) if len(self.lights.a.color) == 3 else ()) for i in range(self.numLights): lightObj = self.getLightObj(self.lights.l[i]) @@ -1100,7 +1167,9 @@ class F3DContext: self.mat().set_lights = True lightIndex = self.getLightIndex(command.params[0]) colorData = math_eval(command.params[1], self.f3d) - color = [((colorData >> 24) & 0xFF) / 0xFF, ((colorData >> 16) & 0xFF) / 0xFF, ((colorData >> 8) & 0xFF) / 0xFF] + color = mathutils.Vector( + [((colorData >> 24) & 0xFF) / 0xFF, ((colorData >> 16) & 0xFF) / 0xFF, ((colorData >> 8) & 0xFF) / 0xFF] + ) if lightIndex != self.numLights + 1: self.lights.l[lightIndex - 1].color = color @@ -1156,17 +1225,17 @@ class F3DContext: def createLights(self, data, lightsName): numLights, lightValues = parseLightsData(data, lightsName, self) - ambientColor = gammaInverse([value / 255 for value in lightValues[0:3]]) + ambientColor = mathutils.Vector(gammaInverse([value / 255 for value in lightValues[0:3]])) lightList = [] for i in range(numLights): - color = gammaInverse([value / 255 for value in lightValues[3 + 6 * i : 3 + 6 * i + 3]]) - direction = bytesToNormal(lightValues[3 + 6 * i + 3 : 3 + 6 * i + 6]) + color = mathutils.Vector(gammaInverse([value / 255 for value in lightValues[3 + 6 * i : 3 + 6 * i + 3]])) + direction = mathutils.Vector(bytesToNormal(lightValues[3 + 6 * i + 3 : 3 + 6 * i + 6])) lightList.append(Light(color, direction)) while len(lightList) < 7: - lightList.append(Light([0, 0, 0], [0x28, 0x28, 0x28])) + lightList.append(Light(mathutils.Vector([0, 0, 0]), mathutils.Vector([0x28, 0x28, 0x28]))) # normally a and l are Ambient and Light objects, # but here they will be a color and blender light object array. @@ -1298,7 +1367,32 @@ class F3DContext: self.setTile([0, 0, 0, 256, "G_TX_LOADTILE", 0, 0, 0, 0, 0, 0, 0], dlData) self.loadTLUT(["G_TX_LOADTILE", count], dlData) - def applyTileToMaterial(self, index, tileSettings, tileSizeSettings): + # override this in a child context to handle texture references. + # keep material parameter for use by parent. + # ex. In OOT, you can call self.loadTexture() here based on texture arrays. + def handleTextureReference( + self, + name: str, + image: F3DTextureReference, + material: bpy.types.Material, + index: int, + tileSettings: DPSetTile, + data: str, + ): + texProp = getattr(material.f3d_mat, "tex" + str(index)) + texProp.tex = None + texProp.use_tex_reference = True + texProp.tex_reference = name + size = texProp.tex_reference_size + + # add to this by overriding in a parent context, to handle clearing settings related to previous texture references. + def handleTextureValue(self, material: bpy.types.Material, image: bpy.types.Image, index: int): + texProp = getattr(material.f3d_mat, "tex" + str(index)) + texProp.tex = image + texProp.use_tex_reference = False + size = texProp.tex.size + + def applyTileToMaterial(self, index, tileSettings, tileSizeSettings, dlData: str): mat = self.mat() texProp = getattr(mat, "tex" + str(index)) @@ -1306,23 +1400,13 @@ class F3DContext: name = self.tmemDict[tileSettings.tmem] image = self.textureData[name] if isinstance(image, F3DTextureReference): - texProp.tex = None - texProp.use_tex_reference = True - texProp.tex_reference = name - size = texProp.tex_reference_size + self.handleTextureReference(name, image, self.materialContext, index, tileSettings, dlData) else: - texProp.tex = image - texProp.use_tex_reference = False - size = texProp.tex.size + self.handleTextureValue(self.materialContext, image, index) texProp.tex_set = True # TODO: Handle low/high for image files? if texProp.use_tex_reference: - # texProp.autoprop = False - # texProp.S.low = round(tileSizeSettings.uls / (2 ** self.f3d.G_TEXTURE_IMAGE_FRAC), 3) - # texProp.T.low = round(tileSizeSettings.ult / (2 ** self.f3d.G_TEXTURE_IMAGE_FRAC), 3) - # texProp.S.high = round(tileSizeSettings.lrs / (2 ** self.f3d.G_TEXTURE_IMAGE_FRAC), 3) - # texProp.T.high = round(tileSizeSettings.lrt / (2 ** self.f3d.G_TEXTURE_IMAGE_FRAC), 3) # WARNING: Inferring texture size from tile size. texProp.tex_reference_size = [ @@ -1330,30 +1414,25 @@ class F3DContext: int(round(tileSizeSettings.lrt / (2**self.f3d.G_TEXTURE_IMAGE_FRAC) + 1)), ] - # if texProp.S.low == 0 and texProp.T.low == 0 and \ - # texProp.S.high == size[0] - 1 and \ - # (texProp.use_tex_reference or texProp.T.high == size[1] - 1): - # texProp.autoProp = True - # else: - # print(str(texProp.S.low) + " " + str(texProp.T.low) + " " + str(texProp.S.high) + " " + str(texProp.T.high)) - # print(str(size[0]-1) + " " + str(size[1]-1)) - texProp.tex_format = tileSettings.fmt[8:].replace("_", "") + tileSettings.siz[8:-1].replace("_", "") texProp.S.clamp = tileSettings.cms[0] texProp.S.mirror = tileSettings.cms[1] + texProp.S.mask = tileSettings.masks + texProp.S.shift = tileSettings.shifts texProp.T.clamp = tileSettings.cmt[0] texProp.T.mirror = tileSettings.cmt[1] + texProp.T.mask = tileSettings.maskt + texProp.T.shift = tileSettings.shiftt - # TODO: Handle S and T properties - - # Override this to handle game specific references. - def handleTextureName(self, textureName): - return textureName + texProp.S.low = round(tileSizeSettings.uls / (2**self.f3d.G_TEXTURE_IMAGE_FRAC), 3) + texProp.T.low = round(tileSizeSettings.ult / (2**self.f3d.G_TEXTURE_IMAGE_FRAC), 3) + texProp.S.high = round(tileSizeSettings.lrs / (2**self.f3d.G_TEXTURE_IMAGE_FRAC), 3) + texProp.T.high = round(tileSizeSettings.lrt / (2**self.f3d.G_TEXTURE_IMAGE_FRAC), 3) def loadTexture(self, data, name, region, tileSettings, isLUT): - textureName = self.handleTextureName(name) + textureName = name if textureName in self.textureData: return self.textureData[textureName] @@ -1383,21 +1462,26 @@ class F3DContext: def loadTLUT(self, params, dlData): tileSettings = self.getTileSettings(params[0]) name = self.currentTextureName - textureName = self.handleTextureName(name) + textureName = name self.tmemDict[tileSettings.tmem] = textureName tlut = self.loadTexture(dlData, textureName, [0, 0, 16, 16], tileSettings, True) self.materialChanged = True def applyTLUT(self, image, tlut): + invalidIndicesDetected = False for i in range(int(len(image.pixels) / 4)): lutIndex = int(round(image.pixels[4 * i] * 255)) newValues = tlut.pixels[4 * lutIndex : 4 * (lutIndex + 1)] if len(newValues) < 4: - print("Invalid lutIndex " + str(lutIndex)) + # print("Invalid LUT Index " + str(lutIndex)) + invalidIndicesDetected = True else: image.pixels[4 * i : 4 * (i + 1)] = newValues + if invalidIndicesDetected: + print("Invalid LUT Indices detected.") + def processCommands(self, dlData, dlName, dlCommands): callStack = [F3DParsedCommands(dlName, dlCommands, 0)] while len(callStack) > 0: @@ -1462,11 +1546,11 @@ class F3DContext: mat.fog_position = [math_eval(command.params[0], self.f3d), math_eval(command.params[1], self.f3d)] mat.set_fog = True elif command.name == "gsSPTexture" or command.name == "gsSPTextureL": + # scale_autoprop should always be false (set in init) + # This prevents issues with material caching where updating nodes on a material causes its key to change if command.params[0] == 0xFFFF and command.params[1] == 0xFFFF: - mat.scale_autoprop = True mat.tex_scale = (1, 1) else: - mat.scale_autoprop = False mat.tex_scale = [ math_eval(command.params[0], self.f3d) / (2**16), math_eval(command.params[1], self.f3d) / (2**16), @@ -1641,14 +1725,21 @@ class F3DContext: def processDLName(self, name): return name - def createMesh(self, obj, removeDoubles, importNormals): + def deleteMaterialContext(self): + if self.materialContext is not None: + bpy.data.materials.remove(self.materialContext) + else: + raise PluginError("Attempting to delete material context that is None.") + + # if deleteMaterialContext is False, then manually call self.deleteMaterialContext() later. + def createMesh(self, obj, removeDoubles, importNormals, callDeleteMaterialContext: bool): mesh = obj.data if len(self.verts) % 3 != 0: print(len(self.verts)) raise PluginError("Number of verts in mesh not divisible by 3, currently " + str(len(self.verts))) triangleCount = int(len(self.verts) / 3) - verts = [f3dVert[0] for f3dVert in self.verts] + verts = [f3dVert.position for f3dVert in self.verts] faces = [[3 * i + j for j in range(3)] for i in range(triangleCount)] print("Vertices: " + str(len(self.verts)) + ", Triangles: " + str(triangleCount)) @@ -1661,7 +1752,7 @@ class F3DContext: if importNormals: mesh.use_auto_smooth = True - mesh.normals_split_custom_set([f3dVert[3] for f3dVert in self.verts]) + mesh.normals_split_custom_set([f3dVert.normal for f3dVert in self.verts]) for groupName, indices in self.limbGroups.items(): group = obj.vertex_groups.new(name=self.limbToBoneName[groupName]) @@ -1673,11 +1764,11 @@ class F3DContext: for i in range(len(mesh.loops)): # This should be okay, since we aren't trying to optimize vertices # There will be one loop for every vertex - uv_layer[i].uv = self.verts[i][1] + uv_layer[i].uv = self.verts[i].uv # if self.materialContext.f3d_mat.rdp_settings.g_lighting: - color_layer[i].color = self.verts[i][2] - alpha_layer[i].color = [self.verts[i][2][3]] * 3 + [1] + color_layer[i].color = self.verts[i].color + alpha_layer[i].color = [self.verts[i].color[3]] * 3 + [1] if bpy.context.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") @@ -1695,8 +1786,6 @@ class F3DContext: bpy.ops.mesh.remove_doubles() bpy.ops.object.mode_set(mode="OBJECT") - bpy.data.materials.remove(self.materialContext) - obj.location = bpy.context.scene.cursor.location i = 0 @@ -1704,6 +1793,11 @@ class F3DContext: lightObj.location = bpy.context.scene.cursor.location + mathutils.Vector((i, 0, 0)) i += 1 + self.clearGeometry() + + if callDeleteMaterialContext: + self.deleteMaterialContext() + class ParsedMacro: def __init__(self, name, params): @@ -1718,7 +1812,17 @@ class ParsedMacro: # we distinguish these because there is no guarantee of bone order in blender, # so we usually rely on alphabetical naming. # This means changing the c variable names. -def parseF3D(dlData, dlName, obj, transformMatrix, limbName, boneName, drawLayerPropName, drawLayer, f3dContext): +def parseF3D( + dlData: str, + dlName: str, + transformMatrix: mathutils.Matrix, + limbName: str, + boneName: str, + drawLayerPropName: str, + drawLayer: str, + f3dContext: F3DContext, + callClearMaterial: bool, +): f3dContext.matrixData[limbName] = transformMatrix f3dContext.setCurrentTransform(limbName) @@ -1728,8 +1832,13 @@ def parseF3D(dlData, dlName, obj, transformMatrix, limbName, boneName, drawLayer # vertexGroup = getOrMakeVertexGroup(obj, boneName) # groupIndex = vertexGroup.index - dlCommands = parseDLData(dlData, dlName) - f3dContext.processCommands(dlData, dlName, dlCommands) + processedDLName = f3dContext.processDLName(dlName) + if processedDLName is not None: + dlCommands = parseDLData(dlData, processedDLName) + f3dContext.processCommands(dlData, processedDLName, dlCommands) + + if callClearMaterial: + f3dContext.clearMaterial() def parseDLData(dlData, dlName): @@ -1761,7 +1870,7 @@ def getVertexDataStart(vertexDataParam, f3d): return matchResult.group(1), offset -def parseVertexData(dlData, vertexDataName, f3dContext): +def parseVertexData(dlData: str, vertexDataName: str, f3dContext: F3DContext): if vertexDataName in f3dContext.vertexData: return f3dContext.vertexData[vertexDataName] @@ -1781,16 +1890,26 @@ def parseVertexData(dlData, vertexDataName, f3dContext): patterns = f3dContext.vertexFormatPatterns(data) vertexData = [] for pattern in patterns: + # Note that color is None here, as we are just parsing vertex data. + # The same values should be used for color and normal, but getColorOrNormal() will be used later + # which returns whichever value is not None between the two. + + # When loaded into the vertex buffer and transformed, the actual normal/color will be calculated. vertexData = [ - ( - [math_eval(match.group(1), f3d), math_eval(match.group(2), f3d), math_eval(match.group(3), f3d)], - [math_eval(match.group(4), f3d), math_eval(match.group(5), f3d)], - [ - math_eval(match.group(6), f3d), - math_eval(match.group(7), f3d), - math_eval(match.group(8), f3d), - math_eval(match.group(9), f3d), - ], + F3DVert( + mathutils.Vector( + [math_eval(match.group(1), f3d), math_eval(match.group(2), f3d), math_eval(match.group(3), f3d)] + ), + mathutils.Vector([math_eval(match.group(4), f3d), math_eval(match.group(5), f3d)]), + None, + mathutils.Vector( + [ + math_eval(match.group(6), f3d), + math_eval(match.group(7), f3d), + math_eval(match.group(8), f3d), + math_eval(match.group(9), f3d), + ] + ), ) for match in re.finditer(pattern, data, re.DOTALL) ] @@ -1858,16 +1977,10 @@ def CI4toRGBA32(value): return [value / 255, value / 255, value / 255, 1] -class F3DTextureReference: - def __init__(self, name, width): - self.name = name - self.width = width - - def parseTextureData(dlData, textureName, f3dContext, imageFormat, imageSize, width, basePath, isLUT, f3d): matchResult = re.search( - "([A-Za-z0-9\_]+)\s*" + re.escape(textureName) + "\s*\[\s*[0-9x]*\s*\]\s*=\s*\{([^\}]*)\s\}\s*;\s*", + r"([A-Za-z0-9\_]+)\s*" + re.escape(textureName) + r"\s*\[\s*[0-9a-fA-Fx]*\s*\]\s*=\s*\{([^\}]*)\s*\}\s*;\s*", dlData, re.DOTALL, ) @@ -2038,8 +2151,9 @@ def getImportData(filepaths): return data -def importMeshC(filepaths, name, scale, removeDoubles, importNormals, drawLayer, f3dContext): - data = getImportData(filepaths) +def importMeshC( + data: str, name: str, scale: float, removeDoubles: bool, importNormals: bool, drawLayer: str, f3dContext: F3DContext +) -> bpy.types.Object: # Create new skinned mesh mesh = bpy.data.meshes.new(name + "_mesh") @@ -2049,12 +2163,11 @@ def importMeshC(filepaths, name, scale, removeDoubles, importNormals, drawLayer, f3dContext.mat().draw_layer.oot = drawLayer transformMatrix = mathutils.Matrix.Scale(1 / scale, 4) - parseF3D(data, name, obj, transformMatrix, name, name, "oot", drawLayer, f3dContext) - - f3dContext.clearMaterial() - f3dContext.createMesh(obj, removeDoubles, importNormals) + parseF3D(data, name, transformMatrix, name, name, "oot", drawLayer, f3dContext, True) + f3dContext.createMesh(obj, removeDoubles, importNormals, True) applyRotation([obj], math.radians(-90), "X") + return obj class F3D_ImportDL(bpy.types.Operator): @@ -2082,10 +2195,10 @@ class F3D_ImportDL(bpy.types.Operator): f3dType = context.scene.f3d_type isHWv1 = context.scene.isHWv1 - importPaths = [importPath] + data = getImportData([importPath]) importMeshC( - importPaths, + data, name, scaleValue, removeDoubles, diff --git a/fast64_internal/f3d/f3d_writer.py b/fast64_internal/f3d/f3d_writer.py index 7b4c002..5d59086 100644 --- a/fast64_internal/f3d/f3d_writer.py +++ b/fast64_internal/f3d/f3d_writer.py @@ -1,3 +1,4 @@ +from typing import Union import functools import bpy, bmesh, mathutils, os, re, copy, math from math import pi, ceil @@ -14,6 +15,7 @@ from .f3d_material import ( bitSizeDict, texBitSizeOf, texFormatOf, + TextureProperty, ) from .f3d_gbi import * from .f3d_gbi import _DPLoadTextureBlock @@ -818,13 +820,50 @@ def getNewIndices(existingIndices, bufferStart): return newIndices -class BufferVertex: - def __init__(self, f3dVert, groupIndex, materialIndex): - self.f3dVert = f3dVert - self.groupIndex = groupIndex - self.materialIndex = materialIndex +# Color and normal are separate, since for parsing, the normal must be transformed into +# bone/object space while the color should just be a regular conversion. +class F3DVert: + def __init__( + self, + position: mathutils.Vector, + uv: mathutils.Vector, + color: mathutils.Vector | None, # 4 components + normal: mathutils.Vector | None, # 4 components + ): + self.position: mathutils.Vector = position + self.uv: mathutils.Vector = uv + self.color: mathutils.Vector | None = color + self.normal: mathutils.Vector | None = normal def __eq__(self, other): + if not isinstance(other, F3DVert): + return False + return ( + self.position == other.position + and self.uv == other.uv + and self.color == other.color + and self.normal == other.normal + ) + + def getColorOrNormal(self): + if self.color is None and self.normal is None: + raise PluginError("An F3D vert has neither a color or a normal.") + elif self.color is not None: + return self.color + else: + return self.normal + + +# groupIndex is either a vertex group (writing), or name of c variable identifying a transform group, like a limb (parsing) +class BufferVertex: + def __init__(self, f3dVert: F3DVert, groupIndex: int | str, materialIndex: int): + self.f3dVert: F3DVert = f3dVert + self.groupIndex: int | str = groupIndex + self.materialIndex: int = materialIndex + + def __eq__(self, other): + if not isinstance(other, BufferVertex): + return False return ( self.f3dVert == other.f3dVert and self.groupIndex == other.groupIndex @@ -871,12 +910,12 @@ class TriangleConverter: def __init__( self, triConverterInfo: TriangleConverterInfo, - texDimensions, + texDimensions: tuple[int, int], material: bpy.types.Material, currentGroupIndex, triList, vtxList, - existingVertexData, + existingVertexData: list[BufferVertex], existingVertexMaterialRegions, ): self.triConverterInfo = triConverterInfo @@ -884,11 +923,9 @@ class TriangleConverter: self.originalGroupIndex = currentGroupIndex # Existing data assumed to be already loaded in. + self.vertBuffer: list[BufferVertex] = [] if existingVertexData is not None: - # [(position, uv, colorOrNormal)] - self.vertBuffer = existingVertexData - else: - self.vertBuffer = [] + self.vertBuffer: list[BufferVertex] = existingVertexData self.existingVertexMaterialRegions = existingVertexMaterialRegions self.bufferStart = len(self.vertBuffer) self.vertexBufferTriangles = [] # [(index0, index1, index2)] @@ -916,8 +953,8 @@ class TriangleConverter: return bufferVert in self.vertBuffer[self.bufferStart :] - def getSortedBuffer(self): - limbVerts = {} + def getSortedBuffer(self) -> dict[int, list[BufferVertex]]: + limbVerts: dict[int, list[BufferVertex]] = {} for bufferVert in self.vertBuffer[self.bufferStart :]: if bufferVert.groupIndex not in limbVerts: limbVerts[bufferVert.groupIndex] = [] @@ -945,9 +982,9 @@ class TriangleConverter: self.vtxList.vertices.append( convertVertexData( self.triConverterInfo.mesh, - bufferVert.f3dVert[0], - bufferVert.f3dVert[1], - bufferVert.f3dVert[2], + bufferVert.f3dVert.position, + bufferVert.f3dVert.uv, + bufferVert.f3dVert.getColorOrNormal(), self.texDimensions, self.triConverterInfo.getTransformMatrix(bufferVert.groupIndex), self.isPointSampled, @@ -979,9 +1016,9 @@ class TriangleConverter: self.vtxList.vertices.append( convertVertexData( self.triConverterInfo.mesh, - bufferVert.f3dVert[0], - bufferVert.f3dVert[1], - bufferVert.f3dVert[2], + bufferVert.f3dVert.position, + bufferVert.f3dVert.uv, + bufferVert.f3dVert.getColorOrNormal(), self.texDimensions, self.triConverterInfo.getTransformMatrix(bufferVert.groupIndex), self.isPointSampled, @@ -1046,11 +1083,11 @@ def getF3DVert(loop: bpy.types.MeshLoop, face, convertInfo: LoopConvertInfo, mes uv[:] = [field if not math.isnan(field) else 0 for field in uv] uv[1] = 1 - uv[1] uv = uv.freeze() - colorOrNormal = getLoopColorOrNormal( + color, normal = getLoopColorOrNormal( loop, face, convertInfo.obj.data, convertInfo.obj, convertInfo.exportVertexColors ) - return (position, uv, colorOrNormal) + return F3DVert(position, uv, color, normal) def getLoopNormal(loop: bpy.types.MeshLoop, face, mesh, isFlatShaded): @@ -1198,7 +1235,11 @@ def convertVertexData( # However, Point samples from the corner. # Thus we add 0.5 to the UV only if bilinear filtering. # see section 13.7.5.3 in programming manual. - pixelOffset = (0, 0) if isPointSampled else (0.5 / tex_scale[0], 0.5 / tex_scale[1]) + pixelOffset = ( + (0, 0) + if (isPointSampled or tex_scale[0] == 0 or tex_scale[1] == 0) + else (0.5 / tex_scale[0], 0.5 / tex_scale[1]) + ) uv = [ convertFloatToFixed16(loopUV[0] * texDimensions[0] - pixelOffset[0]), @@ -1246,16 +1287,18 @@ def getLoopColor(loop: bpy.types.MeshLoop, mesh, mat_ver): else: normalizedA = 1 - return (normalizedRGB[0], normalizedRGB[1], normalizedRGB[2], normalizedA) + return mathutils.Vector((normalizedRGB[0], normalizedRGB[1], normalizedRGB[2], normalizedA)) -def getLoopColorOrNormal(loop: bpy.types.MeshLoop, face, mesh, obj, exportVertexColors): +def getLoopColorOrNormal( + loop: bpy.types.MeshLoop, face, mesh: bpy.types.Mesh, obj: bpy.types.Object, exportVertexColors: bool +) -> tuple[mathutils.Vector, None] | tuple[None, mathutils.Vector]: material = obj.material_slots[face.material_index].material isFlatShaded = checkIfFlatShaded(material) if exportVertexColors: - return getLoopColor(loop, mesh, material.mat_ver) + return getLoopColor(loop, mesh, material.mat_ver), None else: - return getLoopNormal(loop, face, mesh, isFlatShaded) + return None, getLoopNormal(loop, face, mesh, isFlatShaded) def createTriangleCommands(triangles, vertexBuffer, useSP2Triangle): @@ -1593,7 +1636,7 @@ def saveOrGetF3DMaterial(material, fModel, obj, drawLayer, convertTextureData): ] ) - fModel.onMaterialCommandsBuilt(fMaterial.material, fMaterial.revert, material, drawLayer) + fModel.onMaterialCommandsBuilt(fMaterial, material, drawLayer) # End Display List # For dynamic calls, materials will be called as functions and should not end the DL. @@ -1617,6 +1660,33 @@ def saveOrGetF3DMaterial(material, fModel, obj, drawLayer, convertTextureData): return fMaterial, texDimensions +def getTextureName(texProp: TextureProperty, fModelName: str, overrideName: str) -> str: + tex = texProp.tex + texFormat = texProp.tex_format + if not texProp.use_tex_reference: + if tex.filepath == "": + name = tex.name + else: + name = tex.filepath + else: + name = texProp.tex_reference + texName = ( + fModelName + + "_" + + (getNameFromPath(name, True) + "_" + texFormat.lower() if overrideName is None else overrideName) + ) + + return texName + + +def getTextureNameTexRef(texProp: TextureProperty, fModelName: str) -> str: + texFormat = texProp.tex_format + name = texProp.tex_reference + texName = fModelName + "_" + (getNameFromPath(name, True) + "_" + texFormat.lower()) + + return texName + + def saveTextureIndex( propName, fModel, @@ -1653,18 +1723,7 @@ def saveTextureIndex( isCITexture = texFormat[:2] == "CI" palFormat = texProp.ci_format if isCITexture else "" - if not texProp.use_tex_reference: - if tex.filepath == "": - name = tex.name - else: - name = tex.filepath - else: - name = texProp.tex_reference - texName = ( - fModel.name - + "_" - + (getNameFromPath(name, True) + "_" + texFormat.lower() if overrideName is None else overrideName) - ) + texName = getTextureName(texProp, fModel.name, overrideName) if tileSettingsOverride is not None: tileSettings = tileSettingsOverride[index] @@ -1726,8 +1785,9 @@ def saveTextureIndex( fImage = FImage(texProp.tex_reference, None, None, width, height, None, False) fPalette = FImage(texProp.pal_reference, None, None, 1, texProp.pal_reference_size, None, False) else: - fImage, fPalette = saveOrGetPaletteDefinition( - fMaterial, fModel, tex, texName, texFormat, palFormat, convertTextureData + # fPalette should be an fImage here, since sharedPalette is None + fImage, fPalette, alreadyExists = saveOrGetPaletteAndImageDefinition( + fMaterial, fModel, tex, texName, texFormat, palFormat, convertTextureData, None ) if loadPalettes: @@ -1995,19 +2055,79 @@ def savePaletteLoading(loadTexGfx, revertTexGfx, fPalette, palFormat, pal, color ) -def saveOrGetPaletteDefinition(fMaterial, fModelOrTexRect, image, imageName, texFmt, palFmt, convertTextureData): +class FSharedPalette: + def __init__(self, name): + self.name = name + self.palette = [] + + +def saveOrGetPaletteOnlyDefinition( + fMaterial: FMaterial, + fModel: FModel, + image: bpy.types.Image, + imageName: str, + texFmt: str, + palFmt: str, + convertTextureData: bool, + palette: list[int], +) -> FImage: + + palFormat = texFormatOf[palFmt] + paletteName = checkDuplicateTextureName(fModel, toAlnum(imageName) + "_pal_" + palFmt.lower()) + paletteKey = (image, (palFmt, "PAL")) + paletteFilename = getNameFromPath(imageName, True) + "." + fModel.getTextureSuffixFromFormat(texFmt) + ".pal" + + fPalette = FImage( + paletteName, + palFormat, + "G_IM_SIZ_16b", + 1, + len(palette), + paletteFilename, + convertTextureData, + ) + + if fMaterial.useLargeTextures: + fPalette.isLargeTexture = True + + if convertTextureData: + for color in palette: + fPalette.data.extend(color.to_bytes(2, "big")) + + # print(f"Palette data: {paletteName} - length {len(fPalette.data)}") + + fModel.addTexture(paletteKey, fPalette, fMaterial) + return fPalette + + +def imageAlreadyExists(fModel: FModel, image: bpy.types.Image, texFmt: str, palFmt: str) -> bool: + texFormat = texFormatOf[texFmt] + palFormat = texFormatOf[palFmt] + bitSize = texBitSizeOf[texFmt] + # If image already loaded, return that data. + imageKey = (image, (texFmt, palFmt)) + fImage, fPalette = fModel.getTextureAndHandleShared(imageKey) + return fImage is not None + + +def saveOrGetPaletteAndImageDefinition( + fMaterial, fModelOrTexRect, image, imageName, texFmt, palFmt, convertTextureData, sharedPalette: FSharedPalette +) -> tuple[FImage, FImage, bool]: texFormat = texFormatOf[texFmt] palFormat = texFormatOf[palFmt] bitSize = texBitSizeOf[texFmt] # If image already loaded, return that data. - paletteName = toAlnum(imageName) + "_pal_" + palFmt.lower() imageKey = (image, (texFmt, palFmt)) - paletteKey = (image, (palFmt, "PAL")) fImage, fPalette = fModelOrTexRect.getTextureAndHandleShared(imageKey) if fImage is not None: - return fImage, fPalette + # print(f"Image already exists") + return fImage, fPalette, True - palette = [] + # print(f"Size: {str(image.size[0])} x {str(image.size[1])}, Data: {str(len(image.pixels))}") + if sharedPalette is not None: + palette = sharedPalette.palette + else: + palette = [] texture = [] maxColors = 16 if bitSize == "G_IM_SIZ_4b" else 256 if convertTextureData: @@ -2028,7 +2148,13 @@ def saveOrGetPaletteDefinition(fMaterial, fModelOrTexRect, image, imageName, tex if pixelColor not in palette: palette.append(pixelColor) if len(palette) > maxColors: - raise PluginError("Texture " + imageName + " has more than " + str(maxColors) + " colors.") + raise PluginError( + "Texture " + + imageName + + " has more than " + + str(maxColors) + + " colors, or is part of a shared palette with too many colors." + ) texture.append(palette.index(pixelColor)) if image.filepath == "": @@ -2036,7 +2162,7 @@ def saveOrGetPaletteDefinition(fMaterial, fModelOrTexRect, image, imageName, tex else: name = image.filepath filename = getNameFromPath(name, True) + "." + fModelOrTexRect.getTextureSuffixFromFormat(texFmt) + ".inc.c" - paletteFilename = getNameFromPath(name, True) + "." + fModelOrTexRect.getTextureSuffixFromFormat(texFmt) + ".pal" + # paletteFilename = getNameFromPath(name, True) + '.' + \ # fModelOrTexRect.getTextureSuffixFromFormat(palFmt) + '.inc.c' fImage = FImage( @@ -2049,37 +2175,37 @@ def saveOrGetPaletteDefinition(fMaterial, fModelOrTexRect, image, imageName, tex convertTextureData, ) - fPalette = FImage( - checkDuplicateTextureName(fModelOrTexRect, paletteName), - palFormat, - "G_IM_SIZ_16b", - 1, - len(palette), - paletteFilename, - convertTextureData, - ) if fMaterial.useLargeTextures: fImage.isLargeTexture = True - fPalette.isLargeTexture = True - fImage.paletteKey = paletteKey # paletteImage = bpy.data.images.new(paletteName, 1, len(palette)) # paletteImage.pixels = palette # paletteImage.filepath = paletteFilename if convertTextureData: - for color in palette: - fPalette.data.extend(color.to_bytes(2, "big")) - if bitSize == "G_IM_SIZ_4b": fImage.data = compactNibbleArray(texture, image.size[0], image.size[1]) else: fImage.data = bytearray(texture) fModelOrTexRect.addTexture((image, (texFmt, palFmt)), fImage, fMaterial) - fModelOrTexRect.addTexture((image, (palFmt, "PAL")), fPalette, fMaterial) - return fImage, fPalette # , paletteImage + # For shared palettes, paletteName should be the same for the same imageName until + # the next saveOrGetPaletteOnlyDefinition + # Make sure paletteName is read here before saveOrGetPaletteOnlyDefinition is called. + paletteName = checkDuplicateTextureName(fModelOrTexRect, toAlnum(imageName) + "_pal_" + palFmt.lower()) + + if sharedPalette is None: + fPalette = saveOrGetPaletteOnlyDefinition( + fMaterial, fModelOrTexRect, image, imageName, texFmt, palFmt, convertTextureData, palette + ) + paletteKey = (image, (palFmt, "PAL")) + fImage.paletteKey = paletteKey + else: + fPalette = None + fImage.paletteKey = None + + return fImage, fPalette, False # , paletteImage def compactNibbleArray(texture, width, height): diff --git a/fast64_internal/f3d/flipbook.py b/fast64_internal/f3d/flipbook.py new file mode 100644 index 0000000..15b2e60 --- /dev/null +++ b/fast64_internal/f3d/flipbook.py @@ -0,0 +1,332 @@ +import bpy, re +from typing import Any, Callable, Optional +from bpy.utils import register_class, unregister_class +from bpy.app.handlers import persistent +from .f3d_material import all_combiner_uses, update_tex_values_manual, iter_tex_nodes, TextureProperty +from ..utility import prop_split, CollectionProperty +from dataclasses import dataclass + + +@dataclass +class TextureFlipbook: + name: str + exportMode: str + textureNames: list[str] + + +def flipbook_data_to_c(flipbook: TextureFlipbook): + newArrayData = "" + for textureName in flipbook.textureNames: + newArrayData += textureName + ",\n" + return newArrayData + + +def flipbook_to_c(flipbook: TextureFlipbook, isStatic: bool): + newArrayData = "void* " if not isStatic else "static void* " + newArrayData += f"{flipbook.name}[]" + " = { " + newArrayData += flipbook_data_to_c(flipbook) + newArrayData += " };" + return newArrayData + + +def flipbook_2d_to_c(flipbook: TextureFlipbook, isStatic: bool, count: int): + newArrayData = "void* " if not isStatic else "static void* " + newArrayData += f"{flipbook.name}[][{len(flipbook.textureNames)}] = {{ " + newArrayData += ("{ " + flipbook_data_to_c(flipbook) + " },\n") * count + newArrayData += " };" + return newArrayData + + +def usesFlipbook( + material: bpy.types.Material, + flipbookProperty: Any, + index: int, + checkEnable: bool, + checkFlipbookReference: Optional[Callable[[str], bool]], +) -> bool: + texProp = getattr(material.f3d_mat, f"tex{index}") + if all_combiner_uses(material.f3d_mat)["Texture " + str(index)] and texProp.use_tex_reference: + return ( + checkFlipbookReference is not None + and checkFlipbookReference(texProp.tex_reference) + and (not checkEnable or flipbookProperty.enable) + ) + else: + return False + + +class FlipbookImagePointerProperty(bpy.types.PropertyGroup): + image: bpy.props.PointerProperty(type=bpy.types.Image) + name: bpy.props.StringProperty(name="Name", default="gImage") + + +def drawTextureArray(layout: bpy.types.UILayout, textureArray: CollectionProperty, index: int, exportMode: str): + for i in range(len(textureArray)): + drawTextureArrayProperty(layout, textureArray[i], i, index, exportMode) + + addOp = layout.operator(AddFlipbookTexture.bl_idname, text="Add Texture") + addOp.combinerTexIndex = index + addOp.arrayIndex = len(textureArray) + + +def drawTextureArrayProperty( + layout: bpy.types.UILayout, + texturePointer: FlipbookImagePointerProperty, + arrayIndex: int, + texNum: int, + exportMode: str, +): + col = layout.column() + + box = col.box().column() + if exportMode == "Individual": + prop_split(box, texturePointer, "name", "Texture Name") + + box.template_ID(texturePointer, "image", new="image.new", open="image.open") + + row = box.row() + buttons = row.row(align=True) + visualizeOp = buttons.operator(VisualizeFlipbookTexture.bl_idname, text="Visualize", icon="VIEW_CAMERA") + visualizeOp.arrayIndex = arrayIndex + visualizeOp.combinerTexIndex = texNum + + addOp = buttons.operator(AddFlipbookTexture.bl_idname, text="", icon="ADD") + addOp.arrayIndex = arrayIndex + 1 + addOp.combinerTexIndex = texNum + + removeOp = buttons.operator(RemoveFlipbookTexture.bl_idname, text="", icon="REMOVE") + removeOp.arrayIndex = arrayIndex + removeOp.combinerTexIndex = texNum + + moveUp = buttons.operator(MoveFlipbookTexture.bl_idname, text="", icon="TRIA_UP") + moveUp.arrayIndex = arrayIndex + moveUp.offset = -1 + moveUp.combinerTexIndex = texNum + + moveDown = buttons.operator(MoveFlipbookTexture.bl_idname, text="", icon="TRIA_DOWN") + moveDown.arrayIndex = arrayIndex + moveDown.offset = 1 + moveUp.combinerTexIndex = texNum + + +class AddFlipbookTexture(bpy.types.Operator): + bl_idname = "material.add_flipbook_texture" + bl_label = "Add Flipbook Texture" + bl_options = {"REGISTER", "UNDO"} + arrayIndex: bpy.props.IntProperty() + combinerTexIndex: bpy.props.IntProperty() + + def execute(self, context): + material = context.material + flipbook = getattr(material.flipbookGroup, "flipbook" + str(self.combinerTexIndex)) + flipbook.textures.add() + flipbook.textures.move(len(flipbook.textures) - 1, self.arrayIndex) + self.report({"INFO"}, "Success!") + return {"FINISHED"} + + +class RemoveFlipbookTexture(bpy.types.Operator): + bl_idname = "material.remove_flipbook_texture" + bl_label = "Remove Flipbook Texture" + bl_options = {"REGISTER", "UNDO"} + arrayIndex: bpy.props.IntProperty() + combinerTexIndex: bpy.props.IntProperty() + + def execute(self, context): + material = context.material + flipbook = getattr(material.flipbookGroup, "flipbook" + str(self.combinerTexIndex)) + flipbook.textures.remove(self.arrayIndex) + self.report({"INFO"}, "Success!") + return {"FINISHED"} + + +class MoveFlipbookTexture(bpy.types.Operator): + bl_idname = "material.move_flipbook_texture" + bl_label = "Move Flipbook Texture" + bl_options = {"REGISTER", "UNDO"} + combinerTexIndex: bpy.props.IntProperty() + arrayIndex: bpy.props.IntProperty() + offset: bpy.props.IntProperty() + + def execute(self, context): + material = context.material + flipbook = getattr(material.flipbookGroup, "flipbook" + str(self.combinerTexIndex)) + flipbook.textures.move(self.arrayIndex, self.arrayIndex + self.offset) + self.report({"INFO"}, "Success!") + return {"FINISHED"} + + +class VisualizeFlipbookTexture(bpy.types.Operator): + bl_idname = "material.visualize_flipbook_texture" + bl_label = "Visualize Flipbook Texture" + bl_options = {"REGISTER", "UNDO"} + combinerTexIndex: bpy.props.IntProperty() + arrayIndex: bpy.props.IntProperty() + + def execute(self, context): + material = context.material + flipbook = getattr(material.flipbookGroup, "flipbook" + str(self.combinerTexIndex)) + texProp = getattr(material.f3d_mat, "tex" + str(self.combinerTexIndex)) + + setTexNodeImage(context.material, self.combinerTexIndex, self.arrayIndex) + + self.report({"INFO"}, "Success!") + return {"FINISHED"} + + +enumFlipbookExportMode = [ + ("Array", "Array", "Array"), + ("Individual", "Individual", "Individual"), +] + + +class FlipbookProperty(bpy.types.PropertyGroup): + enable: bpy.props.BoolProperty() + name: bpy.props.StringProperty(default="sFlipbookTextures") + exportMode: bpy.props.EnumProperty(default="Array", items=enumFlipbookExportMode) + textures: bpy.props.CollectionProperty(type=FlipbookImagePointerProperty) + + +class FlipbookGroupProperty(bpy.types.PropertyGroup): + flipbook0: bpy.props.PointerProperty(type=FlipbookProperty) + flipbook1: bpy.props.PointerProperty(type=FlipbookProperty) + + +def drawFlipbookProperty(layout: bpy.types.UILayout, flipbookProp: FlipbookProperty, index: int): + box = layout.box().column() + box.prop(flipbookProp, "enable", text="Export Flipbook Textures " + str(index)) + if flipbookProp.enable: + prop_split(box, flipbookProp, "exportMode", "Export Mode") + if flipbookProp.exportMode == "Array": + prop_split(box, flipbookProp, "name", "Array Name") + drawTextureArray(box.column(), flipbookProp.textures, index, flipbookProp.exportMode) + + +def drawFlipbookGroupProperty( + layout: bpy.types.UILayout, + material: bpy.types.Material, + checkFlipbookReference: Callable[[str], bool], + drawFlipbookRequirementMessage: Callable[[bpy.types.UILayout], None], +): + layout.box().column().label(text="Flipbook Properties") + if drawFlipbookRequirementMessage is not None: + drawFlipbookRequirementMessage(layout) + for i in range(2): + flipbook = getattr(material.flipbookGroup, "flipbook" + str(i)) + if usesFlipbook(material, flipbook, i, False, checkFlipbookReference): + drawFlipbookProperty(layout.column(), flipbook, i) + if getattr(material.f3d_mat, "tex" + str(i)).tex_format[:2] == "CI": + layout.label(text="New shared CI palette will be generated.", icon="ERROR") + + +# START GAME SPECIFIC CALLBACKS +def ootFlipbookReferenceIsValid(texReference: str) -> bool: + return re.search(f"0x0([0-9A-F])000000", texReference) is not None + + +def ootFlipbookRequirementMessage(layout: bpy.types.UILayout): + layout.label(text="To use this, material must use a") + layout.label(text="texture reference with name = 0x0?000000.") + + +def ootFlipbookAnimUpdate(self, armatureObj: bpy.types.Object, segment: str, index: int): + for child in armatureObj.children: + if not isinstance(child.data, bpy.types.Mesh): + continue + for material in child.data.materials: + for i in range(2): + flipbook = getattr(material.flipbookGroup, "flipbook" + str(i)) + texProp = getattr(material.f3d_mat, "tex" + str(i)) + if usesFlipbook(material, flipbook, i, True, ootFlipbookReferenceIsValid): + match = re.search(f"0x0([0-9A-F])000000", texProp.tex_reference) + if match is None: + continue + if match.group(1) == segment: + # Remember that index 0 = auto, and keyframed values start at 1 + flipbookIndex = min((index - 1 if index > 0 else 0), len(flipbook.textures) - 1) + setTexNodeImage(material, i, flipbookIndex) + + +# END GAME SPECIFIC CALLBACKS + +# we use a handler since update functions are not called when a property is animated. +@persistent +def flipbookAnimHandler(dummy): + if bpy.context.scene.gameEditorMode == "OOT": + for obj in bpy.data.objects: + if isinstance(obj.data, bpy.types.Armature): + # we only want to update texture on keyframed armatures. + # this somewhat mitigates the issue of two skeletons using the same flipbook material. + if obj.animation_data is None or obj.animation_data.action is None: + continue + action = obj.animation_data.action + if not ( + action.fcurves.find("ootLinkTextureAnim.eyes") is None + or action.fcurves.find("ootLinkTextureAnim.mouth") is None + ): + ootFlipbookAnimUpdate(obj.data, obj, "8", obj.ootLinkTextureAnim.eyes) + ootFlipbookAnimUpdate(obj.data, obj, "9", obj.ootLinkTextureAnim.mouth) + else: + pass + + +class Flipbook_MaterialPanel(bpy.types.Panel): + bl_label = "Flipbook Material" + bl_idname = "MATERIAL_PT_Flipbook_Material_Inspector" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "material" + bl_options = {"HIDE_HEADER"} + + @classmethod + def poll(cls, context): + return context.material is not None and context.scene.gameEditorMode in ["OOT"] + + def draw(self, context): + layout = self.layout + mat = context.material + col = layout.column() + + if context.scene.gameEditorMode == "OOT": + checkFlipbookReference = ootFlipbookReferenceIsValid + drawFlipbookRequirementMessage = ootFlipbookRequirementMessage + else: + checkFlipbookReference = None + drawFlipbookRequirementMessage = None + + drawFlipbookGroupProperty(col.box().column(), mat, checkFlipbookReference, drawFlipbookRequirementMessage) + + +def setTexNodeImage(material: bpy.types.Material, texIndex: int, flipbookIndex: int): + flipbook = getattr(material.flipbookGroup, "flipbook" + str(texIndex)) + for texNode in iter_tex_nodes(material.node_tree, texIndex): + if texNode.image is not flipbook.textures[flipbookIndex].image: + texNode.image = flipbook.textures[flipbookIndex].image + + +flipbook_classes = [ + FlipbookImagePointerProperty, + AddFlipbookTexture, + RemoveFlipbookTexture, + MoveFlipbookTexture, + VisualizeFlipbookTexture, + FlipbookProperty, + FlipbookGroupProperty, + Flipbook_MaterialPanel, +] + + +def flipbook_register(): + for cls in flipbook_classes: + register_class(cls) + + bpy.app.handlers.frame_change_pre.append(flipbookAnimHandler) + bpy.types.Material.flipbookGroup = bpy.props.PointerProperty(type=FlipbookGroupProperty) + + +def flipbook_unregister(): + for cls in reversed(flipbook_classes): + unregister_class(cls) + + bpy.app.handlers.frame_change_pre.remove(flipbookAnimHandler) + del bpy.types.Material.flipbookGroup diff --git a/fast64_internal/f3d_material_converter.py b/fast64_internal/f3d_material_converter.py index 474db0f..cd62d0b 100644 --- a/fast64_internal/f3d_material_converter.py +++ b/fast64_internal/f3d_material_converter.py @@ -158,6 +158,7 @@ def convertF3DtoNewVersion(obj: bpy.types.Object | bpy.types.Bone, index, materi newMat.f3d_mat.draw_layer.sm64 = material.f3d_mat.draw_layer.sm64 copyPropertyGroup(material.ootMaterial, newMat.ootMaterial) + copyPropertyGroup(material.flipbookGroup, newMat.flipbookGroup) copyPropertyGroup(material.ootCollisionProperty, newMat.ootCollisionProperty) colSettings = CollisionSettings() diff --git a/fast64_internal/oot/README.md b/fast64_internal/oot/README.md index 300a0e0..0f74049 100644 --- a/fast64_internal/oot/README.md +++ b/fast64_internal/oot/README.md @@ -71,18 +71,56 @@ To import a skeletal mesh, just click "Import" for the armature importer. You ma ![](/images/oot_imported_gerudo_textured.png) ![](/images/oot_imported_gerudo_solid.png) +1. Certain colors are white/different: Some graphical effects are achieved through dynamic Gfx commands, such as tinting white textures. These effects will not be imported. +2. Strange imported normals: Due to the behaviour of rotating vertices on a skinned triangle that differs between Blender and the N64, normals may look strange. Note that these normals will look correct if re exported back into the game (assuming the rest pose is not changed). -1. Eye/face textures are black: Texture pointers which are set dynamically will not be imported. Instead, the name of the pointer will be used instead of the actual data. -2. Certain colors are white/different: Some graphical effects are achieved through dynamic Gfx commands, such as tinting white textures. These effects will not be imported. -3. Strange imported normals: Due to the behaviour of rotating vertices on a skinned triangle that differs between Blender and the N64, normals may look strange. Note that these normals will look correct if re exported back into the game (assuming the rest pose is not changed). - -Note that rest pose rotations are zeroed out on export, so you can modify the rest pose of imported armature while still preserving its structure. You can do this by using the "Apply As Rest Pose" operator under the Fast64 tab. Note that imported animations however still require the imported rest pose to work correctly. +Note that rest pose rotations are zeroed out on export, so you can modify the rest pose of imported armature while still preserving its structure. You can do this by using the "Apply As Rest Pose" operator under the Fast64 tab or the OOT Skeleton Exporter section. Note that imported animations however still require the imported rest pose to work correctly. There may also be an issue where some meshes import completely black due to the assumption that the F3D cycle mode is set to 2-Cycle, when it should really be 1-Cycle. Try changing the cycle type to 1-Cycle in cases where a dynamic texture pointer is not expected. To import an animation, select the armature the animation belongs to then click "Import" on the animation importer. To export an animation, select an armature and click "Export", which will export the active animation of the armature. +### Flipbook Textures +Many actors in OOT will animate textures through code using a flipbook method, like with Link's eyes/mouth. A flipbook material will use a texture reference pointing to an address formatted as 0x0?000000. You can find the flipbook texture frames in the material properties tab underneath the dynamic material section. +![](/images/oot_flipbook.png) +On import, Fast64 will try to read the provided actors code for flipbook textures. On export, Fast64 will try to modify texture arrays used for flipbook textures. + +For Link, the eyes/mouth materials use flipbook textures. For Link animations you can animate these flipbook indices in the Link Animation Inspector, located in the object properties tab for an armature object. Note that the 0 index is reserved for the "auto" setting, and that flipbook texture indices start at 1. +![](/images/oot_link_texture_anim.png) + +### Custom Link Process +1. In the OOT Skeleton Exporter window, go to the Import Skeleton section, select "Mode" and switch it to "Adult Link." +2. Click "Import Skeleton" to import the skeleton from your decomp repo set up in the the "Getting Started" intro. +3. Replace/modify the mesh. +4. For any new materials, make sure to go to material properties -> OOT Dynamic Material Properties -> enable segment C. This handles rendering for Link's reflection. +5. To add your own eye/mouth materials, create a new F3D material, then go to material properties -> F3D Material Inspector -> Sources -> Texture 0 Properties: + - Set "Use Texture Reference". + - Set the texture size to the size of your textures. + - Ignore palette reference/size, those will be auto-generated if using CI textures. + - Set the texture reference to 0x08000000 (eyes) or 0x09000000 (mouth) +6. To add different eye/mouth texture frames, go to the material properties tab, then scroll down to the Flipbook Properties. +7. Once you've modified Link's mesh, go the OOT Skeleton Exporter window, go to the Export Skeleton section, select "Mode" and switch it to "Adult Link". +8. Select Link's armature and then hit "Export Skeleton". +9. If you're not using HackerOOT, make sure to set NON_MATCHING to 1 in the Makefile in the decomp repo. +10. Most of Link's items are combined with his hand mesh. There are plans to simplify the process, but for now these models must be manually replaced using the display list importer/exporter. You'll also have to modify the DL arrays at the start of src/code/z_player_lib.c to include your own DLs if you're appending and not replacing. +11. Common Issues: + - Corrupted mesh: Make sure the root, upper control, and lower control bones are the only bones set to non-deform. + - Incorrect waist DL: Go to src/code/z_player_lib.c and modify sPlayerWaistDLs to include your own waist DL. + +### Custom Skeleton Mesh Process +1. Import the character you want to modify. + - Skeleton: The name of the skeleton struct, of type FlexSkeletonHeader or SkeletonHeader. Usually found in the object files. + - Object: The "asset group" the skeleton belongs to. The name will be from "assets/objects/\/" + - Overlay: The location of the actor code, if necessary. The name will be from "src/overlays/actors/\/" +2. Put it into a suitable rest pose, then click the "Apply As Rest Pose" button at the bottom of the OOT Skeleton Exporter section to apply it. It helps to import an existing animation to see how a good rest pose would look like. + - Animation Header Name: struct of type AnimationHeader or LinkAnimationHeader, found in the object files. +3. Replace the existing mesh with your own. +4. Export the skeleton back into the game. It is not necessary to re-fold the armature before export. +5. If "Replace Vanilla Headers On Export" is enabled, then any reference conflicts should be removed. +6. In the actor header file, (in src/overlays/actors/\/), set the joint/morph table sizes to be (number of bones + 1) +7. In the actor source file, this value should also be used for the limbCount argument in SkelAnime_InitFlex(). + ### Creating a Cutscene **Creating the cutscene itself:** diff --git a/fast64_internal/oot/__init__.py b/fast64_internal/oot/__init__.py index 59ebf94..80e124e 100644 --- a/fast64_internal/oot/__init__.py +++ b/fast64_internal/oot/__init__.py @@ -64,7 +64,6 @@ class OOT_FileSettingsPanel(OOT_Panel): col = self.layout.column() col.scale_y = 1.1 # extra padding, makes it easier to see these main settings prop_split(col, context.scene, "ootBlenderScale", "OOT Scene Scale") - prop_split(col, context.scene, "ootActorBlenderScale", "OOT Actor Scale") prop_split(col, context.scene, "ootDecompPath", "Decomp Path") col.prop(context.scene.fast64.oot, "hackerFeaturesEnabled") @@ -80,6 +79,8 @@ class OOT_Properties(bpy.types.PropertyGroup): DLImportSettings: bpy.props.PointerProperty(type=OOTDLImportSettings) skeletonExportSettings: bpy.props.PointerProperty(type=oot_skeleton.OOTSkeletonExportSettings) skeletonImportSettings: bpy.props.PointerProperty(type=oot_skeleton.OOTSkeletonImportSettings) + animExportSettings: bpy.props.PointerProperty(type=oot_anim.OOTAnimExportSettingsProperty) + animImportSettings: bpy.props.PointerProperty(type=oot_anim.OOTAnimImportSettingsProperty) oot_classes = ( @@ -133,7 +134,6 @@ def oot_register(registerPanels): bpy.types.Scene.ootBlenderScale = bpy.props.FloatProperty( name="Blender To OOT Scale", default=10, update=on_update_render_settings ) - bpy.types.Scene.ootActorBlenderScale = bpy.props.FloatProperty(name="Blender To OOT Actor Scale", default=1000) bpy.types.Scene.ootDecompPath = bpy.props.StringProperty(name="Decomp Folder", subtype="FILE_PATH") @@ -156,5 +156,4 @@ def oot_unregister(unregisterPanels): oot_panel_unregister() del bpy.types.Scene.ootBlenderScale - del bpy.types.Scene.ootActorBlenderScale del bpy.types.Scene.ootDecompPath diff --git a/fast64_internal/oot/oot_anim.py b/fast64_internal/oot/oot_anim.py index 6ad3c9f..fb7d219 100644 --- a/fast64_internal/oot/oot_anim.py +++ b/fast64_internal/oot/oot_anim.py @@ -7,11 +7,13 @@ from ..utility import CData, PluginError, toAlnum, writeCData, readFile, hexOrDe from .oot_utility import ( checkForStartBone, getStartBone, + getNextBone, getSortedChildren, ootGetPath, addIncludeFiles, checkEmptyName, ootGetObjectPath, + getOOTScale, ) from ..utility_anim import ( @@ -20,8 +22,36 @@ from ..utility_anim import ( saveQuaternionFrame, squashFramesIfAllSame, getFrameInterval, + getTranslationRelativeToRest, + getRotationRelativeToRest, ) +from ..f3d.f3d_material import iter_tex_nodes +from ..f3d.flipbook import usesFlipbook, ootFlipbookAnimUpdate + +from .oot_model_classes import ootGetIncludedAssetData +from ..f3d.f3d_parser import getImportData + + +class OOTAnimExportSettingsProperty(bpy.types.PropertyGroup): + isCustom: bpy.props.BoolProperty(name="Use Custom Path") + customPath: bpy.props.StringProperty(name="Folder", subtype="FILE_PATH") + folderName: bpy.props.StringProperty(name="Animation Folder", default="object_geldb") + isLink: bpy.props.BoolProperty(name="Is Link", default=False) + skeletonName: bpy.props.StringProperty(name="Skeleton Name", default="gGerudoRedSkel") + + +class OOTAnimImportSettingsProperty(bpy.types.PropertyGroup): + isCustom: bpy.props.BoolProperty(name="Use Custom Path") + customPath: bpy.props.StringProperty(name="Folder", subtype="FILE_PATH") + folderName: bpy.props.StringProperty(name="Animation Folder", default="object_geldb") + isLink: bpy.props.BoolProperty(name="Is Link", default=False) + animName: bpy.props.StringProperty(name="Anim Name", default="gGerudoRedSpinAttackAnim") + + +def convertToUnsignedShort(value: int) -> int: + return int.from_bytes(value.to_bytes(2, "big", signed=(value < 0)), "big", signed=False) + class OOTAnimation: def __init__(self, name): @@ -48,7 +78,7 @@ class OOTAnimation: for value in self.values: if counter == 0: data.source += "\t" - data.source += format(value, "#06x") + ", " + data.source += format(convertToUnsignedShort(value), "#06x") + ", " counter += 1 if counter >= 16: # round number for finding/counting data counter = 0 @@ -60,7 +90,13 @@ class OOTAnimation: for index in range(-1, len(self.indices) - 1): data.source += "\t{ " for field in range(3): - data.source += format(self.indices[index][field], "#06x") + ", " + data.source += ( + format( + convertToUnsignedShort(self.indices[index][field]), + "#06x", + ) + + ", " + ) data.source += "},\n" data.source += "};\n\n" @@ -83,6 +119,51 @@ class OOTAnimation: return data +class OOTLinkAnimation: + def __init__(self, name): + self.headerName = toAlnum(name) + self.frameCount = None + self.data = [] + + def dataName(self): + return self.headerName + "Data" + + def toC(self, isCustomExport: bool): + data = CData() + animHeaderData = CData() + + data.source += '#include "ultra64.h"\n#include "global.h"\n\n' + animHeaderData.source += '#include "ultra64.h"\n#include "global.h"\n\n' + + # TODO: handle custom import? + if isCustomExport: + animHeaderData.source += f'#include "{self.dataName()}.h"\n' + else: + animHeaderData.source += f'#include "assets/misc/link_animetion/{self.dataName()}.h"\n' + + # data + data.header += f"extern s16 {self.dataName()}[];\n" + data.source += f"s16 {self.dataName()}[] = {{\n" + counter = 0 + for value in self.data: + if counter == 0: + data.source += "\t" + data.source += format(convertToUnsignedShort(value), "#06x") + ", " + counter += 1 + if counter >= 8: # round number for finding/counting data + counter = 0 + data.source += "\n" + data.source += "\n};\n\n" + + # header + animHeaderData.header += f"extern LinkAnimationHeader {self.headerName};\n" + animHeaderData.source += ( + f"LinkAnimationHeader {self.headerName} = {{\n\t{{ {str(self.frameCount)} }}, {self.dataName()} \n}};\n\n" + ) + + return data, animHeaderData + + def ootGetAnimBoneRot(bone, poseBone, convertTransformMatrix, isRoot): # OoT draws limbs like this: # limbMatrix = parentLimbMatrix @ limbFixedTranslationMatrix @ animRotMatrix @@ -147,7 +228,7 @@ def ootGetAnimBoneRot(bone, poseBone, convertTransformMatrix, isRoot): return finalRotation -def ootConvertAnimationData(anim, armatureObj, convertTransformMatrix, *, frame_start, frame_count): +def ootConvertNonLinkAnimationData(anim, armatureObj, convertTransformMatrix, *, frame_start, frame_count): checkForStartBone(armatureObj) bonesToProcess = [getStartBone(armatureObj)] currentBone = armatureObj.data.bones[bonesToProcess[0]] @@ -166,7 +247,7 @@ def ootConvertAnimationData(anim, armatureObj, convertTransformMatrix, *, frame_ bonesToProcess = childrenNames + bonesToProcess # list of boneFrameData, which is [[x frames], [y frames], [z frames]] - # boneIndex is index in animBones in ootConvertAnimationData. + # boneIndex is index in animBones. # since we are processing the bones in the same order as ootProcessBone, # they should be the same as the limb indices. @@ -213,7 +294,67 @@ def ootConvertAnimationData(anim, armatureObj, convertTransformMatrix, *, frame_ return armatureFrameData -def ootExportAnimationCommon(armatureObj, convertTransformMatrix, skeletonName): +def ootConvertLinkAnimationData(anim, armatureObj, convertTransformMatrix, *, frame_start, frame_count): + checkForStartBone(armatureObj) + bonesToProcess = [getStartBone(armatureObj)] + currentBone = armatureObj.data.bones[bonesToProcess[0]] + animBones = [] + + # Get animation bones in order + # must be SAME order as ootProcessBone + while len(bonesToProcess) > 0: + boneName = bonesToProcess[0] + currentBone = armatureObj.data.bones[boneName] + bonesToProcess = bonesToProcess[1:] + + animBones.append(boneName) + + childrenNames = getSortedChildren(armatureObj, currentBone) + bonesToProcess = childrenNames + bonesToProcess + + # list of boneFrameData, which is [[x frames], [y frames], [z frames]] + # boneIndex is index in animBones. + # since we are processing the bones in the same order as ootProcessBone, + # they should be the same as the limb indices. + + frameData = [] + + currentFrame = bpy.context.scene.frame_current + for frame in range(frame_start, frame_start + frame_count): + bpy.context.scene.frame_set(frame) + rootBone = armatureObj.data.bones[animBones[0]] + rootPoseBone = armatureObj.pose.bones[animBones[0]] + + # Convert Z-up to Y-up for root translation animation + translation = ( + mathutils.Quaternion((1, 0, 0), math.radians(-90.0)) + @ (convertTransformMatrix @ rootPoseBone.matrix).decompose()[0] + ) + + for i in range(3): + frameData.append(min(int(round(translation[i])), 2**16 - 1)) + + for boneIndex in range(len(animBones)): + boneName = animBones[boneIndex] + currentBone = armatureObj.data.bones[boneName] + currentPoseBone = armatureObj.pose.bones[boneName] + + rotation = ootGetAnimBoneRot(currentBone, currentPoseBone, convertTransformMatrix, boneIndex == 0) + for i in range(3): + field = rotation.to_euler()[i] + value = (math.degrees(field) % 360) / 360 + frameData.append(min(int(round(value * (2**16 - 1))), 2**16 - 1)) + + textureAnimValue = (armatureObj.ootLinkTextureAnim.eyes & 0xF) | ( + (armatureObj.ootLinkTextureAnim.mouth & 0xF) << 4 + ) + frameData.append(textureAnimValue) + + bpy.context.scene.frame_set(currentFrame) + return frameData + + +def ootExportNonLinkAnimation(armatureObj, convertTransformMatrix, skeletonName): if armatureObj.animation_data is None or armatureObj.animation_data.action is None: raise PluginError("No active animation selected.") anim = armatureObj.animation_data.action @@ -224,7 +365,7 @@ def ootExportAnimationCommon(armatureObj, convertTransformMatrix, skeletonName): frame_start, frame_last = getFrameInterval(anim) ootAnim.frameCount = frame_last - frame_start + 1 - armatureFrameData = ootConvertAnimationData( + armatureFrameData = ootConvertNonLinkAnimationData( anim, armatureObj, convertTransformMatrix, @@ -260,34 +401,114 @@ def ootExportAnimationCommon(armatureObj, convertTransformMatrix, skeletonName): return ootAnim -def exportAnimationC(armatureObj, exportPath, isCustomExport, folderName, skeletonName): - checkEmptyName(folderName) - checkEmptyName(skeletonName) +def ootExportLinkAnimation(armatureObj, convertTransformMatrix, skeletonName): + if armatureObj.animation_data is None or armatureObj.animation_data.action is None: + raise PluginError("No active animation selected.") + anim = armatureObj.animation_data.action + ootAnim = OOTLinkAnimation(toAlnum(skeletonName + anim.name.capitalize() + "Anim")) + + frame_start, frame_last = getFrameInterval(anim) + ootAnim.frameCount = frame_last - frame_start + 1 + + ootAnim.data = ootConvertLinkAnimationData( + anim, + armatureObj, + convertTransformMatrix, + frame_start=frame_start, + frame_count=(frame_last - frame_start + 1), + ) + + return ootAnim + + +def exportAnimationC(armatureObj: bpy.types.Object, settings: OOTAnimExportSettingsProperty): + path = bpy.path.abspath(settings.customPath) + exportPath = ootGetObjectPath(settings.isCustom, path, settings.folderName) + + checkEmptyName(settings.folderName) + checkEmptyName(settings.skeletonName) convertTransformMatrix = ( - mathutils.Matrix.Scale(bpy.context.scene.ootActorBlenderScale, 4) + mathutils.Matrix.Scale(getOOTScale(armatureObj.ootActorScale), 4) @ mathutils.Matrix.Diagonal(armatureObj.scale).to_4x4() ) - ootAnim = ootExportAnimationCommon(armatureObj, convertTransformMatrix, skeletonName) - ootAnimC = ootAnim.toC() - path = ootGetPath(exportPath, isCustomExport, "assets/objects/", folderName, False, False) - writeCData(ootAnimC, os.path.join(path, ootAnim.name + ".h"), os.path.join(path, ootAnim.name + ".c")) + if settings.isLink: + ootAnim = ootExportLinkAnimation(armatureObj, convertTransformMatrix, "gLink") + ootAnimC, ootAnimHeaderC = ootAnim.toC(settings.isCustom) + path = ootGetPath( + exportPath, + settings.isCustom, + "assets/misc/link_animetion", + settings.folderName if settings.isCustom else "", + False, + False, + ) + headerPath = ootGetPath( + exportPath, + settings.isCustom, + "assets/objects/gameplay_keep", + settings.folderName if settings.isCustom else "", + False, + False, + ) + writeCData( + ootAnimC, os.path.join(path, ootAnim.dataName() + ".h"), os.path.join(path, ootAnim.dataName() + ".c") + ) + writeCData( + ootAnimHeaderC, + os.path.join(headerPath, ootAnim.headerName + ".h"), + os.path.join(headerPath, ootAnim.headerName + ".c"), + ) - if not isCustomExport: - addIncludeFiles(folderName, path, ootAnim.name) + if not settings.isCustom: + addIncludeFiles("link_animetion", path, ootAnim.dataName()) + addIncludeFiles("gameplay_keep", headerPath, ootAnim.headerName) + + else: + ootAnim = ootExportNonLinkAnimation(armatureObj, convertTransformMatrix, settings.skeletonName) + + ootAnimC = ootAnim.toC() + path = ootGetPath(exportPath, settings.isCustom, "assets/objects/", settings.folderName, False, False) + writeCData(ootAnimC, os.path.join(path, ootAnim.name + ".h"), os.path.join(path, ootAnim.name + ".c")) + + if not settings.isCustom: + addIncludeFiles(settings.folderName, path, ootAnim.name) -def getNextBone(boneStack, armatureObj): - if len(boneStack) == 0: - raise PluginError("More bones in animation than on armature.") - bone = armatureObj.data.bones[boneStack[0]] - boneStack = boneStack[1:] - boneStack = getSortedChildren(armatureObj, bone) + boneStack - return bone, boneStack +def ootImportAnimationC( + armatureObj: bpy.types.Object, + settings: OOTAnimImportSettingsProperty, + actorScale: float, +): + importPath = bpy.path.abspath(settings.customPath) + filepath = ootGetObjectPath(settings.isCustom, importPath, settings.folderName) + if settings.isLink: + numLimbs = 21 + if not settings.isCustom: + basePath = bpy.path.abspath(bpy.context.scene.ootDecompPath) + animFilepath = os.path.join(basePath, "assets/misc/link_animetion/link_animetion.c") + animHeaderFilepath = os.path.join(basePath, "assets/objects/gameplay_keep/gameplay_keep.c") + else: + animFilepath = filepath + animHeaderFilepath = filepath + ootImportLinkAnimationC( + armatureObj, + animHeaderFilepath, + animFilepath, + settings.animName, + actorScale, + numLimbs, + settings.isCustom, + ) + else: + ootImportNonLinkAnimationC(armatureObj, filepath, settings.animName, actorScale, settings.isCustom) -def ootImportAnimationC(armatureObj, filepath, animName, actorScale): - animData = readFile(filepath) +def ootImportNonLinkAnimationC(armatureObj, filepath, animName, actorScale, isCustomImport: bool): + animData = getImportData([filepath]) + if not isCustomImport: + basePath = bpy.path.abspath(bpy.context.scene.ootDecompPath) + animData = ootGetIncludedAssetData(basePath, [filepath], animData) + animData matchResult = re.search( re.escape(animName) @@ -319,43 +540,192 @@ def ootImportAnimationC(armatureObj, filepath, animName, actorScale): # property index = 0,1,2 (aka x,y,z) for jointIndex in jointIndices: if isRootTranslation: - for propertyIndex in range(3): - fcurve = anim.fcurves.new( + fcurves = [ + anim.fcurves.new( data_path='pose.bones["' + startBoneName + '"].location', index=propertyIndex, action_group=startBoneName, ) - if jointIndex[propertyIndex] < staticIndexMax: - value = frameData[jointIndex[propertyIndex]] / actorScale - fcurve.keyframe_points.insert(0, value) - else: - for frame in range(frameCount): - value = frameData[jointIndex[propertyIndex] + frame] / actorScale - fcurve.keyframe_points.insert(frame, value) + for propertyIndex in range(3) + ] + for frame in range(frameCount): + rawTranslation = mathutils.Vector((0, 0, 0)) + for propertyIndex in range(3): + + if jointIndex[propertyIndex] < staticIndexMax: + value = ootTranslationValue(frameData[jointIndex[propertyIndex]], actorScale) + else: + value = ootTranslationValue(frameData[jointIndex[propertyIndex] + frame], actorScale) + + rawTranslation[propertyIndex] = value + + trueTranslation = getTranslationRelativeToRest(armatureObj.data.bones[startBoneName], rawTranslation) + + for propertyIndex in range(3): + fcurves[propertyIndex].keyframe_points.insert(frame, trueTranslation[propertyIndex]) + isRootTranslation = False else: # WARNING: This assumes the order bones are processed are in alphabetical order. # If this changes in the future, then this won't work. bone, boneStack = getNextBone(boneStack, armatureObj) - for propertyIndex in range(3): - fcurve = anim.fcurves.new( + + fcurves = [ + anim.fcurves.new( data_path='pose.bones["' + bone.name + '"].rotation_euler', index=propertyIndex, action_group=bone.name, ) - if jointIndex[propertyIndex] < staticIndexMax: - value = math.radians(frameData[jointIndex[propertyIndex]] * 360 / (2**16)) - fcurve.keyframe_points.insert(0, value) - else: - for frame in range(frameCount): - value = math.radians(frameData[jointIndex[propertyIndex] + frame] * 360 / (2**16)) - fcurve.keyframe_points.insert(frame, value) + for propertyIndex in range(3) + ] + + for frame in range(frameCount): + rawRotation = mathutils.Euler((0, 0, 0), "XYZ") + for propertyIndex in range(3): + if jointIndex[propertyIndex] < staticIndexMax: + value = binangToRadians(frameData[jointIndex[propertyIndex]]) + else: + value = binangToRadians(frameData[jointIndex[propertyIndex] + frame]) + + rawRotation[propertyIndex] = value + + trueRotation = getRotationRelativeToRest(bone, rawRotation) + + for propertyIndex in range(3): + fcurves[propertyIndex].keyframe_points.insert(frame, trueRotation[propertyIndex]) if armatureObj.animation_data is None: armatureObj.animation_data_create() armatureObj.animation_data.action = anim +# filepath is gameplay_keep.c +# animName is header name. +# numLimbs = 21 for link. +def ootImportLinkAnimationC( + armatureObj: bpy.types.Object, + animHeaderFilepath: str, + animFilepath: str, + animHeaderName: str, + actorScale: float, + numLimbs: int, + isCustomImport: bool, +): + animHeaderData = getImportData([animHeaderFilepath]) + animData = getImportData([animFilepath]) + if not isCustomImport: + basePath = bpy.path.abspath(bpy.context.scene.ootDecompPath) + animHeaderData = ootGetIncludedAssetData(basePath, [animHeaderFilepath], animHeaderData) + animHeaderData + animData = ootGetIncludedAssetData(basePath, [animFilepath], animData) + animData + + matchResult = re.search( + re.escape(animHeaderName) + "\s*=\s*\{\s*\{\s*([^,\s]*)\s*\}\s*,\s*([^,\s]*)\s*\}\s*;", + animHeaderData, + ) + if matchResult is None: + raise PluginError("Cannot find animation named " + animHeaderName + " in " + animHeaderFilepath) + frameCount = hexOrDecInt(matchResult.group(1).strip()) + frameDataName = matchResult.group(2).strip() + + frameData = getFrameData(animFilepath, animData, frameDataName) + print(f"{frameDataName}: {frameCount} frames, {len(frameData)} values.") + + bpy.context.scene.frame_end = frameCount + anim = bpy.data.actions.new(animHeaderName) + + # get ordered list of bone names + # create animation curves for each bone + startBoneName = getStartBone(armatureObj) + boneList = [] + boneCurvesRotation = [] + boneCurveTranslation = None + boneStack = [startBoneName] + + eyesCurve = anim.fcurves.new( + data_path="ootLinkTextureAnim.eyes", + action_group="Texture Animations", + ) + mouthCurve = anim.fcurves.new( + data_path="ootLinkTextureAnim.mouth", + action_group="Texture Animations", + ) + + # create all necessary fcurves + while len(boneStack) > 0: + bone, boneStack = getNextBone(boneStack, armatureObj) + boneList.append(bone) + + if boneCurveTranslation is None: + boneCurveTranslation = [ + anim.fcurves.new( + data_path='pose.bones["' + bone.name + '"].location', + index=propertyIndex, + action_group=startBoneName, + ) + for propertyIndex in range(3) + ] + + boneCurvesRotation.append( + [ + anim.fcurves.new( + data_path='pose.bones["' + bone.name + '"].rotation_euler', + index=propertyIndex, + action_group=bone.name, + ) + for propertyIndex in range(3) + ] + ) + + # vec3 = 3x s16 values + # padding = u8, tex anim = u8 + # root trans vec3 + rot vec3 for each limb + (s16 with eye/mouth indices) + frameSize = 3 + 3 * numLimbs + 1 + for frame in range(frameCount): + currentFrame = frameData[frame * frameSize : (frame + 1) * frameSize] + if len(currentFrame) < frameSize: + raise PluginError( + f"{frameDataName} has malformed data. Framesize = {frameSize}, CurrentFrame = {len(currentFrame)}" + ) + + translation = getTranslationRelativeToRest( + boneList[0], mathutils.Vector([ootTranslationValue(currentFrame[i], actorScale) for i in range(3)]) + ) + + for i in range(3): + boneCurveTranslation[i].keyframe_points.insert(frame, translation[i]) + + for boneIndex in range(numLimbs): + bone = boneList[boneIndex] + rawRotation = mathutils.Euler( + [binangToRadians(currentFrame[i + (boneIndex + 1) * 3]) for i in range(3)], "XYZ" + ) + trueRotation = getRotationRelativeToRest(bone, rawRotation) + for i in range(3): + boneCurvesRotation[boneIndex][i].keyframe_points.insert(frame, trueRotation[i]) + + # convert to unsigned short representation + texAnimValue = int.from_bytes( + currentFrame[(numLimbs + 1) * 3].to_bytes(2, "big", signed=True), "big", signed=False + ) + eyesValue = texAnimValue & 0xF + mouthValue = texAnimValue >> 4 & 0xF + + eyesCurve.keyframe_points.insert(frame, eyesValue).interpolation = "CONSTANT" + mouthCurve.keyframe_points.insert(frame, mouthValue).interpolation = "CONSTANT" + + if armatureObj.animation_data is None: + armatureObj.animation_data_create() + armatureObj.animation_data.action = anim + + +def ootTranslationValue(value, actorScale): + return value / actorScale + + +def binangToRadians(value): + return math.radians(value * 360 / (2**16)) + + def getFrameData(filepath, animData, frameDataName): matchResult = re.search(re.escape(frameDataName) + "\s*\[\s*[0-9]*\s*\]\s*=\s*\{([^\}]*)\}", animData, re.DOTALL) if matchResult is None: @@ -406,14 +776,8 @@ class OOT_ExportAnim(bpy.types.Operator): return {"CANCELLED"} try: - isCustomExport = context.scene.ootAnimIsCustomExport - exportPath = bpy.path.abspath(context.scene.ootAnimExportCustomPath) - folderName = context.scene.ootAnimExportFolderName - skeletonName = context.scene.ootAnimSkeletonName - - path = ootGetObjectPath(isCustomExport, exportPath, folderName) - - exportAnimationC(armatureObj, path, isCustomExport, folderName, skeletonName) + settings = context.scene.fast64.oot.animExportSettings + exportAnimationC(armatureObj, settings) self.report({"INFO"}, "Success!") except Exception as e: @@ -441,20 +805,21 @@ class OOT_ImportAnim(bpy.types.Operator): armatureObj = context.selected_objects[0] if context.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") + + # We need to apply scale otherwise translation imports won't be correct. + bpy.ops.object.select_all(action="DESELECT") + armatureObj.select_set(True) + bpy.context.view_layer.objects.active = armatureObj + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True, properties=False) + except Exception as e: raisePluginError(self, e) return {"CANCELLED"} try: - isCustomImport = context.scene.ootAnimIsCustomImport - folderName = context.scene.ootAnimImportFolderName - importPath = bpy.path.abspath(context.scene.ootAnimImportCustomPath) - animName = context.scene.ootAnimName - actorScale = context.scene.ootActorBlenderScale - - path = ootGetObjectPath(isCustomImport, importPath, folderName) - - ootImportAnimationC(armatureObj, path, animName, actorScale) + actorScale = getOOTScale(armatureObj.ootActorScale) + settings = context.scene.fast64.oot.animImportSettings + ootImportAnimationC(armatureObj, settings, actorScale) self.report({"INFO"}, "Success!") except Exception as e: @@ -473,29 +838,81 @@ class OOT_ExportAnimPanel(OOT_Panel): col = self.layout.column() col.operator(OOT_ExportAnim.bl_idname) - prop_split(col, context.scene, "ootAnimSkeletonName", "Skeleton Name") - if context.scene.ootAnimIsCustomExport: - prop_split(col, context.scene, "ootAnimExportCustomPath", "Folder") - else: - prop_split(col, context.scene, "ootAnimExportFolderName", "Object") - col.prop(context.scene, "ootAnimIsCustomExport") + exportSettings = context.scene.fast64.oot.animExportSettings + prop_split(col, exportSettings, "skeletonName", "Anim Name Prefix") + if exportSettings.isCustom: + prop_split(col, exportSettings, "customPath", "Folder") + elif not exportSettings.isLink: + prop_split(col, exportSettings, "folderName", "Object") + col.prop(exportSettings, "isLink") + col.prop(exportSettings, "isCustom") col.operator(OOT_ImportAnim.bl_idname) - prop_split(col, context.scene, "ootAnimName", "Anim Name") + importSettings = context.scene.fast64.oot.animImportSettings + prop_split(col, importSettings, "animName", "Anim Header Name") + if importSettings.isCustom: + prop_split(col, importSettings, "customPath", "File") + elif not importSettings.isLink: + prop_split(col, importSettings, "folderName", "Object") + col.prop(importSettings, "isLink") + col.prop(importSettings, "isCustom") - if context.scene.ootAnimIsCustomImport: - prop_split(col, context.scene, "ootAnimImportCustomPath", "File") - else: - prop_split(col, context.scene, "ootAnimImportFolderName", "Object") - col.prop(context.scene, "ootAnimIsCustomImport") + +# The update callbacks are for manually setting texture with visualize operator. +# They don't run from animation updates, see flipbookAnimHandler in flipbook.py +def ootUpdateLinkEyes(self, context): + index = self.eyes + ootFlipbookAnimUpdate(self, context.object, "8", index) + + +def ootUpdateLinkMouth(self, context): + index = self.mouth + ootFlipbookAnimUpdate(self, context.object, "9", index) + + +class OOTLinkTextureAnimProperty(bpy.types.PropertyGroup): + eyes: bpy.props.IntProperty(min=0, max=15, default=0, name="Eyes", update=ootUpdateLinkEyes) + mouth: bpy.props.IntProperty(min=0, max=15, default=0, name="Mouth", update=ootUpdateLinkMouth) + + +class OOT_LinkAnimPanel(bpy.types.Panel): + bl_idname = "OOT_PT_link_anim" + bl_label = "OOT Link Animation Properties" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + bl_options = {"HIDE_HEADER"} + + @classmethod + def poll(cls, context): + return ( + context.scene.gameEditorMode == "OOT" + and hasattr(context, "object") + and context.object is not None + and isinstance(context.object.data, bpy.types.Armature) + ) + + # called every frame + def draw(self, context): + col = self.layout.box().column() + col.box().label(text="OOT Link Animation Inspector") + prop_split(col, context.object.ootLinkTextureAnim, "eyes", "Eyes") + prop_split(col, context.object.ootLinkTextureAnim, "mouth", "Mouth") + col.label(text="Index 0 is for auto, flipbook starts at index 1.", icon="INFO") oot_anim_classes = ( OOT_ExportAnim, OOT_ImportAnim, + OOTLinkTextureAnimProperty, + OOTAnimExportSettingsProperty, + OOTAnimImportSettingsProperty, ) -oot_anim_panels = (OOT_ExportAnimPanel,) +oot_anim_panels = ( + OOT_ExportAnimPanel, + OOT_LinkAnimPanel, +) def oot_anim_panel_register(): @@ -509,30 +926,14 @@ def oot_anim_panel_unregister(): def oot_anim_register(): - bpy.types.Scene.ootAnimIsCustomExport = bpy.props.BoolProperty(name="Use Custom Path") - bpy.types.Scene.ootAnimExportCustomPath = bpy.props.StringProperty(name="Folder", subtype="FILE_PATH") - bpy.types.Scene.ootAnimExportFolderName = bpy.props.StringProperty(name="Animation Folder", default="object_geldb") - - bpy.types.Scene.ootAnimIsCustomImport = bpy.props.BoolProperty(name="Use Custom Path") - bpy.types.Scene.ootAnimImportCustomPath = bpy.props.StringProperty(name="Folder", subtype="FILE_PATH") - bpy.types.Scene.ootAnimImportFolderName = bpy.props.StringProperty(name="Animation Folder", default="object_geldb") - - bpy.types.Scene.ootAnimSkeletonName = bpy.props.StringProperty(name="Skeleton Name", default="gGerudoRedSkel") - bpy.types.Scene.ootAnimName = bpy.props.StringProperty(name="Anim Name", default="gGerudoRedSpinAttackAnim") for cls in oot_anim_classes: register_class(cls) + bpy.types.Object.ootLinkTextureAnim = bpy.props.PointerProperty(type=OOTLinkTextureAnimProperty) + def oot_anim_unregister(): - del bpy.types.Scene.ootAnimIsCustomExport - del bpy.types.Scene.ootAnimExportCustomPath - del bpy.types.Scene.ootAnimExportFolderName - - del bpy.types.Scene.ootAnimIsCustomImport - del bpy.types.Scene.ootAnimImportCustomPath - del bpy.types.Scene.ootAnimImportFolderName - - del bpy.types.Scene.ootAnimSkeletonName - del bpy.types.Scene.ootAnimName for cls in reversed(oot_anim_classes): unregister_class(cls) + + del bpy.types.Object.ootLinkTextureAnim diff --git a/fast64_internal/oot/oot_collision.py b/fast64_internal/oot/oot_collision.py index 772b213..e8c9634 100644 --- a/fast64_internal/oot/oot_collision.py +++ b/fast64_internal/oot/oot_collision.py @@ -577,7 +577,7 @@ class OOT_ExportCollision(bpy.types.Operator): if type(obj.data) is not bpy.types.Mesh: raise PluginError("No mesh object selected.") - finalTransform = mathutils.Matrix.Scale(context.scene.ootActorBlenderScale, 4) + finalTransform = mathutils.Matrix.Scale(getOOTScale(obj.ootActorScale), 4) try: scaleValue = bpy.context.scene.ootBlenderScale diff --git a/fast64_internal/oot/oot_f3d_writer.py b/fast64_internal/oot/oot_f3d_writer.py index 3a0d0eb..e53b494 100644 --- a/fast64_internal/oot/oot_f3d_writer.py +++ b/fast64_internal/oot/oot_f3d_writer.py @@ -1,8 +1,18 @@ -import bpy, os, mathutils +import bpy, os, mathutils, re from bpy.utils import register_class, unregister_class from ..panels import OOT_Panel -from ..utility import PluginError, CData, prop_split, writeCData, raisePluginError, getGroupIndexFromname, toAlnum -from ..f3d.f3d_parser import importMeshC, ootEnumDrawLayers +from ..utility import ( + PluginError, + CData, + prop_split, + writeCData, + raisePluginError, + getGroupIndexFromname, + toAlnum, + readFile, + writeFile, +) +from ..f3d.f3d_parser import importMeshC, ootEnumDrawLayers, getImportData from ..f3d.f3d_gbi import DLFormat, TextureExportSettings, ScrollMethod, F3D from ..f3d.f3d_writer import ( @@ -23,6 +33,8 @@ from .oot_utility import ( ootCleanupScene, ootGetPath, addIncludeFiles, + replaceMatchContent, + getOOTScale, ) from .oot_model_classes import ( @@ -31,8 +43,15 @@ from .oot_model_classes import ( OOTModel, OOTGfxFormatter, OOTDynamicTransformProperty, + ootGetActorData, + ootGetLinkData, + ootGetIncludedAssetData, ) +from .oot_scene_room import * +from .oot_texture_array import TextureFlipbook, ootReadTextureArrays +from ..f3d.flipbook import flipbook_to_c, flipbook_2d_to_c, flipbook_data_to_c + class OOTDLExportSettings(bpy.types.PropertyGroup): name: bpy.props.StringProperty(name="DL Name", default="gBoulderFragmentsDL") @@ -41,6 +60,9 @@ class OOTDLExportSettings(bpy.types.PropertyGroup): isCustom: bpy.props.BoolProperty(name="Use Custom Path") removeVanillaData: bpy.props.BoolProperty(name="Replace Vanilla DLs") drawLayer: bpy.props.EnumProperty(name="Draw Layer", items=ootEnumDrawLayers) + actorOverlayName: bpy.props.StringProperty(name="Overlay", default="") + flipbookUses2DArray: bpy.props.BoolProperty(name="Has 2D Flipbook Array", default=False) + flipbookArrayIndex2D: bpy.props.IntProperty(name="Index if 2D Array", default=0, min=0) customAssetIncludeDir: bpy.props.StringProperty( name="Asset Include Directory", default="assets/objects/gameplay_keep", @@ -56,6 +78,11 @@ class OOTDLImportSettings(bpy.types.PropertyGroup): removeDoubles: bpy.props.BoolProperty(name="Remove Doubles", default=True) importNormals: bpy.props.BoolProperty(name="Import Normals", default=True) drawLayer: bpy.props.EnumProperty(name="Draw Layer", items=ootEnumDrawLayers) + actorOverlayName: bpy.props.StringProperty(name="Overlay", default="") + flipbookUses2DArray: bpy.props.BoolProperty(name="Has 2D Flipbook Array", default=False) + flipbookArrayIndex2D: bpy.props.IntProperty(name="Index if 2D Array", default=0, min=0) + autoDetectActorScale: bpy.props.BoolProperty(name="Auto Detect Actor Scale", default=True) + actorScale: bpy.props.FloatProperty(name="Actor Scale", min=0, default=100) # returns: @@ -231,6 +258,9 @@ def ootConvertMeshToC( drawLayer = settings.drawLayer removeVanillaData = settings.removeVanillaData name = toAlnum(settings.name) + overlayName = settings.actorOverlayName + flipbookUses2DArray = settings.flipbookUses2DArray + flipbookArrayIndex2D = settings.flipbookArrayIndex2D if flipbookUses2DArray else None try: obj, allObjs = ootDuplicateHierarchy(originalObj, None, False, OOTObjectCategorizer()) @@ -266,9 +296,14 @@ def ootConvertMeshToC( data.append(exportData.all()) + if isCustomExport: + textureArrayData = writeTextureArraysNew(fModel, flipbookArrayIndex2D) + data.append(textureArrayData) + writeCData(data, os.path.join(path, name + ".h"), os.path.join(path, name + ".c")) if not isCustomExport: + writeTextureArraysExisting(bpy.context.scene.ootDecompPath, overlayName, False, flipbookArrayIndex2D, fModel) addIncludeFiles(folderName, path, name) if removeVanillaData: headerPath = os.path.join(path, folderName + ".h") @@ -276,6 +311,154 @@ def ootConvertMeshToC( removeDL(sourcePath, headerPath, name) +def writeTextureArraysNew(fModel: OOTModel, arrayIndex: int): + textureArrayData = CData() + for flipbook in fModel.flipbooks: + if flipbook.exportMode == "Array": + if arrayIndex is not None: + textureArrayData.source += flipbook_2d_to_c(flipbook, True, arrayIndex + 1) + "\n" + else: + textureArrayData.source += flipbook_to_c(flipbook, True) + "\n" + return textureArrayData + + +def getActorFilepath(basePath: str, overlayName: str | None, isLink: bool, checkDataPath: bool = False): + if isLink: + actorFilePath = os.path.join(basePath, f"src/code/z_player_lib.c") + else: + actorFilePath = os.path.join(basePath, f"src/overlays/actors/{overlayName}/z_{overlayName[4:].lower()}.c") + actorFileDataPath = f"{actorFilePath[:-2]}_data.c" # some bosses store texture arrays here + + if checkDataPath and os.path.exists(actorFileDataPath): + actorFilePath = actorFileDataPath + + return actorFilePath + + +def writeTextureArraysExisting( + exportPath: str, overlayName: str, isLink: bool, flipbookArrayIndex2D: int, fModel: OOTModel +): + actorFilePath = getActorFilepath(exportPath, overlayName, isLink, True) + + if not os.path.exists(actorFilePath): + print(f"{actorFilePath} not found, ignoring texture array writing.") + return + + actorData = readFile(actorFilePath) + newData = actorData + + for flipbook in fModel.flipbooks: + if flipbook.exportMode == "Array": + if flipbookArrayIndex2D is None: + newData = writeTextureArraysExisting1D(newData, flipbook) + else: + newData = writeTextureArraysExisting2D(newData, flipbook, flipbookArrayIndex2D) + + if newData != actorData: + writeFile(actorFilePath, newData) + + +def writeTextureArraysExisting1D(data: str, flipbook: TextureFlipbook) -> str: + newData = data + arrayMatch = re.search( + r"(static\s*)?void\s*\*\s*" + re.escape(flipbook.name) + r"\s*\[\s*\]\s*=\s*\{(((?!\}).)*)\}\s*;", + newData, + flags=re.DOTALL, + ) + + # replace array if found + if arrayMatch: + newArrayData = flipbook_to_c(flipbook, arrayMatch.group(1)) + newData = newData[: arrayMatch.start(0)] + newArrayData + newData[arrayMatch.end(0) :] + + # otherwise, add to end of asset includes + else: + newArrayData = flipbook_to_c(flipbook, True) + # get last asset include + includeMatch = None + for includeMatchItem in re.finditer(r"\#include\s*\"assets/.*?\"\s*?\n", newData, flags=re.DOTALL): + includeMatch = includeMatchItem + if includeMatch: + newData = newData[: includeMatch.end(0)] + newArrayData + "\n" + newData[includeMatch.end(0) :] + else: + newData += newArrayData + "\n" + + return newData + + +# for flipbook textures, we only replace one element of the 2D array. +def writeTextureArraysExisting2D(data: str, flipbook: TextureFlipbook, flipbookArrayIndex2D: int) -> str: + newData = data + + # for !AVOID_UB, Link has textures in 2D Arrays + array2DMatch = re.search( + r"(static\s*)?void\s*\*\s*" + + re.escape(flipbook.name) + + r"\s*\[\s*\]\s*\[\s*[0-9a-fA-Fx]*\s*\]\s*=\s*\{(.*?)\}\s*;", + newData, + flags=re.DOTALL, + ) + + newArrayData = "{ " + flipbook_data_to_c(flipbook) + " }" + + # build a list of arrays here + # replace existing element if list is large enough + # otherwise, pad list with repeated arrays + if array2DMatch: + arrayMatchData = [ + arrayMatch.group(0) for arrayMatch in re.finditer(r"\{(.*?)\}", array2DMatch.group(2), flags=re.DOTALL) + ] + + if flipbookArrayIndex2D >= len(arrayMatchData): + while len(arrayMatchData) <= flipbookArrayIndex2D: + arrayMatchData.append(newArrayData) + else: + arrayMatchData[flipbookArrayIndex2D] = newArrayData + + newArray2DData = ",\n".join([item for item in arrayMatchData]) + newData = replaceMatchContent(newData, newArray2DData, array2DMatch, 2) + + # otherwise, add to end of asset includes + else: + arrayMatchData = [newArrayData] * (flipbookArrayIndex2D + 1) + newArray2DData = ",\n".join([item for item in arrayMatchData]) + + # get last asset include + includeMatch = None + for includeMatchItem in re.finditer(r"\#include\s*\"assets/.*?\"\s*?\n", newData, flags=re.DOTALL): + includeMatch = includeMatchItem + if includeMatch: + newData = newData[: includeMatch.end(0)] + newArray2DData + "\n" + newData[includeMatch.end(0) :] + else: + newData += newArray2DData + "\n" + + return newData + + +# Note this does not work well with actors containing multiple "parts". (z_en_honotrap) +def ootReadActorScale(basePath: str, overlayName: str, isLink: bool) -> float: + if not isLink: + actorData = ootGetActorData(basePath, overlayName) + else: + actorData = ootGetLinkData(basePath) + + chainInitMatch = re.search(r"CHAIN_VEC3F_DIV1000\s*\(\s*scale\s*,\s*(.*?)\s*,", actorData, re.DOTALL) + if chainInitMatch is not None: + scale = chainInitMatch.group(1).strip() + if scale[-1] == "f": + scale = scale[:-1] + return getOOTScale(1 / (float(scale) / 1000)) + + actorScaleMatch = re.search(r"Actor\_SetScale\s*\(.*?,\s*(.*?)\s*\)", actorData, re.DOTALL) + if actorScaleMatch is not None: + scale = actorScaleMatch.group(1).strip() + if scale[-1] == "f": + scale = scale[:-1] + return getOOTScale(1 / float(scale)) + + return getOOTScale(100) + + class OOT_DisplayListPanel(bpy.types.Panel): bl_label = "Display List Inspector" bl_idname = "OBJECT_PT_OOT_DL_Inspector" @@ -299,6 +482,10 @@ class OOT_DisplayListPanel(bpy.types.Panel): box.prop(obj, "ignore_render") box.prop(obj, "ignore_collision") + if not (obj.parent is not None and isinstance(obj.parent.data, bpy.types.Armature)): + prop_split(box, obj, "ootActorScale", "Actor Scale") + box.label(text="This applies to actor exports only.", icon="INFO") + # Doesn't work since all static meshes are pre-transformed # box.prop(obj.ootDynamicTransform, "billboard") @@ -324,27 +511,37 @@ class OOT_ImportDL(bpy.types.Operator): folderName = settings.folder importPath = bpy.path.abspath(settings.customPath) isCustomImport = settings.isCustom - scale = context.scene.ootActorBlenderScale basePath = bpy.path.abspath(context.scene.ootDecompPath) removeDoubles = settings.removeDoubles importNormals = settings.importNormals drawLayer = settings.drawLayer + overlayName = settings.actorOverlayName + flipbookUses2DArray = settings.flipbookUses2DArray + flipbookArrayIndex2D = settings.flipbookArrayIndex2D if flipbookUses2DArray else None - filepaths = [ootGetObjectPath(isCustomImport, importPath, folderName)] + paths = [ootGetObjectPath(isCustomImport, importPath, folderName)] + data = getImportData(paths) + f3dContext = OOTF3DContext(F3D("F3DEX2/LX2", False), [name], basePath) + + scale = getOOTScale(settings.actorScale) if not isCustomImport: - filepaths.append( - os.path.join(bpy.context.scene.ootDecompPath, "assets/objects/gameplay_keep/gameplay_keep.c") - ) + data = ootGetIncludedAssetData(basePath, paths, data) + data - importMeshC( - filepaths, + if overlayName is not None: + ootReadTextureArrays(basePath, overlayName, name, f3dContext, False, flipbookArrayIndex2D) + if settings.autoDetectActorScale: + scale = ootReadActorScale(basePath, overlayName, False) + + obj = importMeshC( + data, name, scale, removeDoubles, importNormals, drawLayer, - OOTF3DContext(F3D("F3DEX2/LX2", False), [name], basePath), + f3dContext, ) + obj.ootActorScale = scale / bpy.context.scene.ootBlenderScale self.report({"INFO"}, "Success!") return {"FINISHED"} @@ -374,7 +571,7 @@ class OOT_ExportDL(bpy.types.Operator): if type(obj.data) is not bpy.types.Mesh: raise PluginError("Mesh not selected.") - finalTransform = mathutils.Matrix.Scale(context.scene.ootActorBlenderScale, 4) + finalTransform = mathutils.Matrix.Scale(getOOTScale(obj.ootActorScale), 4) try: # exportPath, levelName = getPathAndLevel(context.scene.geoCustomExport, @@ -421,6 +618,12 @@ class OOT_ExportDLPanel(OOT_Panel): if exportSettings.isCustom: prop_split(col, exportSettings, "customAssetIncludeDir", "Asset Include Path") prop_split(col, exportSettings, "customPath", "Path") + else: + prop_split(col, exportSettings, "actorOverlayName", "Overlay (Optional)") + col.prop(exportSettings, "flipbookUses2DArray") + if exportSettings.flipbookUses2DArray: + box = col.box().column() + prop_split(box, exportSettings, "flipbookArrayIndex2D", "Flipbook Index") prop_split(col, exportSettings, "drawLayer", "Export Draw Layer") col.prop(exportSettings, "isCustom") @@ -434,6 +637,14 @@ class OOT_ExportDLPanel(OOT_Panel): prop_split(col, importSettings, "customPath", "File") else: prop_split(col, importSettings, "folder", "Object") + prop_split(col, importSettings, "actorOverlayName", "Overlay (Optional)") + col.prop(importSettings, "autoDetectActorScale") + if not importSettings.autoDetectActorScale: + prop_split(col, importSettings, "actorScale", "Actor Scale") + col.prop(importSettings, "flipbookUses2DArray") + if importSettings.flipbookUses2DArray: + box = col.box().column() + prop_split(box, importSettings, "flipbookArrayIndex2D", "Flipbook Index") prop_split(col, importSettings, "drawLayer", "Import Draw Layer") col.prop(importSettings, "isCustom") @@ -512,7 +723,7 @@ class OOT_MaterialPanel(bpy.types.Panel): else: drawLayer = mat.f3d_mat.draw_layer.oot - drawOOTMaterialProperty(col.box().column(), mat.ootMaterial, drawLayer) + drawOOTMaterialProperty(col.box().column(), mat, drawLayer) def drawOOTMaterialDrawLayerProperty(layout, matDrawLayerProp, suffix): @@ -534,14 +745,18 @@ def drawOOTMaterialDrawLayerProperty(layout, matDrawLayerProp, suffix): drawLayerSuffix = {"Opaque": "OPA", "Transparent": "XLU", "Overlay": "OVL"} -def drawOOTMaterialProperty(layout, matProp, drawLayer): +def drawOOTMaterialProperty(layout, mat, drawLayer): if drawLayer == "Overlay": return + matProp = mat.ootMaterial suffix = "(" + drawLayerSuffix[drawLayer] + ")" layout.box().column().label(text="OOT Dynamic Material Properties " + suffix) layout.label(text="See gSPSegment calls in z_scene_table.c.") layout.label(text="Based off draw config index in gSceneTable.") drawOOTMaterialDrawLayerProperty(layout.column(), getattr(matProp, drawLayer.lower()), suffix) + if not mat.is_f3d: + return + f3d_mat = mat.f3d_mat class OOTDynamicMaterialDrawLayerProperty(bpy.types.PropertyGroup): @@ -556,6 +771,18 @@ class OOTDynamicMaterialDrawLayerProperty(bpy.types.PropertyGroup): customCall1: bpy.props.BoolProperty() customCall1_seg: bpy.props.StringProperty(description="Segment address of a display list to call, e.g. 0x08000010") + def key(self): + return ( + self.segment8, + self.segment9, + self.segmentA, + self.segmentB, + self.segmentC, + self.segmentD, + self.customCall0_seg if self.customCall0 else None, + self.customCall1_seg if self.customCall1 else None, + ) + # The reason these are separate is for the case when the user changes the material draw layer, but not the # dynamic material calls. This could cause crashes which would be hard to detect. @@ -563,6 +790,9 @@ class OOTDynamicMaterialProperty(bpy.types.PropertyGroup): opaque: bpy.props.PointerProperty(type=OOTDynamicMaterialDrawLayerProperty) transparent: bpy.props.PointerProperty(type=OOTDynamicMaterialDrawLayerProperty) + def key(self): + return (self.opaque.key(), self.transparent.key()) + oot_dl_writer_classes = ( OOTDefaultRenderModesProperty, diff --git a/fast64_internal/oot/oot_model_classes.py b/fast64_internal/oot/oot_model_classes.py index cf514e9..a057cd9 100644 --- a/fast64_internal/oot/oot_model_classes.py +++ b/fast64_internal/oot/oot_model_classes.py @@ -1,11 +1,26 @@ -import bpy -from ..f3d.f3d_writer import VertexGroupInfo, TriangleConverterInfo -from ..f3d.f3d_parser import F3DContext -from ..f3d.f3d_material import createF3DMat -from ..utility import CData, hexOrDecInt +import bpy, os, re +from typing import Union +from ..f3d.f3d_writer import ( + VertexGroupInfo, + TriangleConverterInfo, + saveOrGetTextureDefinition, + saveOrGetPaletteAndImageDefinition, + getTextureNameTexRef, + saveOrGetPaletteOnlyDefinition, + FSharedPalette, + DPLoadTLUTCmd, + DPSetTextureLUT, + DPSetTile, + texFormatOf, +) +from ..f3d.f3d_parser import F3DContext, F3DTextureReference, getImportData +from ..f3d.f3d_material import createF3DMat, TextureProperty +from ..utility import CData, hexOrDecInt, PluginError from ..f3d.f3d_gbi import ( FModel, + FMaterial, + FImage, GfxMatWriteMethod, SPDisplayList, GfxList, @@ -15,11 +30,79 @@ from ..f3d.f3d_gbi import ( GfxFormatter, MTX_SIZE, ) +from ..f3d.flipbook import TextureFlipbook, FlipbookProperty, usesFlipbook, ootFlipbookReferenceIsValid + +# read included asset data +def ootGetIncludedAssetData(basePath: str, currentPaths: list[str], data: str) -> str: + includeData = "" + searchedPaths = currentPaths[:] + + print("Included paths:") + + # search assets + for includeMatch in re.finditer(r"\#include\s*\"(assets/objects/(.*?))\.h\"", data): + path = os.path.join(basePath, includeMatch.group(1) + ".c") + if path in searchedPaths: + continue + searchedPaths.append(path) + subIncludeData = getImportData([path]) + "\n" + includeData += subIncludeData + print(path) + + for subIncludeMatch in re.finditer(r"\#include\s*\"(((?![/\"]).)*)\.c\"", subIncludeData): + subPath = os.path.join(os.path.dirname(path), subIncludeMatch.group(1) + ".c") + if subPath in searchedPaths: + continue + searchedPaths.append(subPath) + print(subPath) + includeData += getImportData([subPath]) + "\n" + + # search same directory c includes, both in current path and in included object files + # these are usually fast64 exported files + for includeMatch in re.finditer(r"\#include\s*\"(((?![/\"]).)*)\.c\"", data): + sameDirPaths = [ + os.path.join(os.path.dirname(currentPath), includeMatch.group(1) + ".c") for currentPath in currentPaths + ] + sameDirPathsToSearch = [] + for sameDirPath in sameDirPaths: + if sameDirPath not in searchedPaths: + sameDirPathsToSearch.append(sameDirPath) + + for sameDirPath in sameDirPathsToSearch: + print(sameDirPath) + + includeData += getImportData(sameDirPathsToSearch) + "\n" + return includeData + + +def ootGetActorDataPaths(basePath: str, overlayName: str) -> list[str]: + actorFilePath = os.path.join(basePath, f"src/overlays/actors/{overlayName}/z_{overlayName[4:].lower()}.c") + actorFileDataPath = f"{actorFilePath[:-2]}_data.c" # some bosses store texture arrays here + + return [actorFileDataPath, actorFilePath] + + +# read actor data +def ootGetActorData(basePath: str, overlayName: str) -> str: + actorData = getImportData(ootGetActorDataPaths(basePath, overlayName)) + return actorData + + +def ootGetLinkData(basePath: str) -> str: + linkFilePath = os.path.join(basePath, f"src/code/z_player_lib.c") + actorData = getImportData([linkFilePath]) + + return actorData class OOTModel(FModel): def __init__(self, f3dType, isHWv1, name, DLFormat, drawLayerOverride): self.drawLayerOverride = drawLayerOverride + self.flipbooks: list[TextureFlipbook] = [] + + # key: first flipbook image + # value: list of flipbook textures in order + self.processedFlipbooks: dict[bpy.types.Image, list[bpy.types.Image]] = {} FModel.__init__(self, f3dType, isHWv1, name, DLFormat, GfxMatWriteMethod.WriteAll) def getDrawLayerV3(self, obj): @@ -41,7 +124,170 @@ class OOTModel(FModel): else: return texFmt.lower() - def onMaterialCommandsBuilt(self, gfxList, revertList, material, drawLayer): + def modifyDLForCIFlipbook(self, fMaterial: FMaterial, fPalette: FMaterial, texProp: TextureProperty): + # Modfiy DL to use new palette texture + tlutCmdIndex = 0 + gfxList = fMaterial.material + while tlutCmdIndex < len(gfxList.commands): + if isinstance(gfxList.commands[tlutCmdIndex], DPLoadTLUTCmd): + loadTlutCmd = gfxList.commands[tlutCmdIndex] + loadTlutCmd.count = int(round(len(fPalette.data) / 2)) - 1 + + setTLUTCmd = gfxList.commands[tlutCmdIndex - 5] + setTImageCmd = gfxList.commands[tlutCmdIndex - 4] + if tlutCmdIndex < 5 or not isinstance(setTLUTCmd, DPSetTextureLUT): + raise PluginError("Error when processing flipbook CI textures: unexpected display list format.") + setTImageCmd.fmt = texFormatOf[texProp.ci_format] + setTImageCmd.image = fPalette + setTLUTCmd.mode = "G_TT_RGBA16" if setTImageCmd.fmt == "G_IM_FMT_RGBA" else "G_TT_IA16" + break + + else: + tlutCmdIndex += 1 + if tlutCmdIndex == len(gfxList.commands): + raise PluginError(f"Can not find TLUT command in material {fMaterial.name}") + + def addFlipbookWithRepeatCheck(self, flipbook: TextureFlipbook): + for existingFlipbook in self.flipbooks: + if existingFlipbook.name == flipbook.name: + if len(existingFlipbook.textureNames) != len(flipbook.textureNames): + raise PluginError( + f"There are two flipbooks with differing elements trying to write to the same texture array name: {flipbook.name}." + + f"\nMake sure that this flipbook name is unique, or that repeated uses of this name use the same textures is the same order/format." + ) + for i in range(len(flipbook.textureNames)): + if existingFlipbook.textureNames[i] != flipbook.textureNames[i]: + raise PluginError( + f"There are two flipbooks with differing elements trying to write to the same texture array name: {flipbook.name}." + + f"\nMake sure that this flipbook name is unique, or that repeated uses of this name use the same textures is the same order/format." + ) + self.flipbooks.append(flipbook) + + def validateCIFlipbook( + self, existingFPalette: FImage, alreadyExists: bool, fPalette: FImage, flipbookImage: bpy.types.Image + ) -> Union[FImage, bool]: + if existingFPalette is None: + if alreadyExists: + if fPalette: + return fPalette + else: + raise PluginError("FPalette not found.") + else: + return False + else: + if ( + alreadyExists # texture already processed for this export + and fPalette is not None # texture is not a repeat within flipbook + and existingFPalette != False # a previous texture used an existing palette + and fPalette != existingFPalette # the palettes do not match + ): + raise PluginError( + f"Cannot reuse a CI texture across multiple flipbooks: {str(flipbookImage)}. " + + f"Flipbook textures should only be reused if they are in the same grouping/order, including LOD skeletons." + ) + elif ( + not alreadyExists # current texture has not been processed yet + and existingFPalette is not None + and existingFPalette != False # a previous texture used an existing palette + ): + raise PluginError( + f"Flipbook textures before this were part of a different palette: {str(flipbookImage)}. " + + f"Flipbook textures should only be reused if they are in the same grouping/order, including LOD skeletons." + ) + return existingFPalette + + def processFlipbookCI(self, fMaterial: FMaterial, flipbookProp: FlipbookProperty, texProp: TextureProperty): + # print("Processing flipbook...") + flipbook = TextureFlipbook(flipbookProp.name, flipbookProp.exportMode, []) + sharedPalette = FSharedPalette(self.name + "_" + flipbookProp.textures[0].image.name + "_pal") + existingFPalette = None + fImages = [] + for flipbookTexture in flipbookProp.textures: + if flipbookTexture.image is None: + raise PluginError(f"Flipbook for {fMaterial.name} has a texture array item that has not been set.") + # print(f"Texture: {str(flipbookTexture.image)}") + name = ( + flipbookTexture.name + if flipbookProp.exportMode == "Individual" + else self.name + "_" + flipbookTexture.image.name + "_" + texProp.tex_format.lower() + ) + + texName = getTextureNameTexRef(texProp, self.name) + # fPalette should be None here, since sharedPalette is not None + fImage, fPalette, alreadyExists = saveOrGetPaletteAndImageDefinition( + fMaterial, + self, + flipbookTexture.image, + name, + texProp.tex_format, + texProp.ci_format, + True, + sharedPalette, + ) + existingFPalette = self.validateCIFlipbook(existingFPalette, alreadyExists, fPalette, flipbookTexture.image) + fImages.append(fImage) + + # do this here to check for modified names due to repeats + flipbook.textureNames.append(fImage.name) + + self.addFlipbookWithRepeatCheck(flipbook) + + # print(f"Palette length for {sharedPalette.name}: {len(sharedPalette.palette)}") + firstImage = flipbookProp.textures[0].image + self.processedFlipbooks[firstImage] = [flipbookTex.image for flipbookTex in flipbookProp.textures] + + if existingFPalette == False: + + palFormat = texProp.ci_format + fPalette = saveOrGetPaletteOnlyDefinition( + fMaterial, + self, + firstImage, + sharedPalette.name, + texProp.tex_format, + palFormat, + True, + sharedPalette.palette, + ) + + # using the first image for the key, apply paletteKey to all images + # while this is not ideal, its better to us an image for the key as + # names are modified when duplicates are found + paletteKey = (firstImage, (palFormat, "PAL")) + for fImage in fImages: + fImage.paletteKey = paletteKey + else: + fPalette = existingFPalette + + self.modifyDLForCIFlipbook(fMaterial, fPalette, texProp) + + def processFlipbookNonCI(self, fMaterial: FMaterial, flipbookProp: FlipbookProperty, texProp: TextureProperty): + flipbook = TextureFlipbook(flipbookProp.name, flipbookProp.exportMode, []) + for flipbookTexture in flipbookProp.textures: + if flipbookTexture.image is None: + raise PluginError(f"Flipbook for {fMaterial.name} has a texture array item that has not been set.") + # print(f"Texture: {str(flipbookTexture.image)}") + name = ( + flipbookTexture.name + if flipbookProp.exportMode == "Individual" + else self.name + "_" + flipbookTexture.image.name + "_" + texProp.tex_format.lower() + ) + fImage = saveOrGetTextureDefinition( + fMaterial, + self, + flipbookTexture.image, + name, + texProp.tex_format, + True, + ) + + # do this here to check for modified names due to repeats + flipbook.textureNames.append(fImage.name) + self.addFlipbookWithRepeatCheck(flipbook) + + def onMaterialCommandsBuilt(self, fMaterial, material, drawLayer): + # handle dynamic material calls + gfxList = fMaterial.material matDrawLayer = getattr(material.ootMaterial, drawLayer.lower()) for i in range(8, 14): if getattr(matDrawLayer, "segment" + format(i, "X")): @@ -55,6 +301,27 @@ class OOTModel(FModel): SPDisplayList(GfxList(getattr(matDrawLayer, p + "_seg"), GfxListTag.Material, DLFormat.Static)) ) + # save flipbook textures + for i in range(2): + flipbookProp = getattr(material.flipbookGroup, "flipbook" + str(i)) + texProp = getattr(material.f3d_mat, "tex" + str(i)) + if usesFlipbook(material, flipbookProp, i, True, ootFlipbookReferenceIsValid): + if len(flipbookProp.textures) == 0: + raise PluginError(f"{str(material)} cannot have a flipbook material with no flipbook textures.") + + if texProp.tex_format[:2] == "CI": + self.processFlipbookCI( + fMaterial, + flipbookProp, + texProp, + ) + else: + self.processFlipbookNonCI( + fMaterial, + flipbookProp, + texProp, + ) + def onAddMesh(self, fMesh, contextObj): if contextObj is not None and hasattr(contextObj, "ootDynamicTransform"): if contextObj.ootDynamicTransform.billboard: @@ -133,6 +400,8 @@ class OOTF3DContext(F3DContext): self.limbList = limbList self.dlList = [] # in the order they are rendered self.isBillboard = False + self.flipbooks = {} # {(segment, draw layer) : TextureFlipbook} + materialContext = createF3DMat(None, preset="oot_shaded_solid") # materialContext.f3d_mat.rdp_settings.g_mdsft_cycletype = "G_CYC_1CYCLE" F3DContext.__init__(self, f3d, basePath, materialContext) @@ -170,14 +439,15 @@ class OOTF3DContext(F3DContext): try: pointer = hexOrDecInt(name) except: + if name == "gEmptyDL": + return None return name else: segment = pointer >> 24 - print("POINTER") if segment >= 0x08 and segment <= 0x0D: - print("SETTING " + str(segment)) setattr(self.materialContext.ootMaterial.opaque, "segment" + format(segment, "1X"), True) setattr(self.materialContext.ootMaterial.transparent, "segment" + format(segment, "1X"), True) + self.materialChanged = True return None return name @@ -191,15 +461,104 @@ class OOTF3DContext(F3DContext): # if (pointer >> 24) == 0x08: # print("Unhandled OOT pointer: " + textureName) + def getMaterialKey(self, material: bpy.types.Material): + return (material.ootMaterial.key(), material.f3d_mat.key()) + + def clearGeometry(self): + self.dlList = [] + self.isBillboard = False + super().clearGeometry() + def clearMaterial(self): self.isBillboard = False - clearOOTMaterialDrawLayerProperty(self.materialContext.ootMaterial.opaque) - clearOOTMaterialDrawLayerProperty(self.materialContext.ootMaterial.transparent) + + # Don't clear ootMaterial, some skeletons (Link) require dynamic material calls to be preserved between limbs + clearOOTFlipbookProperty(self.materialContext.flipbookGroup.flipbook0) + clearOOTFlipbookProperty(self.materialContext.flipbookGroup.flipbook1) F3DContext.clearMaterial(self) def postMaterialChanged(self): - clearOOTMaterialDrawLayerProperty(self.materialContext.ootMaterial.opaque) - clearOOTMaterialDrawLayerProperty(self.materialContext.ootMaterial.transparent) + pass + + def handleTextureReference( + self, + name: str, + image: F3DTextureReference, + material: bpy.types.Material, + index: int, + tileSettings: DPSetTile, + data: str, + ): + # check for texture arrays. + clearOOTFlipbookProperty(getattr(material.flipbookGroup, "flipbook" + str(index))) + match = re.search(f"(0x0[0-9a-fA-F])000000", name) + if match: + segment = int(match.group(1), 16) + flipbookKey = (segment, material.f3d_mat.draw_layer.oot) + if flipbookKey in self.flipbooks: + flipbook = self.flipbooks[flipbookKey] + + flipbookProp = getattr(material.flipbookGroup, "flipbook" + str(index)) + flipbookProp.enable = True + flipbookProp.exportMode = flipbook.exportMode + if flipbookProp.exportMode == "Array": + flipbookProp.name = flipbook.name + + if len(flipbook.textureNames) == 0: + raise PluginError( + f'Texture array "{flipbookProp.name}" pointed at segment {hex(segment)} is a zero element array, which is invalid.' + ) + for textureName in flipbook.textureNames: + image = self.loadTexture(data, textureName, None, tileSettings, False) + if not isinstance(image, bpy.types.Image): + raise PluginError( + f'Could not find texture "{textureName}", so it can not be used in a flipbook texture.' + ) + flipbookProp.textures.add() + flipbookProp.textures[-1].image = image + + if flipbookProp.exportMode == "Individual": + flipbookProp.textures[-1].name = textureName + + texProp = getattr(material.f3d_mat, "tex" + str(index)) + texProp.tex = flipbookProp.textures[0].image # for visual purposes only, will be ignored + texProp.use_tex_reference = True + texProp.tex_reference = name + else: + super().handleTextureReference(name, image, material, index, tileSettings, data) + else: + super().handleTextureReference(name, image, material, index, tileSettings, data) + + def handleTextureValue(self, material: bpy.types.Material, image: bpy.types.Image, index: int): + clearOOTFlipbookProperty(getattr(material.flipbookGroup, "flipbook" + str(index))) + super().handleTextureValue(material, image, index) + + def handleApplyTLUT( + self, + material: bpy.types.Material, + texProp: TextureProperty, + tlut: bpy.types.Image, + index: int, + ): + + flipbook = getattr(material.flipbookGroup, "flipbook" + str(index)) + if usesFlipbook(material, flipbook, index, True, ootFlipbookReferenceIsValid): + # Don't apply TLUT to texProp.tex, as it is the same texture as the first flipbook texture. + # Make sure to check if tlut is already applied (ex. LOD skeleton uses same flipbook textures) + # applyTLUTToIndex() doesn't check for this if texProp.use_tex_reference. + for flipbookTexture in flipbook.textures: + if flipbookTexture.image not in self.tlutAppliedTextures: + self.applyTLUT(flipbookTexture.image, tlut) + self.tlutAppliedTextures.append(flipbookTexture.image) + else: + super().handleApplyTLUT(material, texProp, tlut, index) + + +def clearOOTFlipbookProperty(flipbookProp): + flipbookProp.enable = False + flipbookProp.name = "sFlipbookTextures" + flipbookProp.exportMode = "Array" + flipbookProp.textures.clear() def clearOOTMaterialDrawLayerProperty(matDrawLayerProp): diff --git a/fast64_internal/oot/oot_skeleton.py b/fast64_internal/oot/oot_skeleton.py index 1c84d64..92c9192 100644 --- a/fast64_internal/oot/oot_skeleton.py +++ b/fast64_internal/oot/oot_skeleton.py @@ -1,11 +1,18 @@ import mathutils, bpy, math, os, re from ..panels import OOT_Panel from ..f3d.f3d_gbi import DLFormat, FMesh, TextureExportSettings, ScrollMethod, F3D -from .oot_model_classes import OOTVertexGroupInfo, OOTModel, OOTGfxFormatter, OOTF3DContext, OOTDynamicTransformProperty +from .oot_model_classes import ( + OOTVertexGroupInfo, + OOTModel, + OOTGfxFormatter, + OOTF3DContext, + OOTDynamicTransformProperty, + ootGetIncludedAssetData, +) from bpy.utils import register_class, unregister_class -from ..f3d.f3d_writer import getInfoDict +from ..f3d.f3d_writer import getInfoDict, GfxList from ..f3d.f3d_parser import getImportData, parseF3D -from .oot_f3d_writer import ootProcessVertexGroup +from .oot_f3d_writer import ootProcessVertexGroup, writeTextureArraysNew, writeTextureArraysExisting, ootReadActorScale from ..f3d.f3d_material import ootEnumDrawLayers from ..utility import ( @@ -25,6 +32,7 @@ from ..utility import ( getGroupNameFromIndex, attemptModifierApply, cleanupDuplicatedObjects, + VertexWeightError, ) from .oot_utility import ( @@ -35,15 +43,29 @@ from .oot_utility import ( getSortedChildren, ootGetPath, addIncludeFiles, + getOOTScale, +) + +from ..utility_anim import armatureApplyWithMesh +from .oot_texture_array import ootReadTextureArrays +from .oot_skeleton_import_data import ( + ootEnumSkeletonImportMode, + applySkeletonRestPose, + OOT_SaveRestPose, + ootSkeletonImportDict, ) class OOTSkeletonExportSettings(bpy.types.PropertyGroup): + mode: bpy.props.EnumProperty(name="Mode", items=ootEnumSkeletonImportMode) name: bpy.props.StringProperty(name="Skeleton Name", default="gGerudoRedSkel") folder: bpy.props.StringProperty(name="Skeleton Folder", default="object_geldb") customPath: bpy.props.StringProperty(name="Custom Skeleton Path", subtype="FILE_PATH") isCustom: bpy.props.BoolProperty(name="Use Custom Path") removeVanillaData: bpy.props.BoolProperty(name="Replace Vanilla Skeletons On Export", default=True) + actorOverlayName: bpy.props.StringProperty(name="Overlay", default="ovl_En_GeldB") + flipbookUses2DArray: bpy.props.BoolProperty(name="Has 2D Flipbook Array", default=False) + flipbookArrayIndex2D: bpy.props.IntProperty(name="Index if 2D Array", default=0, min=0) customAssetIncludeDir: bpy.props.StringProperty( name="Asset Include Directory", default="assets/objects/object_geldb", @@ -58,6 +80,8 @@ class OOTSkeletonExportSettings(bpy.types.PropertyGroup): class OOTSkeletonImportSettings(bpy.types.PropertyGroup): + mode: bpy.props.EnumProperty(name="Mode", items=ootEnumSkeletonImportMode) + applyRestPose: bpy.props.BoolProperty(name="Apply Friendly Rest Pose (If Available)", default=True) name: bpy.props.StringProperty(name="Skeleton Name", default="gGerudoRedSkel") folder: bpy.props.StringProperty(name="Skeleton Folder", default="object_geldb") customPath: bpy.props.StringProperty(name="Custom Skeleton Path", subtype="FILE_PATH") @@ -65,6 +89,11 @@ class OOTSkeletonImportSettings(bpy.types.PropertyGroup): removeDoubles: bpy.props.BoolProperty(name="Remove Doubles On Import", default=True) importNormals: bpy.props.BoolProperty(name="Import Normals", default=True) drawLayer: bpy.props.EnumProperty(name="Import Draw Layer", items=ootEnumDrawLayers) + actorOverlayName: bpy.props.StringProperty(name="Overlay", default="ovl_En_GeldB") + flipbookUses2DArray: bpy.props.BoolProperty(name="Has 2D Flipbook Array", default=False) + flipbookArrayIndex2D: bpy.props.IntProperty(name="Index if 2D Array", default=0, min=0) + autoDetectActorScale: bpy.props.BoolProperty(name="Auto Detect Actor Scale", default=True) + actorScale: bpy.props.FloatProperty(name="Actor Scale", min=0, default=100) ootEnumBoneType = [ @@ -74,6 +103,20 @@ ootEnumBoneType = [ ] +def pollArmature(self, obj): + return isinstance(obj.data, bpy.types.Armature) + + +class OOTBoneProperty(bpy.types.PropertyGroup): + boneType: bpy.props.EnumProperty(name="Bone Type", items=ootEnumBoneType) + dynamicTransform: bpy.props.PointerProperty(type=OOTDynamicTransformProperty) + customDLName: bpy.props.StringProperty(name="Custom DL", default="gEmptyDL") + + +class OOTSkeletonProperty(bpy.types.PropertyGroup): + LOD: bpy.props.PointerProperty(type=bpy.types.Object, poll=pollArmature) + + class OOTSkeleton: def __init__(self, name): self.name = name @@ -161,8 +204,21 @@ class OOTSkeleton: return limbData +class OOTDLReference: + def __init__(self, name: str): + self.name = name + + class OOTLimb: - def __init__(self, skeletonName, boneName, index, translation, DL, lodDL): + def __init__( + self, + skeletonName: str, + boneName: str, + index: int, + translation: mathutils.Vector, + DL: GfxList | OOTDLReference, + lodDL: GfxList | OOTDLReference, + ): self.skeletonName = skeletonName self.boneName = boneName self.translation = translation @@ -280,10 +336,18 @@ def getGroupIndexOfVert(vert, armatureObj, obj, rootGroupIndex): nonBoneGroups.append(groupName) if len(actualGroups) == 0: - return rootGroupIndex + # return rootGroupIndex # highlightWeightErrors(obj, [vert], "VERT") - # raise VertexWeightError("All vertices must be part of a vertex group that corresponds to a bone in the armature.\n" +\ - # "Groups of the bad vert that don't correspond to a bone: " + str(nonBoneGroups) + '. If a vert is supposed to belong to this group then either a bone is missing or you have the wrong group.') + if len(nonBoneGroups) > 0: + raise VertexWeightError( + "All vertices must be part of a vertex group " + + "that corresponds to a bone in the armature.\n" + + "Groups of the bad vert that don't correspond to a bone: " + + str(nonBoneGroups) + + ". If a vert is supposed to belong to this group then either a bone is missing or you have the wrong group." + ) + else: + raise VertexWeightError("There are unweighted vertices in the mesh that must be weighted to a bone.") vertGroup = actualGroups[0] for group in actualGroups: @@ -294,7 +358,7 @@ def getGroupIndexOfVert(vert, armatureObj, obj, rootGroupIndex): return vertGroup.group -def ootDuplicateArmature(originalArmatureObj): +def ootDuplicateArmatureAndRemoveRotations(originalArmatureObj: bpy.types.Object): # Duplicate objects to apply scale / modifiers / linked data bpy.ops.object.select_all(action="DESELECT") @@ -319,6 +383,8 @@ def ootDuplicateArmature(originalArmatureObj): bpy.context.view_layer.objects.active = armatureObj bpy.ops.object.transform_apply(location=False, rotation=False, scale=True, properties=False) + ootRemoveRotationsFromArmature(armatureObj) + # Apply modifiers/data to mesh objs bpy.ops.object.select_all(action="DESELECT") for obj in meshObjs: @@ -365,6 +431,42 @@ def ootConvertArmatureToSkeletonWithMesh( ) +def ootRemoveRotationsFromArmature(armatureObj: bpy.types.Object) -> None: + checkForStartBone(armatureObj) + startBoneName = getStartBone(armatureObj) + + if bpy.context.mode != "EDIT": + bpy.ops.object.mode_set(mode="EDIT") + for editBone in armatureObj.data.edit_bones: + editBone.use_connect = False + bpy.ops.object.mode_set(mode="OBJECT") + ootRemoveRotationsFromBone(armatureObj, armatureObj.data.bones[startBoneName]) + armatureApplyWithMesh(armatureObj, bpy.context) + + +# TODO: check for bone type? +def ootRemoveRotationsFromBone(armatureObj: bpy.types.Object, bone: bpy.types.Bone): + for childBone in bone.children: + ootRemoveRotationsFromBone(armatureObj, childBone) + + yUpToZUp = mathutils.Quaternion((1, 0, 0), math.radians(90.0)).to_matrix().to_4x4() + + if bone.parent is not None: + transform = bone.parent.matrix_local.inverted() @ bone.matrix_local + else: + transform = bone.matrix_local + + # extract local transform, excluding rotation/scale + # apply the inverse of that to the pose bone to get it to zero-rotation rest pose + translate = mathutils.Matrix.Translation(transform.decompose()[0]) + undoRotationTransform = transform.inverted() @ translate + if bone.parent is None: + undoRotationTransform = undoRotationTransform @ yUpToZUp + + poseBone = armatureObj.pose.bones[bone.name] + poseBone.matrix_basis = undoRotationTransform + + def ootConvertArmatureToSkeleton( originalArmatureObj, convertTransformMatrix, @@ -377,7 +479,7 @@ def ootConvertArmatureToSkeleton( ): checkEmptyName(name) - armatureObj, meshObjs = ootDuplicateArmature(originalArmatureObj) + armatureObj, meshObjs = ootDuplicateArmatureAndRemoveRotations(originalArmatureObj) try: skeleton = OOTSkeleton(name) @@ -473,7 +575,7 @@ def ootProcessBone( optimize, ) - if bone.ootBoneType == "Custom DL": + if bone.ootBone.boneType == "Custom DL": if mesh is not None: raise PluginError( bone.name @@ -481,7 +583,7 @@ def ootProcessBone( ) else: # Dummy data, only used so that name is set correctly - mesh = FMesh(bone.ootCustomDLName, DLFormat.Static) + mesh = FMesh(bone.ootBone.customDLName, DLFormat.Static) DL = None if mesh is not None: @@ -492,6 +594,12 @@ def ootProcessBone( ) DL = mesh.draw + # Some skeletons will override the current drawn DL for a limb. + # If an override DL is not NULL but the non-override is NULL, then this causes issues. + # Thus for cases where we remove geometry, we need to have a dummy DL. + elif bone.use_deform: + DL = OOTDLReference("gEmptyDL") + if isinstance(parentLimb, OOTSkeleton): skeleton = parentLimb limb = OOTLimb(skeleton.name, boneName, nextIndex, translate, DL, None) @@ -539,11 +647,25 @@ def ootConvertArmatureToC( drawLayer: str, settings: OOTSkeletonExportSettings, ): - folderName = settings.folder + if settings.mode != "Generic" and not settings.isCustom: + importInfo = ootSkeletonImportDict[settings.mode] + skeletonName = importInfo.skeletonName + folderName = importInfo.folderName + overlayName = importInfo.actorOverlayName + flipbookUses2DArray = importInfo.flipbookArrayIndex2D is not None + flipbookArrayIndex2D = importInfo.flipbookArrayIndex2D + isLink = importInfo.isLink + else: + skeletonName = toAlnum(settings.name) + folderName = settings.folder + overlayName = settings.actorOverlayName if not settings.isCustom else None + flipbookUses2DArray = settings.flipbookUses2DArray + flipbookArrayIndex2D = settings.flipbookArrayIndex2D if flipbookUses2DArray else None + isLink = False + exportPath = bpy.path.abspath(settings.customPath) isCustomExport = settings.isCustom removeVanillaData = settings.removeVanillaData - skeletonName = toAlnum(settings.name) optimize = settings.optimize fModel = OOTModel(f3dType, isHWv1, skeletonName, DLFormat, drawLayer) @@ -551,9 +673,9 @@ def ootConvertArmatureToC( originalArmatureObj, convertTransformMatrix, fModel, skeletonName, not savePNG, drawLayer, optimize ) - if originalArmatureObj.ootFarLOD is not None: + if originalArmatureObj.ootSkeleton.LOD is not None: lodSkeleton, fModel = ootConvertArmatureToSkeletonWithMesh( - originalArmatureObj.ootFarLOD, + originalArmatureObj.ootSkeleton.LOD, convertTransformMatrix, fModel, skeletonName + "_lod", @@ -573,7 +695,7 @@ def ootConvertArmatureToC( raise PluginError( originalArmatureObj.name + " cannot use " - + originalArmatureObj.ootFarLOD.name + + originalArmatureObj.ootSkeleton.LOD.name + "as LOD because they do not have the same bone structure." ) @@ -598,9 +720,14 @@ def ootConvertArmatureToC( data.append(exportData.all()) data.append(skeletonC) + if isCustomExport: + textureArrayData = writeTextureArraysNew(fModel, flipbookArrayIndex2D) + data.append(textureArrayData) + writeCData(data, os.path.join(path, skeletonName + ".h"), os.path.join(path, skeletonName + ".c")) if not isCustomExport: + writeTextureArraysExisting(bpy.context.scene.ootDecompPath, overlayName, isLink, flipbookArrayIndex2D, fModel) addIncludeFiles(folderName, path, skeletonName) if removeVanillaData: ootRemoveSkeleton(path, folderName, skeletonName) @@ -675,36 +802,107 @@ def ootGetLimb(skeletonData, limbName, continueOnError): return matchResult -def ootImportSkeletonC( - filepaths: list[str], actorScale: float, basePath: str, importSettings: OOTSkeletonImportSettings -): - skeletonName = importSettings.name +def ootImportSkeletonC(basePath: str, importSettings: OOTSkeletonImportSettings): + importPath = bpy.path.abspath(importSettings.customPath) + isCustomImport = importSettings.isCustom + + if importSettings.mode != "Generic" and not importSettings.isCustom: + importInfo = ootSkeletonImportDict[importSettings.mode] + skeletonName = importInfo.skeletonName + folderName = importInfo.folderName + overlayName = importInfo.actorOverlayName + flipbookUses2DArray = importInfo.flipbookArrayIndex2D is not None + flipbookArrayIndex2D = importInfo.flipbookArrayIndex2D + isLink = importInfo.isLink + restPoseData = importInfo.restPoseData + else: + skeletonName = importSettings.name + folderName = importSettings.folder + overlayName = importSettings.actorOverlayName if not importSettings.isCustom else None + flipbookUses2DArray = importSettings.flipbookUses2DArray + flipbookArrayIndex2D = importSettings.flipbookArrayIndex2D if flipbookUses2DArray else None + isLink = False + restPoseData = None + + filepaths = [ootGetObjectPath(isCustomImport, importPath, folderName)] + removeDoubles = importSettings.removeDoubles importNormals = importSettings.importNormals drawLayer = importSettings.drawLayer skeletonData = getImportData(filepaths) + if overlayName is not None or isLink: + skeletonData = ootGetIncludedAssetData(basePath, filepaths, skeletonData) + skeletonData matchResult = ootGetSkeleton(skeletonData, skeletonName, False) limbsName = matchResult.group(2) matchResult = ootGetLimbs(skeletonData, limbsName, False) limbsData = matchResult.group(2) - limbList = [entry.strip()[1:] for entry in limbsData.split(",")] + limbList = [entry.strip()[1:] for entry in limbsData.split(",") if entry.strip() != ""] + + f3dContext = OOTF3DContext(F3D("F3DEX2/LX2", False), limbList, basePath) + f3dContext.mat().draw_layer.oot = drawLayer + + if overlayName is not None and importSettings.autoDetectActorScale: + actorScale = ootReadActorScale(basePath, overlayName, isLink) + else: + actorScale = getOOTScale(importSettings.actorScale) # print(limbList) isLOD, armatureObj = ootBuildSkeleton( - skeletonName, skeletonData, limbList, actorScale, removeDoubles, importNormals, False, basePath, drawLayer + skeletonName, + overlayName, + skeletonData, + actorScale, + removeDoubles, + importNormals, + False, + basePath, + drawLayer, + isLink, + flipbookArrayIndex2D, + f3dContext, ) if isLOD: isLOD, LODArmatureObj = ootBuildSkeleton( - skeletonName, skeletonData, limbList, actorScale, removeDoubles, importNormals, True, basePath, drawLayer + skeletonName, + overlayName, + skeletonData, + actorScale, + removeDoubles, + importNormals, + True, + basePath, + drawLayer, + isLink, + flipbookArrayIndex2D, + f3dContext, ) - armatureObj.ootFarLOD = LODArmatureObj + armatureObj.ootSkeleton.LOD = LODArmatureObj + LODArmatureObj.location += mathutils.Vector((10, 0, 0)) + + f3dContext.deleteMaterialContext() + + if importSettings.applyRestPose and restPoseData is not None: + applySkeletonRestPose(restPoseData, armatureObj) + if isLOD: + applySkeletonRestPose(restPoseData, LODArmatureObj) def ootBuildSkeleton( - skeletonName, skeletonData, limbList, actorScale, removeDoubles, importNormals, useFarLOD, basePath, drawLayer + skeletonName, + overlayName, + skeletonData, + actorScale, + removeDoubles, + importNormals, + useFarLOD, + basePath, + drawLayer, + isLink, + flipbookArrayIndex2D: int, + f3dContext: OOTF3DContext, ): lodString = "_lod" if useFarLOD else "" @@ -724,8 +922,11 @@ def ootBuildSkeleton( bpy.context.view_layer.objects.active = armatureObj # bpy.ops.object.mode_set(mode = 'EDIT') - f3dContext = OOTF3DContext(F3D("F3DEX2/LX2", False), limbList, basePath) f3dContext.mat().draw_layer.oot = armatureObj.ootDrawLayer + + if overlayName is not None: + ootReadTextureArrays(basePath, overlayName, skeletonName, f3dContext, isLink, flipbookArrayIndex2D) + transformMatrix = mathutils.Matrix.Scale(1 / actorScale, 4) isLOD = ootAddLimbRecursively(0, skeletonData, obj, armatureObj, transformMatrix, None, f3dContext, useFarLOD) for dlEntry in f3dContext.dlList: @@ -734,18 +935,17 @@ def ootBuildSkeleton( parseF3D( skeletonData, dlEntry.dlName, - obj, f3dContext.matrixData[limbName], limbName, boneName, "oot", drawLayer, f3dContext, + True, ) if f3dContext.isBillboard: - armatureObj.data.bones[boneName].ootDynamicTransform.billboard = True - f3dContext.clearMaterial() # THIS IS IMPORTANT - f3dContext.createMesh(obj, removeDoubles, importNormals) + armatureObj.data.bones[boneName].ootBone.dynamicTransform.billboard = True + f3dContext.createMesh(obj, removeDoubles, importNormals, False) armatureObj.location = bpy.context.scene.cursor.location # Set bone rotation mode. @@ -766,6 +966,7 @@ def ootBuildSkeleton( bpy.ops.object.parent_set(type="ARMATURE") applyRotation([armatureObj], math.radians(-90), "X") + armatureObj.ootActorScale = actorScale / bpy.context.scene.ootBlenderScale return isLOD, armatureObj @@ -778,6 +979,7 @@ def ootAddBone(armatureObj, boneName, parentBoneName, currentTransform, loadDL): bpy.ops.object.mode_set(mode="EDIT") bone = armatureObj.data.edit_bones.new(boneName) bone.use_connect = False + bone.use_deform = loadDL if parentBoneName is not None: bone.parent = armatureObj.data.edit_bones[parentBoneName] bone.head = currentTransform @ mathutils.Vector((0, 0, 0)) @@ -802,9 +1004,15 @@ def ootAddBone(armatureObj, boneName, parentBoneName, currentTransform, loadDL): def ootAddLimbRecursively( - limbIndex, skeletonData, obj, armatureObj, parentTransform, parentBoneName, f3dContext, useFarLOD + limbIndex: int, + skeletonData: str, + obj: bpy.types.Object, + armatureObj: bpy.types.Object, + parentTransform: mathutils.Matrix, + parentBoneName: str, + f3dContext: OOTF3DContext, + useFarLOD: bool, ): - limbName = f3dContext.getLimbName(limbIndex) boneName = f3dContext.getBoneName(limbIndex) matchResult = ootGetLimb(skeletonData, limbName, False) @@ -845,7 +1053,6 @@ def ootAddLimbRecursively( # Therefore were delay F3D parsing until after skeleton is processed. if loadDL: f3dContext.dlList.append(OOTDLEntry(dlName, limbIndex)) - # parseF3D(skeletonData, dlName, obj, transformMatrix, boneName, f3dContext) if nextChildIndex != LIMB_DONE: isLOD |= ootAddLimbRecursively( @@ -885,7 +1092,7 @@ def ootRemoveSkeleton(filepath, objectName, skeletonName): return skeletonDataC = skeletonDataC[: matchResult.start(0)] + skeletonDataC[matchResult.end(0) :] limbsData = matchResult.group(2) - limbList = [entry.strip()[1:] for entry in limbsData.split(",")] + limbList = [entry.strip()[1:] for entry in limbsData.split(",") if entry.strip() != ""] headerMatch = getDeclaration(skeletonDataH, limbsName) if headerMatch is not None: @@ -915,26 +1122,14 @@ class OOT_ImportSkeleton(bpy.types.Operator): # Called on demand (i.e. button press, menu item) # Can also be called from operator search menu (Spacebar) def execute(self, context): - armatureObj = None if context.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") try: importSettings: OOTSkeletonImportSettings = context.scene.fast64.oot.skeletonImportSettings - - importPath = bpy.path.abspath(importSettings.customPath) - isCustomImport = importSettings.isCustom - folderName = importSettings.folder - scale = context.scene.ootActorBlenderScale decompPath = bpy.path.abspath(bpy.context.scene.ootDecompPath) - filepaths = [ootGetObjectPath(isCustomImport, importPath, folderName)] - if not isCustomImport: - filepaths.append( - os.path.join(bpy.context.scene.ootDecompPath, "assets/objects/gameplay_keep/gameplay_keep.c") - ) - - ootImportSkeletonC(filepaths, scale, decompPath, importSettings) + ootImportSkeletonC(decompPath, importSettings) self.report({"INFO"}, "Success!") return {"FINISHED"} @@ -968,7 +1163,14 @@ class OOT_ExportSkeleton(bpy.types.Operator): raise PluginError("Armature does not have any mesh children, or " + "has a non-mesh child.") obj = armatureObj.children[0] - finalTransform = mathutils.Matrix.Scale(context.scene.ootActorBlenderScale, 4) + finalTransform = mathutils.Matrix.Scale(getOOTScale(armatureObj.ootActorScale), 4) + + # Rotation must be applied before exporting skeleton. + # For some reason this does not work if done on the duplicate generated later, so we have to do it before then. + bpy.ops.object.select_all(action="DESELECT") + armatureObj.select_set(True) + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True, properties=False) + bpy.ops.object.select_all(action="DESELECT") try: exportSettings: OOTSkeletonExportSettings = context.scene.fast64.oot.skeletonExportSettings @@ -1002,33 +1204,67 @@ class OOT_ExportSkeletonPanel(OOT_Panel): col.operator(OOT_ExportSkeleton.bl_idname) exportSettings: OOTSkeletonExportSettings = context.scene.fast64.oot.skeletonExportSettings - prop_split(col, exportSettings, "name", "Skeleton") - prop_split(col, exportSettings, "folder", "Object" if not exportSettings.isCustom else "Folder") - if exportSettings.isCustom: - prop_split(col, exportSettings, "customAssetIncludeDir", "Asset Include Path") - prop_split(col, exportSettings, "customPath", "Path") - - col.prop(exportSettings, "isCustom") col.prop(exportSettings, "removeVanillaData") col.prop(exportSettings, "optimize") if exportSettings.optimize: b = col.box().column() b.label(icon="LIBRARY_DATA_BROKEN", text="Do not draw anything in SkelAnime") b.label(text="callbacks or cull limbs, will be corrupted.") + col.prop(exportSettings, "isCustom") + if exportSettings.isCustom: + prop_split(col, exportSettings, "name", "Skeleton") + prop_split(col, exportSettings, "folder", "Object" if not exportSettings.isCustom else "Folder") + prop_split(col, exportSettings, "customAssetIncludeDir", "Asset Include Path") + prop_split(col, exportSettings, "customPath", "Path") + else: + prop_split(col, exportSettings, "mode", "Mode") + if exportSettings.mode == "Generic": + prop_split(col, exportSettings, "name", "Skeleton") + prop_split(col, exportSettings, "folder", "Object" if not exportSettings.isCustom else "Folder") + prop_split(col, exportSettings, "actorOverlayName", "Overlay") + col.prop(exportSettings, "flipbookUses2DArray") + if exportSettings.flipbookUses2DArray: + box = col.box().column() + prop_split(box, exportSettings, "flipbookArrayIndex2D", "Flipbook Index") + elif exportSettings.mode == "Adult Link" or exportSettings.mode == "Child Link": + col.label(text="Requires enabling NON_MATCHING in Makefile.", icon="ERROR") + col.label(text="Preserve all bone deform toggles if modifying an imported skeleton.", icon="ERROR") col.operator(OOT_ImportSkeleton.bl_idname) importSettings: OOTSkeletonImportSettings = context.scene.fast64.oot.skeletonImportSettings - prop_split(col, importSettings, "name", "Skeleton") - if importSettings.isCustom: - prop_split(col, importSettings, "customPath", "File") - else: - prop_split(col, importSettings, "folder", "Object") prop_split(col, importSettings, "drawLayer", "Import Draw Layer") - - col.prop(importSettings, "isCustom") col.prop(importSettings, "removeDoubles") col.prop(importSettings, "importNormals") + col.prop(importSettings, "isCustom") + if importSettings.isCustom: + prop_split(col, importSettings, "name", "Skeleton") + prop_split(col, importSettings, "customPath", "File") + else: + prop_split(col, importSettings, "mode", "Mode") + if importSettings.mode == "Generic": + prop_split(col, importSettings, "name", "Skeleton") + prop_split(col, importSettings, "folder", "Object") + prop_split(col, importSettings, "actorOverlayName", "Overlay") + col.prop(importSettings, "autoDetectActorScale") + if not importSettings.autoDetectActorScale: + prop_split(col, importSettings, "actorScale", "Actor Scale") + col.prop(importSettings, "flipbookUses2DArray") + if importSettings.flipbookUses2DArray: + box = col.box().column() + prop_split(box, importSettings, "flipbookArrayIndex2D", "Flipbook Index") + if importSettings.actorOverlayName == "ovl_En_Wf": + col.box().column().label( + text="This actor has branching gSPSegment calls and will not import correctly unless one of the branches is deleted.", + icon="ERROR", + ) + elif importSettings.actorOverlayName == "ovl_Obj_Switch": + col.box().column().label( + text="This actor has a 2D texture array and will not import correctly unless the array is flattened.", + icon="ERROR", + ) + else: + col.prop(importSettings, "applyRestPose") class OOT_SkeletonPanel(bpy.types.Panel): @@ -1053,9 +1289,10 @@ class OOT_SkeletonPanel(bpy.types.Panel): col = self.layout.box().column() col.box().label(text="OOT Skeleton Inspector") prop_split(col, context.object, "ootDrawLayer", "Draw Layer") - prop_split(col, context.object, "ootFarLOD", "LOD Skeleton") - if context.object.ootFarLOD is not None: + prop_split(col, context.object.ootSkeleton, "LOD", "LOD Skeleton") + if context.object.ootSkeleton.LOD is not None: col.label(text="Make sure LOD has same bone structure.", icon="BONE_DATA") + prop_split(col, context.object, "ootActorScale", "Actor Scale") class OOT_BonePanel(bpy.types.Panel): @@ -1074,18 +1311,14 @@ class OOT_BonePanel(bpy.types.Panel): def draw(self, context): col = self.layout.box().column() col.box().label(text="OOT Bone Inspector") - prop_split(col, context.bone, "ootBoneType", "Bone Type") - if context.bone.ootBoneType == "Custom DL": - prop_split(col, context.bone, "ootCustomDLName", "DL Name") - if context.bone.ootBoneType == "Custom DL" or context.bone.ootBoneType == "Ignore": + prop_split(col, context.bone.ootBone, "boneType", "Bone Type") + if context.bone.ootBone.boneType == "Custom DL": + prop_split(col, context.bone.ootBone, "customDLName", "DL Name") + if context.bone.ootBone.boneType == "Custom DL" or context.bone.ootBone.boneType == "Ignore": col.label(text="Make sure no geometry is skinned to this bone.", icon="BONE_DATA") - if context.bone.ootBoneType != "Ignore": - col.prop(context.bone.ootDynamicTransform, "billboard") - - -def pollArmature(self, obj): - return isinstance(obj.data, bpy.types.Armature) + if context.bone.ootBone.boneType != "Ignore": + col.prop(context.bone.ootBone.dynamicTransform, "billboard") oot_skeleton_classes = ( @@ -1093,6 +1326,9 @@ oot_skeleton_classes = ( OOT_ImportSkeleton, OOTSkeletonExportSettings, OOTSkeletonImportSettings, + OOT_SaveRestPose, + OOTBoneProperty, + OOTSkeletonProperty, ) oot_skeleton_panels = ( @@ -1116,20 +1352,16 @@ def oot_skeleton_register(): for cls in oot_skeleton_classes: register_class(cls) - bpy.types.Object.ootFarLOD = bpy.props.PointerProperty(type=bpy.types.Object, poll=pollArmature) - - bpy.types.Bone.ootBoneType = bpy.props.EnumProperty(name="Bone Type", items=ootEnumBoneType) - bpy.types.Bone.ootDynamicTransform = bpy.props.PointerProperty(type=OOTDynamicTransformProperty) - bpy.types.Bone.ootCustomDLName = bpy.props.StringProperty(name="Custom DL", default="gEmptyDL") + bpy.types.Object.ootActorScale = bpy.props.FloatProperty(min=0, default=100) + bpy.types.Object.ootSkeleton = bpy.props.PointerProperty(type=OOTSkeletonProperty) + bpy.types.Bone.ootBone = bpy.props.PointerProperty(type=OOTBoneProperty) def oot_skeleton_unregister(): - del bpy.types.Object.ootFarLOD - - del bpy.types.Bone.ootBoneType - del bpy.types.Bone.ootDynamicTransform - del bpy.types.Bone.ootCustomDLName + del bpy.types.Object.ootActorScale + del bpy.types.Bone.ootBone + del bpy.types.Object.ootSkeleton for cls in reversed(oot_skeleton_classes): unregister_class(cls) diff --git a/fast64_internal/oot/oot_skeleton_import_data.py b/fast64_internal/oot/oot_skeleton_import_data.py new file mode 100644 index 0000000..07b6470 --- /dev/null +++ b/fast64_internal/oot/oot_skeleton_import_data.py @@ -0,0 +1,176 @@ +from collections import OrderedDict +import bpy, mathutils +from ..utility import PluginError, raisePluginError +from .oot_utility import getSortedChildren, getStartBone, getNextBone + +# Adding new rest pose entry: +# 1. Import a generic skeleton +# 2. Pose into a usable rest pose +# 3. Select skeleton, then run bpy.ops.object.oot_save_rest_pose() +# 4. Copy array data from console into an OOTSkeletonImportInfo object +# - list of tuples, first is root position, rest are euler XYZ rotations +# 5. Add object to ootSkeletonImportDict + + +def applySkeletonRestPose(boneData: list[tuple[float, float, float]], armatureObj: bpy.types.Object): + if bpy.context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.select_all(action="DESELECT") + armatureObj.select_set(True) + + bpy.ops.object.mode_set(mode="POSE") + + startBoneName = getStartBone(armatureObj) + boneStack = [startBoneName] + + index = 0 + while len(boneStack) > 0: + bone, boneStack = getNextBone(boneStack, armatureObj) + poseBone = armatureObj.pose.bones[bone.name] + if index == 0: + poseBone.location = mathutils.Vector(boneData[index]) + + poseBone.rotation_mode = "XYZ" + poseBone.rotation_euler = mathutils.Euler(boneData[index + 1]) + index += 1 + + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.armature_apply_w_mesh() + + +# Copy data from console into python file +class OOT_SaveRestPose(bpy.types.Operator): + # set bl_ properties + bl_idname = "object.oot_save_rest_pose" + bl_label = "Save Rest Pose" + bl_options = {"REGISTER", "UNDO"} + + # path: bpy.props.StringProperty(name="Path", subtype="FILE_PATH") + def execute(self, context): + if context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + + if len(context.selected_objects) == 0: + raise PluginError("Armature not selected.") + armatureObj = context.active_object + if type(armatureObj.data) is not bpy.types.Armature: + raise PluginError("Armature not selected.") + + try: + data = "restPoseData = [\n" + startBoneName = getStartBone(armatureObj) + boneStack = [startBoneName] + + firstBone = True + while len(boneStack) > 0: + bone, boneStack = getNextBone(boneStack, armatureObj) + poseBone = armatureObj.pose.bones[bone.name] + if firstBone: + data += str(poseBone.matrix_basis.decompose()[0][:]) + ", " + firstBone = False + data += str((poseBone.matrix_basis.decompose()[1]).to_euler()[:]) + ", " + + data += "\n]" + + print(data) + + self.report({"INFO"}, "Success!") + return {"FINISHED"} + + except Exception as e: + if context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + raisePluginError(self, e) + return {"CANCELLED"} # must return a set + + +# Link overlay will be "", since link texture array data is handled as a special case. +class OOTSkeletonImportInfo: + def __init__( + self, + skeletonName: str, + folderName: str, + actorOverlayName: str, + flipbookArrayIndex2D: int | None, + restPoseData: list[tuple[float, float, float]] | None, + ): + self.skeletonName = skeletonName + self.folderName = folderName + self.actorOverlayName = actorOverlayName # Note that overlayName = None will disable texture array reading. + self.flipbookArrayIndex2D = flipbookArrayIndex2D + self.isLink = skeletonName == "gLinkAdultSkel" or skeletonName == "gLinkChildSkel" + self.restPoseData = restPoseData + + +ootSkeletonImportDict = OrderedDict( + { + "Adult Link": OOTSkeletonImportInfo( + "gLinkAdultSkel", + "object_link_boy", + "", + 0, + [ + (0.0, 3.6050000190734863, 0.0), + (0.0, -0.0, 0.0), + (-1.5708922147750854, -0.0, -1.5707963705062866), + (0.0, -0.0, 0.0), + (0.0, 0.05235987901687622, 0.0), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (0.0, -0.05235987901687622, 0.0), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (1.5707963705062866, -0.0, 1.5707963705062866), + (-4.740638548383913e-09, -5.356494803265832e-09, 1.4546878337860107), + (-4.114889869409654e-15, -1.1733899984468776e-14, 1.9080803394317627), + (0.0, -0.0, 0.0), + (1.0222795112391236e-15, -0.6981316804885864, -3.141592502593994), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (-1.0222795112391236e-15, 0.6981316804885864, -3.141592502593994), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (-1.5707963705062866, 2.611602306365967, -0.08726644515991211), + (0.0, -0.0, 0.0), + ], + ), + "Child Link": OOTSkeletonImportInfo( + "gLinkChildSkel", + "object_link_child", + "", + 1, + [ + (0.0, 2.3559017181396484, 0.0), + (0.0, -0.0, 0.0), + (-1.5708922147750854, -0.0, -1.5707963705062866), + (0.0, -0.0, 0.0), + (0.0, 0.05235987901687622, 0.0), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (0.0, -0.05235987901687622, 0.0), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (1.5707963705062866, -0.0, 1.5707963705062866), + (-4.740638548383913e-09, -5.356494803265832e-09, 1.4546878337860107), + (-4.114889869409654e-15, -1.1733899984468776e-14, 1.9080803394317627), + (0.0, -0.0, 0.0), + (1.0222795112391236e-15, -0.6981316804885864, -3.141592502593994), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (-1.0222795112391236e-15, 0.6981316804885864, -3.141592502593994), + (0.0, -0.0, 0.0), + (0.0, 0.0, -1.5707964897155762), + (-1.5707963705062866, 2.611602306365967, -0.08726644515991211), + (0.0, -0.0, 0.0), + ], + ), + # "Gerudo": OOTSkeletonImportInfo("gGerudoRedSkel", "object_geldb", "ovl_En_GeldB", None, None), + } +) + +ootEnumSkeletonImportMode = [ + ("Generic", "Generic", "Generic"), +] + +for name, info in ootSkeletonImportDict.items(): + ootEnumSkeletonImportMode.append((name, name, name)) diff --git a/fast64_internal/oot/oot_texture_array.py b/fast64_internal/oot/oot_texture_array.py new file mode 100644 index 0000000..c541d1e --- /dev/null +++ b/fast64_internal/oot/oot_texture_array.py @@ -0,0 +1,240 @@ +from typing import Callable +import os, re +from ..utility import hexOrDecInt +from .oot_model_classes import ( + OOTF3DContext, + TextureFlipbook, + ootGetActorData, + ootGetActorDataPaths, + ootGetIncludedAssetData, + ootGetLinkData, +) + +# Special cases: +# z_en_xc: one texture is not stored in any array. +# skeletonName only used for en_ossan (shopkeepers) and demo_ec (end credits party), which have multiple skeletons +def ootReadTextureArrays( + basePath: str, + overlayName: str, + skeletonName: str, + f3dContext: OOTF3DContext, + isLink: bool, + flipbookArrayIndex2D: int, +): + if not isLink: + actorData = ootGetActorData(basePath, overlayName) + currentPaths = ootGetActorDataPaths(basePath, overlayName) + else: + actorData = ootGetLinkData(basePath) + currentPaths = [os.path.join(basePath, f"src/code/z_player_lib.c")] + actorData = ootGetIncludedAssetData(basePath, currentPaths, actorData) + actorData + + # search for texture arrays + # this is done first so that its easier to tell which gSPSegment calls refer to texture data. + flipbookList = getTextureArrays(actorData, flipbookArrayIndex2D) + + if not isLink and overlayName == "ovl_En_Ossan": + # remove function declarations + actorData = re.sub(r"void\s*EnOssan\_(((?!\{).)*)?\)\s*;", "", actorData) + ootReadTextureArraysFromMultiple( + flipbookList, skeletonName, actorData, f3dContext, "EnOssan", getSPSegmentCalls, flipbookArrayIndex2D + ) + elif not isLink and overlayName == "ovl_Demo_Ec": + ootReadTextureArraysFromMultiple( + flipbookList, skeletonName, actorData, f3dContext, "DemoEc", getSPSegmentCallsDemoEc, flipbookArrayIndex2D + ) + else: + ootReadTextureArraysGeneric(flipbookList, actorData, getSPSegmentCalls, f3dContext) + + +# we return when no matches found to handle cases where actor does not have dynamic textures. +def ootReadTextureArraysFromMultiple( + flipbookList: dict[str, TextureFlipbook], + skeletonName: str, + actorData: str, + f3dContext: OOTF3DContext, + functionPrefix: str, + getSegmentCallsFunc: Callable[[str], None], + flipbookArrayIndex2D: int, +): + # regex should ignore DemoEc_Init() + # relies on formatting convention (tabs indicating bracket scope) + initMatch = re.search( + r"void\s*" + + re.escape(functionPrefix) + + r"\_Init(?!SkelAnime)(((?![\s\(]).)*?)\((((?!\n\}).)*?)" + + re.escape(skeletonName), + actorData, + flags=re.DOTALL, + ) + if not initMatch: + return + + # relies on formatting convention (tabs indicating scope of bracket) + name = initMatch.group(1) + drawMatch = re.search( + r"void\s*" + re.escape(functionPrefix) + r"\_Draw" + re.escape(name) + r"\s*\((.*?)\n\}", + actorData, + flags=re.DOTALL, + ) + if not drawMatch: + return + + drawData = drawMatch.group(1) + flipbookList = getTextureArrays(drawData, flipbookArrayIndex2D) + ootReadTextureArraysGeneric(flipbookList, drawData, getSegmentCallsFunc, f3dContext) + + +def ootReadTextureArraysGeneric( + flipbookList: dict[str, TextureFlipbook], + actorData: str, + getSegmentCallsFunc: Callable[[str], None], + f3dContext: OOTF3DContext, +): + # find gSPSegment() calls that reference texture arrays + for (flipbookKey, segmentParam, spSegmentMatch) in getSegmentCallsFunc(actorData): + + # check for texture array reference + for (arrayName, flipbook) in flipbookList.items(): + directArrayReference = findDirectArrayReference(arrayName, segmentParam) + indexIntoArrayReference = findIndexIntoArrayReference(arrayName, segmentParam, actorData) + + if directArrayReference or indexIntoArrayReference: + f3dContext.flipbooks[flipbookKey] = flipbook + + # check if single non-array texture reference (ex. z_en_ta, which uses a different texture in z_demo_ec (red nose)) + # This is will not get correct texture name, but otherwise works fine. + if flipbookKey not in f3dContext.flipbooks and findSingleTextureReference(segmentParam): + f3dContext.flipbooks[flipbookKey] = TextureFlipbook("", "Individual", [segmentParam]) + + +# check if array is directly referenced in gSPSegment +# SEGMENTED_TO_VIRTUAL(arrayName[...]) +def findDirectArrayReference(arrayName: str, segmentParam: str) -> re.Match: + return re.search(re.escape(arrayName) + r"\s*\[", segmentParam, flags=re.DOTALL) + + +# check if an array element is referenced in gSPSegment +# void* segmentParam = arrayName[...]; +# SEGMENTED_TO_VIRTUAL(segmentParam) +def findIndexIntoArrayReference(arrayName: str, segmentParam: str, actorData: str) -> re.Match: + return re.search(r"[a-zA-Z0-9\_]*", segmentParam, flags=re.DOTALL) and re.search( + r"void\s*\*\s*" + re.escape(segmentParam) + r"\s*=\s*" + re.escape(arrayName) + r"\s*\[", + actorData, + flags=re.DOTALL, + ) + + +# check for single non-array reference +# convention: camel case starting with 'g' +# gSomeTexture +def findSingleTextureReference(segmentParam: str) -> re.Match: + return ( + re.search(r"[a-zA-Z0-9\_]*", segmentParam, flags=re.DOTALL) + and segmentParam[0] == "g" + and segmentParam[1].isupper() + ) + + +# check for texture arrays in data. +# void* ???[] = {a, b, c,} +def getTextureArrays(actorData: str, flipbookArrayIndex2D: int) -> dict[str, TextureFlipbook]: + flipbookList = {} # {array name : TextureFlipbook} + + if flipbookArrayIndex2D is not None: + for texArray2DMatch in re.finditer( + r"void\s*\*\s*([0-9a-zA-Z\_]*)\s*\[\s*\]\s*\[[0-9a-fA-Fx]*\]\s*=\s*\{(.*?)\}\s*;", + actorData, + flags=re.DOTALL, + ): + arrayMatchData = [ + arrayMatch.group(1) + for arrayMatch in re.finditer(r"\{(((?!\}).)*)\}", texArray2DMatch.group(2), flags=re.DOTALL) + ] + + if flipbookArrayIndex2D >= len(arrayMatchData): + continue + + arrayName = texArray2DMatch.group(1).strip() + textureList = stripComments([item for item in arrayMatchData[flipbookArrayIndex2D].split(",")]) + + # handle trailing comma + if textureList[-1] == "": + textureList.pop() + flipbookList[arrayName] = TextureFlipbook(arrayName, "Array", textureList) + else: + for texArrayMatch in re.finditer( + r"void\s*\*\s*([0-9a-zA-Z\_]*)\s*\[\s*\]\s*=\s*\{(((?!\}).)*)\}", actorData, flags=re.DOTALL + ): + arrayName = texArrayMatch.group(1).strip() + textureList = stripComments([item for item in texArrayMatch.group(2).split(",")]) + + # handle trailing comma + if textureList[-1] == "": + textureList.pop() + flipbookList[arrayName] = TextureFlipbook(arrayName, "Array", textureList) + + return flipbookList + + +def stripComments(textureNameList: list[str]) -> list[str]: + for i in range(len(textureNameList)): + try: + commentIndex = textureNameList[i].index("//") + except ValueError: + textureNameList[i] = textureNameList[i].strip() + else: + textureNameList[i] = re.sub(r"//.*?\n", "", textureNameList[i]).strip() + return textureNameList + + +def getSPSegmentCalls(actorData: str) -> list[tuple[tuple[int, str], str, re.Match]]: + segmentCalls = [] + + # find gSPSegment() calls that reference texture arrays + for spSegmentMatch in re.finditer( + r"gSPSegment\s*\(\s*POLY\_(OPA)?(XLU)?\_DISP\s*\+\+\s*,\s*([0-9a-fA-Fx]*)\s*,\s*SEGMENTED\_TO\_VIRTUAL\s*\(\s*(((?!;).)*)\)\s*\)\s*;", + actorData, + flags=re.DOTALL, + ): + # see ootEnumDrawLayers + drawLayer = "Transparent" if spSegmentMatch.group(2) else "Opaque" + segment = hexOrDecInt(spSegmentMatch.group(3)) + flipbookKey = (segment, drawLayer) + segmentParam = spSegmentMatch.group(4).strip() + + segmentCalls.append((flipbookKey, segmentParam, spSegmentMatch)) + + return segmentCalls + + +# assumes DemoEc_DrawSkeleton()/DemoEc_DrawSkeletonCustomColor() is unmodified +def getSPSegmentCallsDemoEc(actorData: str) -> list[tuple[tuple[int, str], str, re.Match]]: + segmentCalls = getSPSegmentCalls(actorData) + functionMatch = re.search( + r"DemoEc_DrawSkeleton(CustomColor)?\s*\(.*?,.*?,(.*?),(.*?),", actorData, flags=re.DOTALL + ) + if functionMatch: + isCustomColor = functionMatch.group(1) is not None + param1 = functionMatch.group(2).strip() + param2 = functionMatch.group(3).strip() + + if param1 == "NULL" or param1 == "0": + param1 = None + if param2 == "NULL" or param2 == "0": + param2 = None + + if isCustomColor: + if param1: + segmentCalls.append(((0x0A, "Opaque"), param1, functionMatch)) + if param2: + segmentCalls.append(((0x0B, "Opaque"), param2, functionMatch)) + else: + if param1: + segmentCalls.append(((0x08, "Opaque"), param1, functionMatch)) + if not param2: + segmentCalls.append(((0x09, "Opaque"), param1, functionMatch)) + if param2: + segmentCalls.append(((0x09, "Opaque"), param2, functionMatch)) + + return segmentCalls diff --git a/fast64_internal/oot/oot_utility.py b/fast64_internal/oot/oot_utility.py index 6fddcf9..224d4c4 100644 --- a/fast64_internal/oot/oot_utility.py +++ b/fast64_internal/oot/oot_utility.py @@ -1,4 +1,4 @@ -import bpy, math, os +import bpy, math, os, re from bpy.utils import register_class, unregister_class from ..utility import ( PluginError, @@ -153,6 +153,14 @@ ootSceneDirs = { } +def getOOTScale(actorScale: float) -> float: + return bpy.context.scene.ootBlenderScale * actorScale + + +def replaceMatchContent(data: str, newContent: str, match: re.Match, index: int) -> str: + return data[: match.start(index)] + newContent + data[match.end(index) :] + + def addIncludeFiles(objectName, objectPath, assetName): addIncludeFilesExtension(objectName, objectPath, assetName, "h") addIncludeFilesExtension(objectName, objectPath, assetName, "c") @@ -372,14 +380,14 @@ def ootGetPath(exportPath, isCustomExport, subPath, folderName, makeIfNotExists, def getSortedChildren(armatureObj, bone): return sorted( - [child.name for child in bone.children if child.ootBoneType != "Ignore"], + [child.name for child in bone.children if child.ootBone.boneType != "Ignore"], key=lambda childName: childName.lower(), ) def getStartBone(armatureObj): startBoneNames = [ - bone.name for bone in armatureObj.data.bones if bone.parent is None and bone.ootBoneType != "Ignore" + bone.name for bone in armatureObj.data.bones if bone.parent is None and bone.ootBone.boneType != "Ignore" ] if len(startBoneNames) == 0: raise PluginError(armatureObj.name + ' does not have any root bones that are not of the "Ignore" type.') @@ -388,6 +396,15 @@ def getStartBone(armatureObj): # return 'root' +def getNextBone(boneStack: list[str], armatureObj: bpy.types.Object): + if len(boneStack) == 0: + raise PluginError("More bones in animation than on armature.") + bone = armatureObj.data.bones[boneStack[0]] + boneStack = boneStack[1:] + boneStack = getSortedChildren(armatureObj, bone) + boneStack + return bone, boneStack + + def checkForStartBone(armatureObj): pass # if "root" not in armatureObj.data.bones: diff --git a/fast64_internal/sm64/sm64_geolayout_writer.py b/fast64_internal/sm64/sm64_geolayout_writer.py index 45dc506..fe24821 100644 --- a/fast64_internal/sm64/sm64_geolayout_writer.py +++ b/fast64_internal/sm64/sm64_geolayout_writer.py @@ -2590,9 +2590,9 @@ def saveSkinnedMeshByMaterial( skinnedTriGroup.vertexList.vertices.append( convertVertexData( obj.data, - bufferVert.f3dVert[0], - bufferVert.f3dVert[1], - bufferVert.f3dVert[2], + bufferVert.f3dVert.position, + bufferVert.f3dVert.uv, + bufferVert.f3dVert.getColorOrNormal(), texDimensions, parentMatrix, isPointSampled, diff --git a/fast64_internal/utility.py b/fast64_internal/utility.py index 093b06a..1ed0082 100644 --- a/fast64_internal/utility.py +++ b/fast64_internal/utility.py @@ -2,7 +2,9 @@ import bpy, random, string, os, math, traceback, re, os, mathutils from math import pi, ceil, degrees, radians from mathutils import * from .utility_anim import * -from typing import Callable, Iterable +from typing import Callable, Iterable, Any + +CollectionProperty = Any # collection prop as defined by using bpy.props.CollectionProperty class PluginError(Exception): @@ -126,13 +128,6 @@ def parentObject(parent, child): bpy.ops.object.parent_set(type="OBJECT", keep_transform=True) -def attemptModifierApply(modifier): - try: - bpy.ops.object.modifier_apply(modifier=modifier.name) - except Exception as e: - print("Skipping modifier " + str(modifier.name)) - - def getFMeshName(vertexGroup, namePrefix, drawLayer, isSkinned): fMeshName = toAlnum(namePrefix + ("_" if namePrefix != "" else "") + vertexGroup) if isSkinned: diff --git a/fast64_internal/utility_anim.py b/fast64_internal/utility_anim.py index ed41905..cf95246 100644 --- a/fast64_internal/utility_anim.py +++ b/fast64_internal/utility_anim.py @@ -1,4 +1,5 @@ import bpy, math, mathutils +from bpy.utils import register_class, unregister_class from typing import TYPE_CHECKING @@ -7,6 +8,91 @@ if TYPE_CHECKING: from .. import Fast64Settings_Properties +class ArmatureApplyWithMeshOperator(bpy.types.Operator): + # set bl_ properties + bl_description = ( + "Applies current pose as default pose. Useful for " + + "rigging an armature that is not in T/A pose. Note that when using " + + " with an SM64 armature, you must revert to the default pose after " + + "skinning." + ) + bl_idname = "object.armature_apply_w_mesh" + bl_label = "Apply As Rest Pose" + bl_options = {"REGISTER", "UNDO", "PRESET"} + + # Called on demand (i.e. button press, menu item) + # Can also be called from operator search menu (Spacebar) + def execute(self, context): + try: + if context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + + if len(context.selected_objects) == 0: + raise PluginError("Armature not selected.") + elif type(context.selected_objects[0].data) is not bpy.types.Armature: + raise PluginError("Armature not selected.") + + armatureObj = context.selected_objects[0] + armatureApplyWithMesh(armatureObj, context) + except Exception as e: + if context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + raisePluginError(self, e) + return {"CANCELLED"} + + self.report({"INFO"}, "Applied armature with mesh.") + return {"FINISHED"} # must return a set + + +# This code only handles root bone with no parent, which is the only bone that translates. +def getTranslationRelativeToRest(bone: bpy.types.Bone, inputVector: mathutils.Vector) -> mathutils.Vector: + zUpToYUp = mathutils.Quaternion((1, 0, 0), math.radians(-90.0)).to_matrix().to_4x4() + actualTranslation = (zUpToYUp @ bone.matrix_local).inverted() @ mathutils.Matrix.Translation(inputVector).to_4x4() + return actualTranslation.decompose()[0] + + +def getRotationRelativeToRest(bone: bpy.types.Bone, inputEuler: mathutils.Euler) -> mathutils.Euler: + if bone.parent is None: + parentRotation = mathutils.Quaternion((1, 0, 0), math.radians(90.0)).to_matrix().to_4x4() + else: + parentRotation = bone.parent.matrix_local + + restRotation = (parentRotation.inverted() @ bone.matrix_local).decompose()[1].to_matrix().to_4x4() + return (restRotation.inverted() @ inputEuler.to_matrix().to_4x4()).to_euler("XYZ", inputEuler) + + +def attemptModifierApply(modifier): + try: + bpy.ops.object.modifier_apply(modifier=modifier.name) + except Exception as e: + print("Skipping modifier " + str(modifier.name)) + + +def armatureApplyWithMesh(armatureObj: bpy.types.Object, context: bpy.types.Context): + for child in armatureObj.children: + if type(child.data) is not bpy.types.Mesh: + continue + armatureModifier = None + for modifier in child.modifiers: + if isinstance(modifier, bpy.types.ArmatureModifier): + armatureModifier = modifier + if armatureModifier is None: + continue + + bpy.ops.object.select_all(action="DESELECT") + context.view_layer.objects.active = child + bpy.ops.object.modifier_copy(modifier=armatureModifier.name) + print(len(child.modifiers)) + attemptModifierApply(armatureModifier) + + bpy.ops.object.select_all(action="DESELECT") + context.view_layer.objects.active = armatureObj + bpy.ops.object.mode_set(mode="POSE") + bpy.ops.pose.armature_apply() + if context.mode != "OBJECT": + bpy.ops.object.mode_set(mode="OBJECT") + + class ValueFrameData: def __init__(self, boneIndex, field, frames): self.boneIndex = boneIndex @@ -89,3 +175,18 @@ def getFrameInterval(action: bpy.types.Action): } return range_get_by_choice[anim_range_choice]() + + +classes = (ArmatureApplyWithMeshOperator,) + + +def utility_anim_register(): + for cls in classes: + register_class(cls) + + +# called on add-on disabling +def utility_anim_unregister(): + + for cls in classes: + unregister_class(cls) diff --git a/images/oot_flipbook.png b/images/oot_flipbook.png new file mode 100644 index 0000000..477a1da Binary files /dev/null and b/images/oot_flipbook.png differ diff --git a/images/oot_imported_gerudo_textured.png b/images/oot_imported_gerudo_textured.png index 99965fd..25ab1ab 100644 Binary files a/images/oot_imported_gerudo_textured.png and b/images/oot_imported_gerudo_textured.png differ diff --git a/images/oot_link_texture_anim.png b/images/oot_link_texture_anim.png new file mode 100644 index 0000000..5fbdd2a Binary files /dev/null and b/images/oot_link_texture_anim.png differ