mirror of
https://github.com/ApfelTeeSaft/lightspeed64.git
synced 2026-08-26 19:33:24 +00:00
* [Codebase] Fix setOrigin AGAIN transform_apply was not neeeded, typehint was missing and offset was being affected by obj.location, we actually just zero it out now before applying the world matrix. also removed the temp_override cause transform already does that. * Update utility.py * Update utility.py * add doc string, use the original matrix as a base and apply the inverted original to the mesh (im stupid) add doc string instead of a comment mentioning the behavior * black * another tryy
2061 lines
67 KiB
Python
2061 lines
67 KiB
Python
from pathlib import Path
|
||
import bpy, random, string, os, math, traceback, re, os, mathutils, ast, operator, inspect
|
||
from math import pi, ceil, degrees, radians, copysign
|
||
from mathutils import *
|
||
|
||
from typing import Callable, Iterable, Any, Optional, Tuple, TypeVar, Union
|
||
from bpy.types import UILayout, Scene, World
|
||
from bpy.props import FloatVectorProperty
|
||
|
||
CollectionProperty = Any # collection prop as defined by using bpy.props.CollectionProperty
|
||
|
||
|
||
class PluginError(Exception):
|
||
# arguments for exception processing
|
||
exc_halt = "exc_halt"
|
||
exc_warn = "exc_warn"
|
||
|
||
"""
|
||
because exceptions generally go through multiple funcs
|
||
and layers, the easiest way to check if we have an exception
|
||
of a certain type is to check for our input string
|
||
"""
|
||
|
||
@classmethod
|
||
def check_exc_warn(self, exc):
|
||
for arg in exc.args:
|
||
if type(arg) is str and self.exc_warn in arg:
|
||
return True
|
||
return False
|
||
|
||
|
||
class VertexWeightError(PluginError):
|
||
pass
|
||
|
||
|
||
class Matrix4x4Property(bpy.types.PropertyGroup): # blender's matrix subtype is broken :))))
|
||
row0: FloatVectorProperty(size=4, default=(1, 0, 0, 0))
|
||
row1: FloatVectorProperty(size=4, default=(0, 1, 0, 0))
|
||
row2: FloatVectorProperty(size=4, default=(0, 0, 1, 0))
|
||
row3: FloatVectorProperty(size=4, default=(0, 0, 0, 1))
|
||
|
||
def to_matrix(self):
|
||
return mathutils.Matrix((tuple(self.row0), tuple(self.row1), tuple(self.row2), tuple(self.row3)))
|
||
|
||
def from_matrix(self, matrix: mathutils.Matrix):
|
||
for i in range(4):
|
||
setattr(self, f"row{i}", tuple(matrix[i]))
|
||
|
||
def draw_props(self, layout: UILayout):
|
||
layout.label(text="Row: → | Column: ↓", icon="INFO")
|
||
for i in range(4):
|
||
layout.row().prop(self, f"row{i}", text="")
|
||
|
||
|
||
# default indentation to use when writing to decomp files
|
||
indent = " " * 4
|
||
|
||
geoNodeRotateOrder = "ZXY"
|
||
sm64BoneUp = Vector([1, 0, 0])
|
||
|
||
transform_mtx_blender_to_n64 = lambda: Matrix(((1, 0, 0, 0), (0, 0, 1, 0), (0, -1, 0, 0), (0, 0, 0, 1)))
|
||
|
||
y_up_to_z_up = mathutils.Quaternion((1, 0, 0), math.radians(90.0))
|
||
yUpToZUp = y_up_to_z_up.to_matrix().to_4x4()
|
||
|
||
z_up_to_y_up = mathutils.Quaternion((1, 0, 0), math.radians(-90.0))
|
||
z_up_to_y_up_matrix = z_up_to_y_up.to_matrix().to_4x4()
|
||
|
||
axis_enums = [
|
||
("X", "X", "X"),
|
||
("Y", "Y", "Y"),
|
||
("-X", "-X", "-X"),
|
||
("-Y", "-Y", "-Y"),
|
||
]
|
||
|
||
enumExportHeaderType = [
|
||
# ('None', 'None', 'Headers are not written'),
|
||
("Actor", "Actor Data", "Headers are written to a group in actors/"),
|
||
("Level", "Level Data", "Headers are written to a specific level in levels/"),
|
||
]
|
||
|
||
# bpy.context.mode returns the keys here, while the values are required by bpy.ops.object.mode_set
|
||
CONTEXT_MODE_TO_MODE_SET = {
|
||
"PAINT_VERTEX": "VERTEX_PAINT",
|
||
"PAINT_WEIGHT": "WEIGHT_PAINT",
|
||
"PAINT_TEXTURE": "TEXTURE_PAINT",
|
||
"PARTICLE": "PARTICLE_EDIT",
|
||
"EDIT_GREASE_PENCIL": "EDIT_GPENCIL",
|
||
}
|
||
|
||
|
||
def get_mode_set_from_context_mode(context_mode: str):
|
||
if context_mode in CONTEXT_MODE_TO_MODE_SET:
|
||
return CONTEXT_MODE_TO_MODE_SET[context_mode]
|
||
elif context_mode.startswith("EDIT"):
|
||
return "EDIT"
|
||
else:
|
||
return context_mode
|
||
|
||
|
||
def isPowerOf2(n):
|
||
return (n & (n - 1) == 0) and n != 0
|
||
|
||
|
||
def log2iRoundDown(n):
|
||
assert n > 0
|
||
return int(math.floor(math.log2(n)))
|
||
|
||
|
||
def log2iRoundUp(n):
|
||
assert n > 0
|
||
return int(math.ceil(math.log2(n)))
|
||
|
||
|
||
def roundDownToPowerOf2(n):
|
||
return 1 << log2iRoundDown(n)
|
||
|
||
|
||
def roundUpToPowerOf2(n):
|
||
return 1 << log2iRoundUp(n)
|
||
|
||
|
||
def getDeclaration(data, name):
|
||
matchResult = re.search("extern\s*[A-Za-z0-9\_]*\s*" + re.escape(name) + "\s*(\[[^;\]]*\])?;\s*", data, re.DOTALL)
|
||
return matchResult
|
||
|
||
|
||
def hexOrDecInt(value: Union[int, str]) -> int:
|
||
if isinstance(value, int):
|
||
return value
|
||
elif "<<" in value:
|
||
i = value.index("<<")
|
||
return hexOrDecInt(value[:i]) << hexOrDecInt(value[i + 2 :])
|
||
elif ">>" in value:
|
||
i = value.index(">>")
|
||
return hexOrDecInt(value[:i]) >> hexOrDecInt(value[i + 2 :])
|
||
elif "x" in value or "X" in value:
|
||
return int(value, 16)
|
||
else:
|
||
return int(value)
|
||
|
||
|
||
def getOrMakeVertexGroup(obj, groupName):
|
||
for group in obj.vertex_groups:
|
||
if group.name == groupName:
|
||
return group
|
||
return obj.vertex_groups.new(name=groupName)
|
||
|
||
|
||
def unhideAllAndGetHiddenState(scene):
|
||
hiddenObjs = []
|
||
for obj in scene.objects:
|
||
if obj.hide_get():
|
||
hiddenObjs.append(obj)
|
||
|
||
if bpy.context.mode != "OBJECT":
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
bpy.ops.object.hide_view_clear()
|
||
|
||
hiddenLayerCols = []
|
||
|
||
layerColStack = [bpy.context.view_layer.layer_collection]
|
||
while layerColStack:
|
||
layerCol = layerColStack.pop(0)
|
||
layerColStack.extend(layerCol.children)
|
||
|
||
if layerCol.hide_viewport:
|
||
hiddenLayerCols.append(layerCol)
|
||
layerCol.hide_viewport = False
|
||
|
||
hiddenState = (hiddenObjs, hiddenLayerCols)
|
||
|
||
return hiddenState
|
||
|
||
|
||
def restoreHiddenState(hiddenState):
|
||
# as returned by unhideAllAndGetHiddenState
|
||
(hiddenObjs, hiddenLayerCols) = hiddenState
|
||
|
||
for obj in hiddenObjs:
|
||
obj.hide_set(True)
|
||
|
||
for layerCol in hiddenLayerCols:
|
||
layerCol.hide_viewport = True
|
||
|
||
|
||
def readFile(filepath):
|
||
datafile = open(filepath, "r", newline="\n", encoding="utf-8")
|
||
data = datafile.read()
|
||
datafile.close()
|
||
return data
|
||
|
||
|
||
def writeFile(filepath, data):
|
||
datafile = open(filepath, "w", newline="\n", encoding="utf-8")
|
||
datafile.write(data)
|
||
datafile.close()
|
||
|
||
|
||
def checkObjectReference(obj, title):
|
||
if obj.name not in bpy.context.view_layer.objects:
|
||
raise PluginError(
|
||
title + " not in current view layer.\n The object is either in a different view layer or is deleted."
|
||
)
|
||
|
||
|
||
def setActiveObject(obj: bpy.types.Object):
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
|
||
|
||
def deselectAllObjects():
|
||
for obj in bpy.data.objects:
|
||
obj.select_set(False)
|
||
|
||
|
||
def selectSingleObject(obj: bpy.types.Object):
|
||
deselectAllObjects()
|
||
setActiveObject(obj)
|
||
|
||
|
||
def parentObject(parent, child):
|
||
deselectAllObjects()
|
||
|
||
child.select_set(True)
|
||
setActiveObject(parent)
|
||
bpy.ops.object.parent_set(type="OBJECT", keep_transform=True)
|
||
|
||
|
||
def getFMeshName(vertexGroup, namePrefix, drawLayer, isSkinned):
|
||
fMeshName = toAlnum(namePrefix + ("_" if namePrefix != "" else "") + vertexGroup)
|
||
if isSkinned:
|
||
fMeshName += "_skinned"
|
||
fMeshName += "_mesh"
|
||
if drawLayer is not None:
|
||
fMeshName += "_layer_" + str(drawLayer)
|
||
return fMeshName
|
||
|
||
|
||
def checkUniqueBoneNames(fModel, name, vertexGroup):
|
||
if name in fModel.meshes:
|
||
raise PluginError(
|
||
vertexGroup
|
||
+ " has already been processed. Make "
|
||
+ "sure this bone name is unique, even across all switch option "
|
||
+ "armatures, and that any integer keys are not strings."
|
||
)
|
||
|
||
|
||
def getGroupIndexFromname(obj, name):
|
||
for group in obj.vertex_groups:
|
||
if group.name == name:
|
||
return group.index
|
||
return None
|
||
|
||
|
||
def getGroupNameFromIndex(obj, index):
|
||
for group in obj.vertex_groups:
|
||
if group.index == index:
|
||
return group.name
|
||
return None
|
||
|
||
|
||
def copyPropertyCollection(oldProp, newProp):
|
||
newProp.clear()
|
||
for item in oldProp:
|
||
newItem = newProp.add()
|
||
if isinstance(item, bpy.types.PropertyGroup):
|
||
copyPropertyGroup(item, newItem)
|
||
elif type(item).__name__ == "bpy_prop_collection_idprop":
|
||
copyPropertyCollection(item, newItem)
|
||
else:
|
||
newItem = item
|
||
|
||
|
||
def copyPropertyGroup(oldProp, newProp):
|
||
for sub_value_attr in oldProp.bl_rna.properties.keys():
|
||
if sub_value_attr == "rna_type":
|
||
continue
|
||
sub_value = getattr(oldProp, sub_value_attr)
|
||
if isinstance(sub_value, bpy.types.PropertyGroup):
|
||
copyPropertyGroup(sub_value, getattr(newProp, sub_value_attr))
|
||
elif type(sub_value).__name__ == "bpy_prop_collection_idprop":
|
||
newCollection = getattr(newProp, sub_value_attr)
|
||
copyPropertyCollection(sub_value, newCollection)
|
||
else:
|
||
setattr(newProp, sub_value_attr, sub_value)
|
||
|
||
|
||
def get_attr_or_property(prop: dict | object, attr: str, newProp: dict | object):
|
||
"""Safely get an attribute or old dict property"""
|
||
val = getattr(prop, attr, prop.get(attr))
|
||
|
||
# might be a dead enum that needs to be mapped back
|
||
if type(val) is int:
|
||
try:
|
||
newPropDef: bpy.types.Property = newProp.bl_rna.properties[attr]
|
||
if "Enum" in newPropDef.bl_rna.name: # Should be "Enum Definition"
|
||
# change type hint to proper type
|
||
newPropDef: bpy.types.EnumProperty = newPropDef
|
||
return newPropDef.enum_items[val].identifier
|
||
elif "Bool" in newPropDef.bl_rna.name: # Should be "Boolean Definition"
|
||
return bool(val)
|
||
except Exception as e:
|
||
pass
|
||
return val
|
||
|
||
|
||
def iter_prop(prop):
|
||
"""Return iterable keys or attributes"""
|
||
if isinstance(prop, bpy.types.PropertyGroup):
|
||
return prop.bl_rna.properties.keys()
|
||
elif type(prop).__name__ == "bpy_prop_collection_idprop":
|
||
return prop
|
||
elif type(prop).__name__ == "IDPropertyGroup":
|
||
return prop.keys()
|
||
|
||
return prop
|
||
|
||
|
||
def recursiveCopyOldPropertyGroup(oldProp, newProp):
|
||
"""Recursively go through an old property group, copying to the new one"""
|
||
for sub_value_attr in iter_prop(oldProp):
|
||
if sub_value_attr == "rna_type":
|
||
continue
|
||
sub_value = get_attr_or_property(oldProp, sub_value_attr, newProp)
|
||
new_value = get_attr_or_property(newProp, sub_value_attr, newProp)
|
||
|
||
if isinstance(new_value, bpy.types.PropertyGroup) or type(new_value).__name__ in (
|
||
"bpy_prop_collection_idprop",
|
||
"IDPropertyGroup",
|
||
):
|
||
newCollection = getattr(newProp, sub_value_attr)
|
||
recursiveCopyOldPropertyGroup(sub_value, newCollection)
|
||
else:
|
||
try:
|
||
setattr(newProp, sub_value_attr, sub_value)
|
||
except Exception as e:
|
||
print(e)
|
||
|
||
|
||
def propertyCollectionEquals(oldProp, newProp):
|
||
if len(oldProp) != len(newProp):
|
||
print("Unequal size: " + str(oldProp) + " " + str(len(oldProp)) + ", " + str(newProp) + str(len(newProp)))
|
||
return False
|
||
|
||
equivalent = True
|
||
for i in range(len(oldProp)):
|
||
item = oldProp[i]
|
||
newItem = newProp[i]
|
||
if isinstance(item, bpy.types.PropertyGroup):
|
||
equivalent &= propertyGroupEquals(item, newItem)
|
||
elif type(item).__name__ == "bpy_prop_collection_idprop":
|
||
equivalent &= propertyCollectionEquals(item, newItem)
|
||
else:
|
||
try:
|
||
iterator = iter(item)
|
||
except TypeError:
|
||
isEquivalent = newItem == item
|
||
else:
|
||
isEquivalent = tuple([i for i in newItem]) == tuple([i for i in item])
|
||
if not isEquivalent:
|
||
pass # print("Not equivalent: " + str(item) + " " + str(newItem))
|
||
equivalent &= isEquivalent
|
||
|
||
return equivalent
|
||
|
||
|
||
def propertyGroupEquals(oldProp, newProp):
|
||
equivalent = True
|
||
for sub_value_attr in oldProp.bl_rna.properties.keys():
|
||
if sub_value_attr == "rna_type":
|
||
continue
|
||
sub_value = getattr(oldProp, sub_value_attr)
|
||
if isinstance(sub_value, bpy.types.PropertyGroup):
|
||
equivalent &= propertyGroupEquals(sub_value, getattr(newProp, sub_value_attr))
|
||
elif type(sub_value).__name__ == "bpy_prop_collection_idprop":
|
||
newCollection = getattr(newProp, sub_value_attr)
|
||
equivalent &= propertyCollectionEquals(sub_value, newCollection)
|
||
else:
|
||
newValue = getattr(newProp, sub_value_attr)
|
||
try:
|
||
iterator = iter(newValue)
|
||
except TypeError:
|
||
isEquivalent = newValue == sub_value
|
||
else:
|
||
isEquivalent = tuple([i for i in newValue]) == tuple([i for i in sub_value])
|
||
|
||
if not isEquivalent:
|
||
pass # print("Not equivalent: " + str(sub_value) + " " + str(newValue) + " " + str(sub_value_attr))
|
||
equivalent &= isEquivalent
|
||
|
||
return equivalent
|
||
|
||
|
||
def writeCData(data, headerPath, sourcePath):
|
||
sourceFile = open(sourcePath, "w", newline="\n", encoding="utf-8")
|
||
sourceFile.write(data.source)
|
||
sourceFile.close()
|
||
|
||
headerFile = open(headerPath, "w", newline="\n", encoding="utf-8")
|
||
headerFile.write(data.header)
|
||
headerFile.close()
|
||
|
||
|
||
def writeCDataSourceOnly(data, sourcePath):
|
||
sourceFile = open(sourcePath, "w", newline="\n", encoding="utf-8")
|
||
sourceFile.write(data.source)
|
||
sourceFile.close()
|
||
|
||
|
||
def writeCDataHeaderOnly(data, headerPath):
|
||
headerFile = open(headerPath, "w", newline="\n", encoding="utf-8")
|
||
headerFile.write(data.header)
|
||
headerFile.close()
|
||
|
||
|
||
class CData:
|
||
def __init__(self):
|
||
self.source = ""
|
||
self.header = ""
|
||
|
||
def append(self, other):
|
||
self.source += other.source
|
||
self.header += other.header
|
||
|
||
|
||
class CScrollData(CData):
|
||
"""This class contains a list of function names, so that the top level scroll function can call all of them."""
|
||
|
||
def __init__(self):
|
||
self.functionCalls: list[str] = []
|
||
"""These function names are all called in one top level scroll function."""
|
||
|
||
self.topLevelScrollFunc: str = ""
|
||
"""This function is the final one that calls all the others."""
|
||
|
||
CData.__init__(self)
|
||
|
||
def append(self, other):
|
||
if isinstance(other, CScrollData):
|
||
self.functionCalls.extend(other.functionCalls)
|
||
CData.append(self, other)
|
||
|
||
def hasScrolling(self):
|
||
return len(self.functionCalls) > 0
|
||
|
||
|
||
def getObjectFromData(data):
|
||
for obj in bpy.data.objects:
|
||
if obj.data == data:
|
||
return obj
|
||
return None
|
||
|
||
|
||
def getTabbedText(text, tabCount):
|
||
return text.replace("\n", "\n" + "\t" * tabCount)
|
||
|
||
|
||
def extendedRAMLabel(layout):
|
||
return
|
||
infoBox = layout.box()
|
||
infoBox.label(text="Be sure to add: ")
|
||
infoBox.label(text='"#define USE_EXT_RAM"')
|
||
infoBox.label(text="to include/segments.h.")
|
||
infoBox.label(text="Extended RAM prevents crashes.")
|
||
|
||
|
||
def getPathAndLevel(is_custom_export, custom_export_path, custom_level_name, level_enum):
|
||
if is_custom_export:
|
||
export_path = bpy.path.abspath(str(custom_export_path))
|
||
level_name = custom_level_name
|
||
else:
|
||
export_path = str(bpy.context.scene.fast64.sm64.abs_decomp_path)
|
||
if level_enum == "Custom":
|
||
level_name = custom_level_name
|
||
else:
|
||
level_name = level_enum
|
||
return export_path, level_name
|
||
|
||
|
||
def findStartBones(armatureObj):
|
||
noParentBones = sorted(
|
||
[
|
||
bone.name
|
||
for bone in armatureObj.data.bones
|
||
if bone.parent is None and (bone.geo_cmd != "SwitchOption" and bone.geo_cmd != "Ignore")
|
||
]
|
||
)
|
||
|
||
if len(noParentBones) == 0:
|
||
raise PluginError(
|
||
"No non switch option start bone could be found "
|
||
+ "in "
|
||
+ armatureObj.name
|
||
+ ". Is this the root armature?"
|
||
)
|
||
else:
|
||
return noParentBones
|
||
|
||
if len(noParentBones) == 1:
|
||
return noParentBones[0]
|
||
elif len(noParentBones) == 0:
|
||
raise PluginError(
|
||
"No non switch option start bone could be found "
|
||
+ "in "
|
||
+ armatureObj.name
|
||
+ ". Is this the root armature?"
|
||
)
|
||
else:
|
||
raise PluginError(
|
||
"Too many parentless bones found. Make sure your bone hierarchy starts from a single bone, "
|
||
+ 'and that any bones not related to a hierarchy have their geolayout command set to "Ignore".'
|
||
)
|
||
|
||
|
||
def getDataFromFile(filepath):
|
||
if not os.path.exists(filepath):
|
||
raise PluginError('Path "' + filepath + '" does not exist.')
|
||
dataFile = open(filepath, "r", newline="\n")
|
||
data = dataFile.read()
|
||
dataFile.close()
|
||
return data
|
||
|
||
|
||
def saveDataToFile(filepath, data):
|
||
dataFile = open(filepath, "w", newline="\n")
|
||
dataFile.write(data)
|
||
dataFile.close()
|
||
|
||
|
||
def applyBasicTweaks(baseDir):
|
||
directory_path_checks(baseDir, "Empty directory path.")
|
||
if bpy.context.scene.fast64.sm64.force_extended_ram:
|
||
enableExtendedRAM(baseDir)
|
||
|
||
|
||
def enableExtendedRAM(baseDir):
|
||
segmentPath = os.path.join(baseDir, "include/segments.h")
|
||
|
||
segmentFile = open(segmentPath, "r", newline="\n")
|
||
segmentData = segmentFile.read()
|
||
segmentFile.close()
|
||
|
||
matchResult = re.search("#define\s*USE\_EXT\_RAM", segmentData)
|
||
|
||
if not matchResult:
|
||
matchResult = re.search("#ifndef\s*USE\_EXT\_RAM", segmentData)
|
||
if matchResult is None:
|
||
raise PluginError(
|
||
"When trying to enable extended RAM, " + "could not find '#ifndef USE_EXT_RAM' in include/segments.h."
|
||
)
|
||
segmentData = (
|
||
segmentData[: matchResult.start(0)] + "#define USE_EXT_RAM\n" + segmentData[matchResult.start(0) :]
|
||
)
|
||
|
||
segmentFile = open(segmentPath, "w", newline="\n")
|
||
segmentFile.write(segmentData)
|
||
segmentFile.close()
|
||
|
||
|
||
def writeMaterialFiles(
|
||
exportDir, assetDir, headerInclude, matHInclude, headerDynamic, dynamic_data, geoString, customExport
|
||
):
|
||
if not customExport:
|
||
writeMaterialBase(exportDir)
|
||
levelMatCPath = os.path.join(assetDir, "material.inc.c")
|
||
levelMatHPath = os.path.join(assetDir, "material.inc.h")
|
||
|
||
levelMatCFile = open(levelMatCPath, "w", newline="\n")
|
||
levelMatCFile.write(dynamic_data)
|
||
levelMatCFile.close()
|
||
|
||
headerDynamic = headerInclude + "\n\n" + headerDynamic
|
||
levelMatHFile = open(levelMatHPath, "w", newline="\n")
|
||
levelMatHFile.write(headerDynamic)
|
||
levelMatHFile.close()
|
||
|
||
return matHInclude + "\n\n" + geoString
|
||
|
||
|
||
def writeMaterialBase(baseDir):
|
||
matHPath = os.path.join(baseDir, "src/game/materials.h")
|
||
if not os.path.exists(matHPath):
|
||
matHFile = open(matHPath, "w", newline="\n")
|
||
|
||
# Write material.inc.h
|
||
matHFile.write("#ifndef MATERIALS_H\n" + "#define MATERIALS_H\n\n" + "#endif")
|
||
|
||
matHFile.close()
|
||
|
||
matCPath = os.path.join(baseDir, "src/game/materials.c")
|
||
if not os.path.exists(matCPath):
|
||
matCFile = open(matCPath, "w", newline="\n")
|
||
matCFile.write(
|
||
'#include "types.h"\n'
|
||
+ '#include "rendering_graph_node.h"\n'
|
||
+ '#include "object_fields.h"\n'
|
||
+ '#include "materials.h"'
|
||
)
|
||
|
||
# Write global texture load function here
|
||
# Write material.inc.c
|
||
# Write update_materials
|
||
|
||
matCFile.close()
|
||
|
||
|
||
def getRGBA16Tuple(color):
|
||
return (
|
||
((int(round(color[0] * 0x1F)) & 0x1F) << 11)
|
||
| ((int(round(color[1] * 0x1F)) & 0x1F) << 6)
|
||
| ((int(round(color[2] * 0x1F)) & 0x1F) << 1)
|
||
| (1 if color[3] > 0.5 else 0)
|
||
)
|
||
|
||
|
||
RGB_TO_LUM_COEF = mathutils.Vector([0.2126729, 0.7151522, 0.0721750])
|
||
|
||
|
||
def colorToLuminance(color: mathutils.Color | list[float] | Vector):
|
||
# https://github.com/blender/blender/blob/594f47ecd2d5367ca936cf6fc6ec8168c2b360d0/intern/cycles/render/shader.cpp#L387
|
||
# These coefficients are used by Blender, so we use them as well for parity between Fast64 exports and Blender color conversions
|
||
return RGB_TO_LUM_COEF.dot(color[:3])
|
||
|
||
|
||
def getIA16Tuple(color):
|
||
intensity = colorToLuminance(color[0:3])
|
||
alpha = color[3]
|
||
return (int(round(intensity * 0xFF)) << 8) | int(alpha * 0xFF)
|
||
|
||
|
||
def convertRadiansToS16(value):
|
||
value = math.degrees(value)
|
||
# ??? Why is this negative?
|
||
# TODO: Figure out why this has to be this way
|
||
value = 360 - (value % 360)
|
||
return hex(round(value / 360 * 0xFFFF))
|
||
|
||
|
||
def cast_integer(value: int, bits: int, signed: bool):
|
||
wrap = 1 << bits
|
||
value %= wrap
|
||
return value - wrap if signed and value & (1 << (bits - 1)) else value
|
||
|
||
|
||
to_s16 = lambda x: cast_integer(round(x), 16, True)
|
||
radians_to_s16 = lambda d: to_s16(d * 0x10000 / (2 * math.pi))
|
||
|
||
|
||
def int_from_s16(value: int) -> int:
|
||
value &= 0xFFFF
|
||
if value >= 0x8000:
|
||
value -= 0x10000
|
||
return value
|
||
|
||
|
||
def int_from_s16_str(value: str) -> int:
|
||
return int_from_s16(int(value, 0))
|
||
|
||
|
||
def float_from_u16_str(value: str) -> float:
|
||
return float(int(value, 0)) / (2**16)
|
||
|
||
|
||
def decompFolderMessage(layout):
|
||
layout.box().label(text="This will export to your decomp folder.")
|
||
|
||
|
||
def customExportWarning(layout):
|
||
layout.box().label(text="This will not write any headers/dependencies.")
|
||
|
||
|
||
def raisePluginError(operator, exception):
|
||
print(traceback.format_exc())
|
||
if bpy.context.scene.fullTraceback:
|
||
operator.report({"ERROR"}, traceback.format_exc())
|
||
else:
|
||
operator.report({"ERROR"}, str(exception))
|
||
|
||
|
||
def highlightWeightErrors(obj, elements, elementType):
|
||
return # Doesn't work currently
|
||
if bpy.context.mode != "OBJECT":
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
selectSingleObject(obj)
|
||
bpy.ops.object.mode_set(mode="EDIT")
|
||
deselectAllObjects()
|
||
bpy.ops.mesh.select_mode(type=elementType)
|
||
bpy.ops.object.mode_set(mode="OBJECT")
|
||
print(elements)
|
||
for element in elements:
|
||
element.select = True
|
||
|
||
|
||
def checkIdentityRotation(obj, rotation, allowYaw):
|
||
rotationDiff = rotation.to_euler()
|
||
if abs(rotationDiff.x) > 0.001 or (not allowYaw and abs(rotationDiff.y) > 0.001) or abs(rotationDiff.z) > 0.001:
|
||
raise PluginError(
|
||
'Box "'
|
||
+ obj.name
|
||
+ '" cannot have a non-zero world rotation '
|
||
+ ("(except yaw)" if allowYaw else "")
|
||
+ ", currently at ("
|
||
+ str(rotationDiff[0])
|
||
+ ", "
|
||
+ str(rotationDiff[1])
|
||
+ ", "
|
||
+ str(rotationDiff[2])
|
||
+ ")"
|
||
)
|
||
|
||
|
||
def setOrigin(obj: bpy.types.Object, target_loc: mathutils.Vector):
|
||
"""
|
||
Sets the object's origin to a new world-space location without moving the
|
||
object's mesh in the world.
|
||
|
||
HACK: Historically this applies all transforms to the mesh data, this is kept to prevent breaking things
|
||
"""
|
||
assert obj.type == "MESH", "Object is not a mesh"
|
||
mesh: bpy.types.Mesh = obj.data
|
||
|
||
original_mat = obj.matrix_world.copy()
|
||
mesh.transform(original_mat)
|
||
|
||
target_mat = original_mat.copy()
|
||
target_mat.translation = target_loc
|
||
mesh.transform(target_mat.inverted())
|
||
obj.matrix_world = target_mat
|
||
|
||
|
||
def checkIfPathExists(filePath):
|
||
if not os.path.exists(filePath):
|
||
raise PluginError(filePath + " does not exist.")
|
||
|
||
|
||
def makeWriteInfoBox(layout):
|
||
writeBox = layout.box()
|
||
writeBox.label(text="Along with header edits, this will write to:")
|
||
return writeBox
|
||
|
||
|
||
def writeBoxExportType(writeBox, headerType, name, levelName, levelOption):
|
||
if not name:
|
||
writeBox.label(text="Empty actor name", icon="ERROR")
|
||
return
|
||
if headerType == "Actor":
|
||
writeBox.label(text="actors/" + toAlnum(name))
|
||
elif headerType == "Level":
|
||
if levelOption != "Custom":
|
||
levelName = levelOption
|
||
if not name:
|
||
writeBox.label(text="Empty level name", icon="ERROR")
|
||
return
|
||
writeBox.label(text="levels/" + toAlnum(levelName) + "/" + toAlnum(name))
|
||
|
||
|
||
def getExportDir(customExport, dirPath, headerType, levelName, texDir, dirName):
|
||
# Get correct directory from decomp base, and overwrite texDir
|
||
if not customExport:
|
||
if headerType == "Actor":
|
||
dirPath = os.path.join(dirPath, "actors")
|
||
texDir = "actors/" + dirName
|
||
elif headerType == "Level":
|
||
dirPath = os.path.join(dirPath, "levels/" + levelName)
|
||
texDir = "levels/" + levelName
|
||
elif not texDir:
|
||
texDir = (Path(dirPath).name / Path(dirName)).as_posix()
|
||
|
||
return dirPath, texDir
|
||
|
||
|
||
def overwriteData(headerRegex, name, value, filePath, writeNewBeforeString, isFunction, post_regex=""):
|
||
if os.path.exists(filePath):
|
||
dataFile = open(filePath, "r")
|
||
data = dataFile.read()
|
||
dataFile.close()
|
||
|
||
matchResult = re.search(
|
||
headerRegex
|
||
+ re.escape(name)
|
||
+ ("\s*\((((?!\)).)*)\)\s*\{(((?!\}).)*)\}" if isFunction else "\[\]\s*=\s*\{(((?!;).)*);")
|
||
+ post_regex,
|
||
data,
|
||
re.DOTALL,
|
||
)
|
||
if matchResult:
|
||
data = data[: matchResult.start(0)] + value + data[matchResult.end(0) :]
|
||
else:
|
||
if writeNewBeforeString is not None:
|
||
cmdPos = data.find(writeNewBeforeString)
|
||
if cmdPos == -1:
|
||
raise PluginError("Could not find '" + writeNewBeforeString + "'.")
|
||
data = data[:cmdPos] + value + "\n" + data[cmdPos:]
|
||
else:
|
||
data += "\n" + value
|
||
dataFile = open(filePath, "w", newline="\n")
|
||
dataFile.write(data)
|
||
dataFile.close()
|
||
else:
|
||
raise PluginError(filePath + " does not exist.")
|
||
|
||
|
||
def yield_children(obj: bpy.types.Object):
|
||
yield obj
|
||
if obj.children:
|
||
for o in obj.children:
|
||
yield from yield_children(o)
|
||
|
||
|
||
def store_original_mtx():
|
||
active_obj = bpy.context.view_layer.objects.active
|
||
for obj in yield_children(active_obj):
|
||
# negative scales produce a rotation, we need to remove that since
|
||
# scales will be applied to the transform for each object
|
||
loc, rot, _scale = obj.matrix_local.decompose()
|
||
obj["original_mtx"] = Matrix.LocRotScale(loc, rot, None)
|
||
loc, rot, scale = obj.matrix_world.decompose()
|
||
obj["original_mtx_world"] = Matrix.LocRotScale(loc, rot, scale)
|
||
|
||
|
||
def rotate_bounds(bounds, mtx: mathutils.Matrix):
|
||
return [(mtx @ mathutils.Vector(b)).to_tuple() for b in bounds]
|
||
|
||
|
||
def obj_scale_is_unified(obj):
|
||
"""Combine scale values into a set to ensure all values are the same"""
|
||
return len(set(obj.scale)) == 1
|
||
|
||
|
||
def translation_rotation_from_mtx(mtx: mathutils.Matrix):
|
||
"""Strip scale from matrix"""
|
||
t, r, _ = mtx.decompose()
|
||
return Matrix.Translation(t) @ r.to_matrix().to_4x4()
|
||
|
||
|
||
def scale_mtx_from_vector(scale: mathutils.Vector):
|
||
return mathutils.Matrix.Diagonal(scale[0:3]).to_4x4()
|
||
|
||
|
||
def attemptModifierApply(modifier):
|
||
try:
|
||
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
||
except Exception as e:
|
||
print("Skipping modifier " + str(modifier.name))
|
||
|
||
|
||
def copy_object_and_apply(obj: bpy.types.Object, apply_scale=False, apply_modifiers=False):
|
||
if apply_scale or apply_modifiers:
|
||
# it's a unique mesh, use object name
|
||
obj["instanced_mesh_name"] = obj.name
|
||
|
||
obj.original_name = obj.name
|
||
|
||
obj_copy = obj.copy()
|
||
obj_copy.data = obj_copy.data.copy()
|
||
|
||
if apply_modifiers:
|
||
# In order to correctly apply modifiers, we have to go through blender and add the object to the collection, then apply modifiers
|
||
prev_active = bpy.context.view_layer.objects.active
|
||
bpy.context.collection.objects.link(obj_copy)
|
||
obj_copy.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj_copy
|
||
for modifier in obj_copy.modifiers:
|
||
attemptModifierApply(modifier)
|
||
|
||
bpy.context.view_layer.objects.active = prev_active
|
||
|
||
obj_copy.parent = None
|
||
# reset transformations
|
||
obj_copy.location = mathutils.Vector([0.0, 0.0, 0.0])
|
||
obj_copy.scale = mathutils.Vector([1.0, 1.0, 1.0])
|
||
obj_copy.rotation_quaternion = mathutils.Quaternion([1, 0, 0, 0])
|
||
|
||
mtx = transform_mtx_blender_to_n64()
|
||
if apply_scale:
|
||
mtx = mtx @ scale_mtx_from_vector(obj.scale)
|
||
|
||
obj_copy.data.transform(mtx)
|
||
# Flag used for finding these temp objects
|
||
obj_copy["temp_export"] = True
|
||
|
||
# Override for F3D culling bounds (used in addCullCommand)
|
||
bounds_mtx = transform_mtx_blender_to_n64()
|
||
if apply_scale:
|
||
bounds_mtx = bounds_mtx @ scale_mtx_from_vector(obj.scale) # apply scale if needed
|
||
obj_copy["culling_bounds"] = rotate_bounds(obj_copy.bound_box, bounds_mtx)
|
||
|
||
|
||
def store_original_meshes(add_warning: Callable[[str], None]):
|
||
"""
|
||
- Creates new objects at 0, 0, 0 with shared mesh
|
||
- Original mesh name is saved to each object
|
||
"""
|
||
instanced_meshes = set()
|
||
active_obj = bpy.context.view_layer.objects.active
|
||
for obj in yield_children(active_obj):
|
||
if obj.type != "EMPTY":
|
||
has_modifiers = len(obj.modifiers) != 0
|
||
has_uneven_scale = not obj_scale_is_unified(obj)
|
||
shares_mesh = obj.data.users > 1
|
||
can_instance = not has_modifiers and not has_uneven_scale
|
||
should_instance = can_instance and (shares_mesh or obj.scaleFromGeolayout)
|
||
|
||
if should_instance:
|
||
# add `_shared_mesh` to instanced name because `obj.data.name` can be the same as object names
|
||
obj["instanced_mesh_name"] = f"{obj.data.name}_shared_mesh"
|
||
obj.original_name = obj.name
|
||
|
||
if obj.data.name not in instanced_meshes:
|
||
instanced_meshes.add(obj.data.name)
|
||
copy_object_and_apply(obj)
|
||
else:
|
||
if shares_mesh and has_modifiers:
|
||
add_warning(
|
||
f'Object "{obj.name}" cannot be instanced due to having modifiers so an extra displaylist will be created. Remove modifiers to allow instancing.'
|
||
)
|
||
if shares_mesh and has_uneven_scale:
|
||
add_warning(
|
||
f'Object "{obj.name}" cannot be instanced due to uneven object scaling and an extra displaylist will be created. Set all scale values to the same value to allow instancing.'
|
||
)
|
||
|
||
copy_object_and_apply(obj, apply_scale=True, apply_modifiers=has_modifiers)
|
||
bpy.context.view_layer.objects.active = active_obj
|
||
|
||
|
||
def get_obj_temp_mesh(obj):
|
||
for o in bpy.data.objects:
|
||
if o.get("temp_export") and o.get("instanced_mesh_name") == obj.get("instanced_mesh_name"):
|
||
return o
|
||
|
||
|
||
def apply_objects_modifiers_and_transformations(allObjs: Iterable[bpy.types.Object]):
|
||
# first apply modifiers so that any objects that affect each other are taken into consideration
|
||
for selectedObj in allObjs:
|
||
selectSingleObject(selectedObj)
|
||
|
||
for modifier in selectedObj.modifiers:
|
||
attemptModifierApply(modifier)
|
||
|
||
# apply transformations now that world space changes are applied
|
||
for selectedObj in allObjs:
|
||
selectSingleObject(selectedObj)
|
||
|
||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True, properties=False)
|
||
|
||
|
||
def duplicateHierarchy(obj, ignoreAttr, includeEmpties, areaIndex):
|
||
# Duplicate objects to apply scale / modifiers / linked data
|
||
deselectAllObjects()
|
||
selectMeshChildrenOnly(obj, None, includeEmpties, areaIndex)
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
bpy.ops.object.duplicate()
|
||
try:
|
||
tempObj = bpy.context.view_layer.objects.active
|
||
allObjs = bpy.context.selected_objects
|
||
|
||
bpy.ops.object.make_single_user(obdata=True)
|
||
|
||
apply_objects_modifiers_and_transformations(allObjs)
|
||
|
||
for selectedObj in allObjs:
|
||
if ignoreAttr is not None and getattr(selectedObj, ignoreAttr):
|
||
for child in selectedObj.children:
|
||
selectSingleObject(child)
|
||
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
|
||
selectedObj.parent.select_set(True)
|
||
bpy.context.view_layer.objects.active = selectedObj.parent
|
||
bpy.ops.object.parent_set(keep_transform=True)
|
||
selectedObj.parent = None
|
||
return tempObj, allObjs
|
||
except Exception as e:
|
||
cleanupDuplicatedObjects(allObjs)
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
raise Exception(str(e))
|
||
|
||
|
||
enumSM64PreInlineGeoLayoutObjects = {"Geo ASM", "Geo Branch", "Geo Displaylist"}
|
||
|
||
|
||
def checkIsSM64PreInlineGeoLayout(obj):
|
||
return obj.sm64_obj_type in enumSM64PreInlineGeoLayoutObjects
|
||
|
||
|
||
enumSM64InlineGeoLayoutObjects = {
|
||
"Geo Translate/Rotate",
|
||
"Geo Translate Node",
|
||
"Geo Rotation Node",
|
||
"Geo Billboard",
|
||
"Geo Scale",
|
||
}
|
||
|
||
|
||
def checkIsSM64InlineGeoLayout(obj):
|
||
return (
|
||
obj.sm64_obj_type in enumSM64InlineGeoLayoutObjects
|
||
or checkIsSM64PreInlineGeoLayout(obj)
|
||
or (obj.sm64_obj_type == "Custom" and obj.fast64.sm64.custom.cmd_type == "Geo")
|
||
)
|
||
|
||
|
||
enumSM64EmptyWithGeolayout = {"None", "Level Root", "Area Root", "Switch"}
|
||
|
||
|
||
def checkSM64EmptyUsesGeoLayout(obj):
|
||
return obj.sm64_obj_type in enumSM64EmptyWithGeolayout or checkIsSM64InlineGeoLayout(obj)
|
||
|
||
|
||
def selectMeshChildrenOnly(obj, ignoreAttr, includeEmpties, areaIndex):
|
||
checkArea = areaIndex is not None and obj.type == "EMPTY"
|
||
if checkArea and obj.sm64_obj_type == "Area Root" and obj.areaIndex != areaIndex:
|
||
return
|
||
ignoreObj = ignoreAttr is not None and getattr(obj, ignoreAttr)
|
||
isMesh = obj.type == "MESH"
|
||
isEmpty = obj.type == "EMPTY" and includeEmpties and checkSM64EmptyUsesGeoLayout(obj)
|
||
if (isMesh or isEmpty) and not ignoreObj:
|
||
obj.select_set(True)
|
||
obj.original_name = obj.name
|
||
for child in obj.children:
|
||
if checkArea and obj.sm64_obj_type == "Level Root":
|
||
if not (child.type == "EMPTY" and child.sm64_obj_type == "Area Root"):
|
||
continue
|
||
selectMeshChildrenOnly(child, ignoreAttr, includeEmpties, areaIndex)
|
||
|
||
|
||
def cleanupDuplicatedObjects(selected_objects):
|
||
meshData = []
|
||
for selectedObj in selected_objects:
|
||
if selectedObj.type == "MESH":
|
||
meshData.append(selectedObj.data)
|
||
for selectedObj in selected_objects:
|
||
bpy.data.objects.remove(selectedObj)
|
||
for mesh in meshData:
|
||
bpy.data.meshes.remove(mesh)
|
||
|
||
|
||
def cleanupTempMeshes():
|
||
"""Delete meshes that have been duplicated for instancing"""
|
||
remove_data = []
|
||
for obj in bpy.data.objects:
|
||
if obj.get("temp_export"):
|
||
remove_data.append(obj.data)
|
||
bpy.data.objects.remove(obj)
|
||
else:
|
||
if obj.get("instanced_mesh_name"):
|
||
del obj["instanced_mesh_name"]
|
||
if obj.get("original_mtx"):
|
||
del obj["original_mtx"]
|
||
if obj.get("original_mtx_world"):
|
||
del obj["original_mtx_world"]
|
||
|
||
for data in remove_data:
|
||
data_type = type(data)
|
||
if data_type == bpy.types.Mesh:
|
||
bpy.data.meshes.remove(data)
|
||
elif data_type == bpy.types.Curve:
|
||
bpy.data.curves.remove(data)
|
||
|
||
|
||
def combineObjects(obj, includeChildren, ignoreAttr, areaIndex):
|
||
obj.original_name = obj.name
|
||
|
||
# Duplicate objects to apply scale / modifiers / linked data
|
||
deselectAllObjects()
|
||
if includeChildren:
|
||
selectMeshChildrenOnly(obj, ignoreAttr, False, areaIndex)
|
||
else:
|
||
obj.select_set(True)
|
||
if len(bpy.context.selected_objects) == 0:
|
||
return None, []
|
||
bpy.ops.object.duplicate()
|
||
joinedObj = None
|
||
try:
|
||
# duplicate obj and apply modifiers / make single user
|
||
allObjs = bpy.context.selected_objects
|
||
bpy.ops.object.make_single_user(obdata=True)
|
||
|
||
apply_objects_modifiers_and_transformations(allObjs)
|
||
|
||
deselectAllObjects()
|
||
|
||
# Joining causes orphan data, so we remove it manually.
|
||
meshList = []
|
||
for selectedObj in allObjs:
|
||
selectedObj.select_set(True)
|
||
meshList.append(selectedObj.data)
|
||
|
||
joinedObj = bpy.context.selected_objects[0]
|
||
bpy.context.view_layer.objects.active = joinedObj
|
||
joinedObj.select_set(True)
|
||
meshList.remove(joinedObj.data)
|
||
bpy.ops.object.join()
|
||
setOrigin(joinedObj, obj.location)
|
||
|
||
selectSingleObject(joinedObj)
|
||
|
||
# Need to clear parent transform in order to correctly apply transform.
|
||
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
|
||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True, properties=False)
|
||
bpy.context.view_layer.objects.active = joinedObj
|
||
joinedObj.select_set(True)
|
||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True, properties=False)
|
||
|
||
except Exception as e:
|
||
cleanupDuplicatedObjects(allObjs)
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = obj
|
||
raise Exception(str(e))
|
||
|
||
return joinedObj, meshList
|
||
|
||
|
||
def cleanupCombineObj(tempObj, meshList):
|
||
for mesh in meshList:
|
||
bpy.data.meshes.remove(mesh)
|
||
cleanupDuplicatedObjects([tempObj])
|
||
# obj.select_set(True)
|
||
# bpy.context.view_layer.objects.active = obj
|
||
|
||
|
||
def writeInsertableFile(filepath, dataType, address_ptrs, startPtr, data):
|
||
address = 0
|
||
openfile = open(filepath, "wb")
|
||
|
||
# 0-4 - Data Type
|
||
openfile.write(dataType.to_bytes(4, "big"))
|
||
address += 4
|
||
|
||
# 4-8 - Data Size
|
||
openfile.seek(address)
|
||
openfile.write(len(data).to_bytes(4, "big"))
|
||
address += 4
|
||
|
||
# 8-12 Start Address
|
||
openfile.seek(address)
|
||
openfile.write(startPtr.to_bytes(4, "big"))
|
||
address += 4
|
||
|
||
# 12-16 - Number of pointer addresses
|
||
openfile.seek(address)
|
||
openfile.write(len(address_ptrs).to_bytes(4, "big"))
|
||
address += 4
|
||
|
||
# 16-? - Pointer address list
|
||
for i in range(len(address_ptrs)):
|
||
openfile.seek(address)
|
||
openfile.write(address_ptrs[i].to_bytes(4, "big"))
|
||
address += 4
|
||
|
||
openfile.seek(address)
|
||
openfile.write(data)
|
||
openfile.close()
|
||
|
||
|
||
def quantize_color(color: mathutils.Color, bit_counts: tuple[int]):
|
||
"""Quantize a color to the specified bit counts."""
|
||
assert len(color) == len(bit_counts), "Number of color channels does not match number of bit counts"
|
||
result = 0
|
||
pos = 0
|
||
for c, bit_count in zip(reversed(color), reversed(bit_counts)):
|
||
result |= round(c * (2**bit_count - 1)) << pos
|
||
pos += bit_count
|
||
return result
|
||
|
||
|
||
def colorTo16bitRGBA(color):
|
||
r = int(round(color[0] * 31))
|
||
g = int(round(color[1] * 31))
|
||
b = int(round(color[2] * 31))
|
||
a = 1 if color[3] > 0.5 else 0
|
||
|
||
return (r << 11) | (g << 6) | (b << 1) | a
|
||
|
||
|
||
# On 2.83/2.91 the rotate operator rotates in the opposite direction (???)
|
||
def getDirectionGivenAppVersion():
|
||
if bpy.app.version[1] == 83 or bpy.app.version[1] == 91:
|
||
return -1
|
||
else:
|
||
return 1
|
||
|
||
|
||
def applyRotation(objList, angle, axis):
|
||
bpy.context.scene.tool_settings.use_transform_data_origin = False
|
||
bpy.context.scene.tool_settings.use_transform_pivot_point_align = False
|
||
bpy.context.scene.tool_settings.use_transform_skip_children = False
|
||
|
||
deselectAllObjects()
|
||
for obj in objList:
|
||
obj.select_set(True)
|
||
bpy.context.view_layer.objects.active = objList[0]
|
||
|
||
direction = getDirectionGivenAppVersion()
|
||
|
||
bpy.ops.transform.rotate(value=direction * angle, orient_axis=axis, orient_type="GLOBAL")
|
||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True, properties=False)
|
||
|
||
|
||
def doRotation(angle, axis):
|
||
direction = getDirectionGivenAppVersion()
|
||
bpy.ops.transform.rotate(value=direction * angle, orient_axis=axis, orient_type="GLOBAL")
|
||
|
||
|
||
def getAddressFromRAMAddress(RAMAddress):
|
||
addr = RAMAddress - 0x80000000
|
||
if addr < 0:
|
||
raise PluginError("Invalid RAM address.")
|
||
return addr
|
||
|
||
|
||
def getObjectQuaternion(obj):
|
||
if obj.rotation_mode == "QUATERNION":
|
||
rotation = mathutils.Quaternion(obj.rotation_quaternion)
|
||
elif obj.rotation_mode == "AXIS_ANGLE":
|
||
rotation = mathutils.Quaternion(obj.rotation_axis_angle)
|
||
else:
|
||
rotation = mathutils.Euler(obj.rotation_euler, obj.rotation_mode).to_quaternion()
|
||
return rotation
|
||
|
||
|
||
def tempName(name):
|
||
letters = string.digits
|
||
return name + "_temp" + "".join(random.choice(letters) for i in range(10))
|
||
|
||
|
||
def label_split(layout, name, text):
|
||
split = layout.split(factor=0.5)
|
||
split.label(text=name)
|
||
split.label(text=text)
|
||
|
||
|
||
def enum_label_split(layout, name, data, prop, enumItems):
|
||
split = layout.split(factor=0.5)
|
||
split.label(text=name)
|
||
split.enum_item_name(data, prop, enumItems)
|
||
|
||
|
||
def prop_split(layout, data, field, name, **prop_kwargs):
|
||
split = layout.split(factor=0.5)
|
||
split.label(text=name)
|
||
split.prop(data, field, text="", **prop_kwargs)
|
||
|
||
|
||
def multilineLabel(layout: UILayout, text: str, icon: str = "NONE"):
|
||
layout = layout.column()
|
||
for i, line in enumerate(text.split("\n")):
|
||
r = layout.row()
|
||
r.label(text=line, icon=icon if i == 0 else "NONE")
|
||
r.scale_y = 0.75
|
||
|
||
|
||
def draw_and_check_tab(
|
||
layout: UILayout, data, proprety: str, text: Optional[str] = None, icon: Optional[str] = None
|
||
) -> bool:
|
||
row = layout.row(align=True)
|
||
tab = getattr(data, proprety)
|
||
tria_icon = "TRIA_DOWN" if tab else "TRIA_RIGHT"
|
||
if icon is not None:
|
||
row.prop(data, proprety, icon=tria_icon, text="")
|
||
row.prop(data, proprety, icon=tria_icon if icon is None else icon, text=text)
|
||
if tab:
|
||
layout.separator()
|
||
return tab
|
||
|
||
|
||
def run_and_draw_errors(layout: UILayout, func, *args):
|
||
try:
|
||
func(*args)
|
||
return True
|
||
except Exception as e:
|
||
multilineLabel(layout.box(), str(e), "ERROR")
|
||
return False
|
||
|
||
|
||
def path_checks(path: str, empty="Empty path.", doesnt_exist="Path {}does not exist.", include_path=True):
|
||
path_in_error = f'"{path}" ' if include_path else ""
|
||
if path == "":
|
||
raise PluginError(empty)
|
||
elif not os.path.exists(path):
|
||
raise FileNotFoundError(doesnt_exist.format(path_in_error))
|
||
|
||
|
||
def path_ui_warnings(layout: bpy.types.UILayout, path: str, empty="Empty path.", doesnt_exist="Path does not exist."):
|
||
return run_and_draw_errors(layout, path_checks, path, empty, doesnt_exist, False)
|
||
|
||
|
||
def directory_path_checks(
|
||
path: str,
|
||
empty="Empty path.",
|
||
doesnt_exist="Directory {}does not exist.",
|
||
not_a_directory="Path {}is not a folder.",
|
||
include_path=True,
|
||
):
|
||
path_checks(path, empty, doesnt_exist, include_path)
|
||
if not os.path.isdir(path):
|
||
raise NotADirectoryError(not_a_directory.format(f'"{path}" ' if include_path else ""))
|
||
|
||
|
||
def directory_ui_warnings(
|
||
layout: bpy.types.UILayout,
|
||
path: str,
|
||
empty="Empty path.",
|
||
doesnt_exist="Directory does not exist.",
|
||
not_a_directory="Path is not a folder.",
|
||
):
|
||
return run_and_draw_errors(layout, directory_path_checks, path, empty, doesnt_exist, not_a_directory, False)
|
||
|
||
|
||
def filepath_checks(
|
||
path: str,
|
||
empty="Empty path.",
|
||
doesnt_exist="File {}does not exist.",
|
||
not_a_file="Path {}is not a file.",
|
||
include_path=True,
|
||
):
|
||
path_checks(path, empty, doesnt_exist, include_path)
|
||
if not os.path.isfile(path):
|
||
raise IsADirectoryError(not_a_file.format(f'"{path}" ' if include_path else ""))
|
||
|
||
|
||
def filepath_ui_warnings(
|
||
layout: bpy.types.UILayout,
|
||
path: str,
|
||
empty="Empty path.",
|
||
doesnt_exist="File does not exist.",
|
||
not_a_file="Path is not a file.",
|
||
):
|
||
return run_and_draw_errors(layout, filepath_checks, path, empty, doesnt_exist, not_a_file, False)
|
||
|
||
|
||
def toAlnum(name, exceptions=[]):
|
||
if name is None or name == "":
|
||
return None
|
||
for i in range(len(name)):
|
||
if not name[i].isalnum() and not name[i] in exceptions:
|
||
name = name[:i] + "_" + name[i + 1 :]
|
||
if name[0].isdigit():
|
||
name = "_" + name
|
||
return name
|
||
|
||
|
||
def get64bitAlignedAddr(address):
|
||
endNibble = hex(address)[-1]
|
||
if endNibble != "0" and endNibble != "8":
|
||
address = ceil(address / 8) * 8
|
||
return address
|
||
|
||
|
||
def getNameFromPath(path, removeExtension=False):
|
||
if path[:2] == "//":
|
||
path = path[2:]
|
||
name = os.path.basename(path)
|
||
if removeExtension:
|
||
name = os.path.splitext(name)[0]
|
||
return toAlnum(name, ["-", "."])
|
||
|
||
|
||
def gammaCorrect(linearColor):
|
||
return list(mathutils.Color(linearColor[:3]).from_scene_linear_to_srgb())
|
||
|
||
|
||
def s_rgb_alpha_1_tuple(linearColor):
|
||
s_rgb = gammaCorrect(linearColor)
|
||
s_rgb.append(1.0)
|
||
return tuple(s for s in s_rgb)
|
||
|
||
|
||
def gammaCorrectValue(linearValue):
|
||
# doesn't need to use `colorToLuminance` since all values are the same
|
||
return mathutils.Color((linearValue, linearValue, linearValue)).from_scene_linear_to_srgb().v
|
||
|
||
|
||
def gammaInverse(sRGBColor):
|
||
return list(mathutils.Color(sRGBColor[:3]).from_srgb_to_scene_linear())
|
||
|
||
|
||
def gammaInverseValue(sRGBValue):
|
||
# doesn't need to use `colorToLuminance` since all values are the same
|
||
return mathutils.Color((sRGBValue, sRGBValue, sRGBValue)).from_srgb_to_scene_linear().v
|
||
|
||
|
||
def exportColor(lightColor):
|
||
return [scaleToU8(value) for value in gammaCorrect(lightColor)]
|
||
|
||
|
||
def get_clean_color(color: list, include_alpha=False, round_color=True, srgb_to_linear=False) -> list:
|
||
color = list(color)
|
||
if srgb_to_linear:
|
||
color = gammaInverse(color[:3]) + color[3:]
|
||
color = color[: 4 if include_alpha else 3]
|
||
if include_alpha and len(color) < 4:
|
||
color = color + [1.0]
|
||
return tuple(round(channel, 4) if round_color else channel for channel in color)
|
||
|
||
|
||
def printBlenderMessage(msgSet, message, blenderOp):
|
||
if blenderOp is not None:
|
||
blenderOp.report(msgSet, message)
|
||
else:
|
||
print(message)
|
||
|
||
|
||
def bytesToInt(value):
|
||
return int.from_bytes(value, "big")
|
||
|
||
|
||
def bytesToHex(value, byteSize=4):
|
||
return format(bytesToInt(value), f"#0{(byteSize * 2 + 2)}x")
|
||
|
||
|
||
def bytesToHexClean(value, byteSize=4):
|
||
return format(bytesToInt(value), f"#0{(byteSize * 2)}x")
|
||
|
||
|
||
def intToHex(value, byte_size=4, signed=True):
|
||
return format(value if signed else cast_integer(value, byte_size * 8, False), f"#0{(byte_size * 2 + 2)}x")
|
||
|
||
|
||
def intToBytes(value, byteSize):
|
||
return bytes.fromhex(intToHex(value, byteSize)[2:])
|
||
|
||
|
||
# byte input
|
||
# returns an integer, usually used for file seeking positions
|
||
def decodeSegmentedAddr(address, segmentData):
|
||
# print(bytesAsHex(address))
|
||
if address[0] not in segmentData:
|
||
raise PluginError("Segment " + str(address[0]) + " not found in segment list.")
|
||
segmentStart = segmentData[address[0]][0]
|
||
return segmentStart + bytesToInt(address[1:4])
|
||
|
||
|
||
# int input
|
||
# returns bytes, usually used for writing new segmented addresses
|
||
def encodeSegmentedAddr(address, segmentData):
|
||
segment = getSegment(address, segmentData)
|
||
segmentStart = segmentData[segment][0]
|
||
|
||
segmentedAddr = address - segmentStart
|
||
return intToBytes(segment, 1) + intToBytes(segmentedAddr, 3)
|
||
|
||
|
||
def getSegment(address, segmentData):
|
||
for segment, interval in segmentData.items():
|
||
if address in range(*interval):
|
||
return segment
|
||
|
||
raise PluginError("Address " + hex(address) + " is not found in any of the provided segments.")
|
||
|
||
|
||
# Position
|
||
def readVectorFromShorts(command, offset):
|
||
return [readFloatFromShort(command, valueOffset) for valueOffset in range(offset, offset + 6, 2)]
|
||
|
||
|
||
def readFloatFromShort(command, offset):
|
||
return (
|
||
int.from_bytes(command[offset : offset + 2], "big", signed=True)
|
||
/ bpy.context.scene.fast64.sm64.blender_to_sm64_scale
|
||
)
|
||
|
||
|
||
def writeVectorToShorts(command, offset, values):
|
||
for i in range(3):
|
||
valueOffset = offset + i * 2
|
||
writeFloatToShort(command, valueOffset, values[i])
|
||
|
||
|
||
def writeFloatToShort(command, offset, value):
|
||
command[offset : offset + 2] = int(round(value * bpy.context.scene.fast64.sm64.blender_to_sm64_scale)).to_bytes(
|
||
2, "big", signed=True
|
||
)
|
||
|
||
|
||
def convertFloatToShort(value):
|
||
return int(round((value * bpy.context.scene.fast64.sm64.blender_to_sm64_scale)))
|
||
|
||
|
||
def convertEulerFloatToShort(value):
|
||
return int(round(degrees(value)))
|
||
|
||
|
||
# Rotation
|
||
|
||
|
||
# Rotation is stored as a short.
|
||
# Zero rotation starts at Z+ on an XZ plane and goes counterclockwise.
|
||
# 2**16 - 1 is the last value before looping around again.
|
||
def readEulerVectorFromShorts(command, offset):
|
||
return [readEulerFloatFromShort(command, valueOffset) for valueOffset in range(offset, offset + 6, 2)]
|
||
|
||
|
||
def readEulerFloatFromShort(command, offset):
|
||
return radians(int.from_bytes(command[offset : offset + 2], "big", signed=True))
|
||
|
||
|
||
def writeEulerVectorToShorts(command, offset, values):
|
||
for i in range(3):
|
||
valueOffset = offset + i * 2
|
||
writeEulerFloatToShort(command, valueOffset, values[i])
|
||
|
||
|
||
def writeEulerFloatToShort(command, offset, value):
|
||
command[offset : offset + 2] = int(round(degrees(value))).to_bytes(2, "big", signed=True)
|
||
|
||
|
||
def getObjDirectionVec(obj, toExport: bool):
|
||
rotation = getObjectQuaternion(obj)
|
||
if toExport:
|
||
spaceRot = mathutils.Euler((-pi / 2, 0, 0)).to_quaternion()
|
||
rotation = spaceRot @ rotation
|
||
normal = (rotation @ mathutils.Vector((0, 0, 1))).normalized()
|
||
return normal
|
||
|
||
|
||
# convert 32 bit (8888) to 16 bit (5551) color
|
||
def convert32to16bitRGBA(oldPixel):
|
||
if oldPixel[3] > 127:
|
||
alpha = 1
|
||
else:
|
||
alpha = 0
|
||
newPixel = (oldPixel[0] >> 3) << 11 | (oldPixel[1] >> 3) << 6 | (oldPixel[2] >> 3) << 1 | alpha
|
||
return newPixel.to_bytes(2, "big")
|
||
|
||
|
||
# convert normalized RGB values to bytes (0-255)
|
||
def convertRGB(normalizedRGB):
|
||
return bytearray([int(normalizedRGB[0] * 255), int(normalizedRGB[1] * 255), int(normalizedRGB[2] * 255)])
|
||
|
||
|
||
# convert normalized RGB values to bytes (0-255)
|
||
def convertRGBA(normalizedRGBA):
|
||
return bytearray(
|
||
[
|
||
int(normalizedRGBA[0] * 255),
|
||
int(normalizedRGBA[1] * 255),
|
||
int(normalizedRGBA[2] * 255),
|
||
int(normalizedRGBA[3] * 255),
|
||
]
|
||
)
|
||
|
||
|
||
def vector3ComponentMultiply(a, b):
|
||
return mathutils.Vector((a.x * b.x, a.y * b.y, a.z * b.z))
|
||
|
||
|
||
# Position values are signed shorts.
|
||
def convertPosition(position):
|
||
positionShorts = [int(floatValue) for floatValue in position]
|
||
F3DPosition = bytearray(0)
|
||
for shortData in [shortValue.to_bytes(2, "big", signed=True) for shortValue in positionShorts]:
|
||
F3DPosition.extend(shortData)
|
||
return F3DPosition
|
||
|
||
|
||
# UVs in F3D are a fixed point short: s10.5 (hence the 2**5)
|
||
# fixed point is NOT exponent+mantissa, it is integer+fraction
|
||
def convertUV(normalizedUVs, textureWidth, textureHeight):
|
||
# print(str(normalizedUVs[0]) + " - " + str(normalizedUVs[1]))
|
||
F3DUVs = convertFloatToFixed16Bytes(normalizedUVs[0] * textureWidth) + convertFloatToFixed16Bytes(
|
||
normalizedUVs[1] * textureHeight
|
||
)
|
||
return F3DUVs
|
||
|
||
|
||
def convertFloatToFixed16Bytes(value):
|
||
value *= 2**5
|
||
value = min(max(value, -(2**15)), 2**15 - 1)
|
||
|
||
return int(round(value)).to_bytes(2, "big", signed=True)
|
||
|
||
|
||
def convertFloatToFixed16(value):
|
||
return int(round(value * (2**5)))
|
||
|
||
# We want support for large textures with 32 bit UVs
|
||
# value *= 2**5
|
||
# value = min(max(value, -2**15), 2**15 - 1)
|
||
# return int.from_bytes(
|
||
# int(round(value)).to_bytes(2, 'big', signed = True), 'big')
|
||
|
||
|
||
def scaleToU8(val):
|
||
return min(int(round(val * 0xFF)), 255)
|
||
|
||
|
||
def normToSigned8Vector(normal):
|
||
return [int.from_bytes(int(value * 127).to_bytes(1, "big", signed=True), "big") for value in normal]
|
||
|
||
|
||
def unpackNormalS8(packedNormal: int) -> Tuple[int, int, int]:
|
||
assert isinstance(packedNormal, int) and packedNormal >= 0 and packedNormal <= 0xFFFF
|
||
if bpy.context.scene.packed_normals_algorithm == "565":
|
||
x = packedNormal & 0xF800
|
||
y = (packedNormal & 0x07E0) << 5
|
||
z = (packedNormal & 0x001F) << 11
|
||
x, y, z = tuple(map(lambda n: (n - 0x10000 if n & 0x8000 else n), (x, y, z)))
|
||
elif bpy.context.scene.packed_normals_algorithm == "Octahedral":
|
||
xo, yo = packedNormal >> 8, packedNormal & 0xFF
|
||
# This is following the instructions in F3DEX3
|
||
x, y = xo & 0x7F, yo & 0x7F
|
||
z = x + y
|
||
zNeg = bool(z & 0x80)
|
||
x2, y2 = x ^ 0x7F, y ^ 0x7F # this is actually producing 7F - x, 7F - y
|
||
z = z ^ 0x7F # 7F - x - y; using xor saves an instruction and a register on the RSP
|
||
if zNeg:
|
||
x, y = x2, y2
|
||
x, y = -x if xo & 0x80 else x, -y if yo & 0x80 else y
|
||
z = z - 0x100 if z & 0x80 else z
|
||
assert abs(x) + abs(y) + abs(z) == 127
|
||
else:
|
||
raise PluginError("Invalid packed normals algorithm")
|
||
return x, y, z
|
||
|
||
|
||
def unpackNormal(packedNormal: int) -> Vector:
|
||
# Convert constant-L1 norm to standard L2 norm
|
||
return Vector(unpackNormalS8(packedNormal)).normalized()
|
||
|
||
|
||
def packNormal(normal: Vector) -> int:
|
||
if bpy.context.scene.packed_normals_algorithm == "565":
|
||
|
||
def convertComponent(v: float, range: int):
|
||
v = int(round(v * float(range)))
|
||
v = min(max(v, -range), range - 1)
|
||
v = v if v >= 0 else v + 2 * range
|
||
return v
|
||
|
||
x = convertComponent(normal[0], 16) << 11
|
||
y = convertComponent(normal[1], 32) << 5
|
||
z = convertComponent(normal[2], 16)
|
||
assert (x & y) == 0 and (y & z) == 0 and (x & z) == 0
|
||
packedNormal = x | y | z
|
||
assert packedNormal >= 0 and packedNormal <= 0xFFFF
|
||
elif bpy.context.scene.packed_normals_algorithm == "Octahedral":
|
||
# Convert standard normal to constant-L1 normal
|
||
assert len(normal) == 3
|
||
l1norm = abs(normal[0]) + abs(normal[1]) + abs(normal[2])
|
||
xo, yo, zo = tuple([int(round(a * 127.0 / l1norm)) for a in normal])
|
||
if abs(xo) + abs(yo) > 127:
|
||
yo = int(math.copysign(127 - abs(xo), yo))
|
||
zo = int(math.copysign(127 - abs(xo) - abs(yo), zo))
|
||
assert abs(xo) + abs(yo) + abs(zo) == 127
|
||
# Pack normals
|
||
xsign, ysign = xo & 0x80, yo & 0x80
|
||
x, y = abs(xo), abs(yo)
|
||
if zo < 0:
|
||
x, y = 0x7F - x, 0x7F - y
|
||
x, y = x | xsign, y | ysign
|
||
packedNormal = x << 8 | y
|
||
# The only error is in the float to int rounding above. The packing and unpacking
|
||
# will precisely restore the original int values.
|
||
assert (xo, yo, zo) == unpackNormalS8(packedNormal)
|
||
else:
|
||
raise PluginError("Invalid packed normals algorithm")
|
||
return packedNormal
|
||
|
||
|
||
def getRgbNormalSettings(f3d_mat: "F3DMaterialProperty") -> Tuple[bool, bool, bool]:
|
||
rdp_settings = f3d_mat.rdp_settings
|
||
has_packed_normals = bpy.context.scene.f3d_type == "F3DEX3" and rdp_settings.g_packed_normals
|
||
has_rgb = not rdp_settings.g_lighting or has_packed_normals
|
||
has_normal = rdp_settings.g_lighting
|
||
return has_rgb, has_normal, has_packed_normals
|
||
|
||
|
||
def byteMask(data, offset, amount):
|
||
return bitMask(data, offset * 8, amount * 8)
|
||
|
||
|
||
def bitMask(data, offset, amount):
|
||
return (~(-1 << amount) << offset & data) >> offset
|
||
|
||
|
||
def is_bit_active(x: int, index: int):
|
||
return ((x >> index) & 1) == 1
|
||
|
||
|
||
def read16bitRGBA(data):
|
||
r = bitMask(data, 11, 5) / ((2**5) - 1)
|
||
g = bitMask(data, 6, 5) / ((2**5) - 1)
|
||
b = bitMask(data, 1, 5) / ((2**5) - 1)
|
||
a = bitMask(data, 0, 1) / ((2**1) - 1)
|
||
|
||
return [r, g, b, a]
|
||
|
||
|
||
def join_c_args(args: "list[str]"):
|
||
return ", ".join(args)
|
||
|
||
|
||
def translate_blender_to_n64(translate: mathutils.Vector):
|
||
return transform_mtx_blender_to_n64() @ translate
|
||
|
||
|
||
def rotate_quat_blender_to_n64(rotation: mathutils.Quaternion):
|
||
new_rot = transform_mtx_blender_to_n64() @ rotation.to_matrix().to_4x4() @ transform_mtx_blender_to_n64().inverted()
|
||
return new_rot.to_quaternion()
|
||
|
||
|
||
def all_values_equal_x(vals: Iterable, test):
|
||
return len(set(vals) - set([test])) == 0
|
||
|
||
|
||
def get_blender_to_game_scale(context):
|
||
match context.scene.gameEditorMode:
|
||
case "SM64":
|
||
return context.scene.fast64.sm64.blender_to_sm64_scale
|
||
case "OOT":
|
||
return context.scene.ootBlenderScale
|
||
case "F3D":
|
||
# TODO: (V5) create F3D game editor mode, utilize that scale
|
||
return context.scene.blenderF3DScale
|
||
case _:
|
||
pass
|
||
return context.scene.blenderF3DScale
|
||
|
||
|
||
def get_material_from_context(context: bpy.types.Context):
|
||
"""Safely check if the context has a valid material and return it"""
|
||
try:
|
||
if type(getattr(context, "material", None)) == bpy.types.Material:
|
||
return context.material
|
||
return context.material_slot.material
|
||
except:
|
||
return None
|
||
|
||
|
||
def lightDataToObj(lightData):
|
||
for obj in bpy.context.scene.objects:
|
||
if obj.data == lightData:
|
||
return obj
|
||
raise PluginError(
|
||
f'Referencing a light ("{lightData.name}") that is no longer in the scene (i.e. has been deleted).'
|
||
)
|
||
|
||
|
||
def ootGetSceneOrRoomHeader(parent, idx, isRoom):
|
||
# This should be in oot_utility.py, but it is needed in f3d_material.py
|
||
# which creates a circular import. The real problem is that the F3D render
|
||
# settings stuff should be in a place which can import both SM64 and OoT
|
||
# code without circular dependencies.
|
||
if idx < 0:
|
||
raise PluginError("Alternate scene/room header index too low: " + str(idx))
|
||
target = "Room" if isRoom else "Scene"
|
||
altHeaders = getattr(parent, "ootAlternate" + target + "Headers")
|
||
if idx == 0:
|
||
return getattr(parent, "oot" + target + "Header")
|
||
elif 1 <= idx <= 3:
|
||
if idx == 1:
|
||
ret = altHeaders.childNightHeader
|
||
elif idx == 2:
|
||
ret = altHeaders.adultDayHeader
|
||
else:
|
||
ret = altHeaders.adultNightHeader
|
||
return None if ret.usePreviousHeader else ret
|
||
else:
|
||
if idx - 4 >= len(altHeaders.cutsceneHeaders):
|
||
return None
|
||
return altHeaders.cutsceneHeaders[idx - 4]
|
||
|
||
|
||
def ootGetBaseOrCustomLight(prop, idx, toExport: bool, errIfMissing: bool):
|
||
# This should be in oot_utility.py, but it is needed in render_settings.py
|
||
# which creates a circular import. The real problem is that the F3D render
|
||
# settings stuff should be in a place which can import both SM64 and OoT
|
||
# code without circular dependencies.
|
||
assert idx in {0, 1}
|
||
col = getattr(prop, "diffuse" + str(idx))
|
||
dir = (mathutils.Vector((1.0, -1.0, 1.0)) * (1.0 if idx == 0 else -1.0)).normalized()
|
||
if getattr(prop, "useCustomDiffuse" + str(idx)):
|
||
try:
|
||
light = getattr(prop, "diffuse" + str(idx) + "Custom")
|
||
if light is None:
|
||
if errIfMissing:
|
||
raise PluginError("Light object not set in a scene lighting property.")
|
||
else:
|
||
col = tuple(c for c in light.color) + (1.0,)
|
||
lightObj = lightDataToObj(light)
|
||
dir = getObjDirectionVec(lightObj, toExport)
|
||
except Exception as exc:
|
||
raise PluginError(f"In custom diffuse {idx}: {exc}") from exc
|
||
col = mathutils.Vector(tuple(c for c in col))
|
||
if toExport:
|
||
col, dir = exportColor(col), normToSigned8Vector(dir)
|
||
return col, dir
|
||
|
||
|
||
def getTextureSuffixFromFormat(texFmt):
|
||
# if texFmt == "RGBA16":
|
||
# return "rgb5a1"
|
||
return texFmt.lower()
|
||
|
||
|
||
# https://stackoverflow.com/a/241506
|
||
COMMENT_PATTERN = re.compile(r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"', re.DOTALL | re.MULTILINE)
|
||
|
||
|
||
def removeComments(text: str):
|
||
def replacer(match: re.Match[str]):
|
||
s = match.group(0)
|
||
if s.startswith("/"):
|
||
return " " # note: a space and not an empty string
|
||
else:
|
||
return s
|
||
|
||
return re.sub(COMMENT_PATTERN, replacer, text)
|
||
|
||
|
||
binOps = {
|
||
ast.Add: operator.add,
|
||
ast.Sub: operator.sub,
|
||
ast.Mult: operator.mul,
|
||
ast.Div: operator.truediv,
|
||
ast.Mod: operator.mod,
|
||
ast.LShift: operator.lshift,
|
||
ast.RShift: operator.rshift,
|
||
ast.RShift: operator.rshift,
|
||
ast.BitOr: operator.or_,
|
||
ast.BitAnd: operator.and_,
|
||
ast.BitXor: operator.xor,
|
||
ast.Pow: operator.pow,
|
||
ast.FloorDiv: operator.floordiv,
|
||
ast.USub: operator.neg,
|
||
ast.UAdd: lambda a: a,
|
||
}
|
||
|
||
|
||
def prop_group_to_json(prop_group, blacklist: list[str] = None, whitelist: list[str] = None):
|
||
blacklist = ["rna_type", "name"] + (blacklist or [])
|
||
|
||
def prop_to_json(prop):
|
||
if isinstance(prop, list) or type(prop).__name__ == "bpy_prop_collection_idprop":
|
||
prop = list(prop)
|
||
for index, value in enumerate(prop):
|
||
prop[index] = prop_to_json(value)
|
||
return prop
|
||
elif isinstance(prop, Color):
|
||
return get_clean_color(prop)
|
||
elif hasattr(prop, "to_list"): # for IDPropertyArray classes
|
||
return prop.to_list()
|
||
elif hasattr(prop, "to_dict"):
|
||
return prop.to_dict()
|
||
else:
|
||
return prop
|
||
|
||
data = {}
|
||
for prop in iter_prop(prop_group):
|
||
if prop in blacklist or (whitelist and prop not in whitelist):
|
||
continue
|
||
value = prop_to_json(getattr(prop_group, prop))
|
||
if value is not None:
|
||
data[prop] = value
|
||
return data
|
||
|
||
|
||
def json_to_prop_group(prop_group, data: dict, blacklist: list[str] = None, whitelist: list[str] = None):
|
||
blacklist = ["rna_type", "name"] + (blacklist or [])
|
||
for prop in iter_prop(prop_group):
|
||
if prop in blacklist or (whitelist and prop not in whitelist):
|
||
continue
|
||
default = getattr(prop_group, prop)
|
||
if hasattr(default, "from_dict"):
|
||
default.from_dict(data.get(prop, None))
|
||
else:
|
||
setattr(prop_group, prop, data.get(prop, default))
|
||
|
||
|
||
T = TypeVar("T")
|
||
SetOrVal = T | list[T]
|
||
|
||
|
||
def get_first_set_prop(old_loc, old_props: SetOrVal[str]):
|
||
"""Pops all old props and returns the first one that is set"""
|
||
|
||
def as_set(val: SetOrVal[T]) -> set[T]:
|
||
if isinstance(val, Iterable) and not isinstance(val, str):
|
||
return set(val)
|
||
else:
|
||
return {val}
|
||
|
||
result = None
|
||
for old_prop in as_set(old_props):
|
||
old_value = old_loc.pop(old_prop, None)
|
||
if old_value is not None:
|
||
result = old_value
|
||
return result
|
||
|
||
|
||
def upgrade_old_prop(
|
||
new_loc,
|
||
new_prop: str,
|
||
old_loc,
|
||
old_props: SetOrVal[str],
|
||
old_enum: list[str] = None,
|
||
fix_forced_base_16=False,
|
||
):
|
||
try:
|
||
new_prop_def = new_loc.bl_rna.properties[new_prop]
|
||
new_prop_value = getattr(new_loc, new_prop)
|
||
assert not old_enum or new_prop_def.type == "ENUM"
|
||
assert not (old_enum and fix_forced_base_16)
|
||
|
||
old_value = get_first_set_prop(old_loc, old_props)
|
||
if old_value is None:
|
||
return False
|
||
|
||
if new_prop_def.type == "ENUM":
|
||
if isinstance(old_value, str):
|
||
new_enum_options = {enum_item.identifier for enum_item in new_prop_def.enum_items}
|
||
if old_value not in new_enum_options:
|
||
return False
|
||
elif not isinstance(old_value, int):
|
||
raise ValueError(f"({old_value}) not an int, but {new_prop} is an enum")
|
||
elif old_enum:
|
||
if old_value >= len(old_enum):
|
||
raise ValueError(f"({old_value}) not in {old_enum}")
|
||
old_value = old_enum[old_value]
|
||
else:
|
||
if old_value >= len(new_prop_def.enum_items):
|
||
raise ValueError(f"({old_value}) not in {new_prop}´s enum items")
|
||
old_value = new_prop_def.enum_items[old_value].identifier
|
||
elif isinstance(new_prop_value, bpy.types.PropertyGroup):
|
||
recursiveCopyOldPropertyGroup(old_value, new_prop_value)
|
||
print(f"Upgraded {new_prop} from old location {old_loc} with props {old_props} via recursive group copy")
|
||
return True
|
||
elif isinstance(new_prop_value, bpy.types.Collection):
|
||
copyPropertyCollection(old_value, new_prop_value)
|
||
print(f"Upgraded {new_prop} from old location {old_loc} with props {old_props} via collection copy")
|
||
return True
|
||
elif fix_forced_base_16:
|
||
try:
|
||
if not isinstance(old_value, str):
|
||
raise ValueError(f"({old_value}) not a string")
|
||
old_value = int(old_value, 16)
|
||
if new_prop_def.type == "STRING":
|
||
old_value = intToHex(old_value)
|
||
except ValueError as exc:
|
||
raise ValueError(f"({old_value}) not a valid base 16 integer") from exc
|
||
|
||
if new_prop_def.type == "STRING":
|
||
old_value = str(old_value)
|
||
if getattr(new_loc, new_prop, None) == old_value:
|
||
return False
|
||
setattr(new_loc, new_prop, old_value)
|
||
print(f'{new_prop} set to "{getattr(new_loc, new_prop)}"')
|
||
return True
|
||
except Exception as exc:
|
||
print(f"Failed to upgrade {new_prop} from old location {old_loc} with props {old_props}")
|
||
traceback.print_exc()
|
||
return False
|
||
|
||
|
||
WORLD_WARNING_COUNT = 0
|
||
|
||
|
||
def create_or_get_world(scene: Scene) -> World:
|
||
"""
|
||
Given a scene, this function will return:
|
||
- The world selected in the scene if the scene has a selected world.
|
||
- The first world in bpy.data.worlds if the current file has a world. (Which it almost always does because of the f3d nodes library)
|
||
- Create a world named "Fast64" and return it if no world exits.
|
||
This function does not assign any world to the scene.
|
||
"""
|
||
global WORLD_WARNING_COUNT
|
||
if scene.world:
|
||
WORLD_WARNING_COUNT = 0
|
||
return scene.world
|
||
elif bpy.data.worlds:
|
||
world: World = bpy.data.worlds.values()[0]
|
||
if WORLD_WARNING_COUNT < 10:
|
||
print(f'No world selected in scene, selected the first one found in this file "{world.name}".')
|
||
WORLD_WARNING_COUNT += 1
|
||
return world
|
||
else: # Almost never reached because the node library has its own world
|
||
WORLD_WARNING_COUNT = 0
|
||
print(f'No world in this file, creating world named "Fast64".')
|
||
return bpy.data.worlds.new("Fast64")
|
||
|
||
|
||
def set_if_different(owner: object, prop: str, value):
|
||
if getattr(owner, prop) != value:
|
||
setattr(owner, prop, value)
|
||
|
||
|
||
def set_prop_if_in_data(owner: object, prop_name: str, data: dict, data_name: str):
|
||
if data_name in data:
|
||
set_if_different(owner, prop_name, data[data_name])
|
||
|
||
|
||
def wrap_func_with_error_message(error_message: Callable):
|
||
"""Decorator for big, reused functions that need generic info in errors, such as material exports."""
|
||
|
||
def decorator(func):
|
||
def wrapper(*args, **kwargs):
|
||
# Get the argument names and values (positional and keyword)
|
||
sig = inspect.signature(func)
|
||
bound_args = sig.bind(*args, **kwargs)
|
||
bound_args.apply_defaults()
|
||
try:
|
||
return func(*args, **kwargs)
|
||
except Exception as exc:
|
||
raise PluginError(f"{error_message(bound_args.arguments)} {exc}") from exc
|
||
|
||
return wrapper
|
||
|
||
return decorator
|
||
|
||
|
||
def as_posix(path: Path) -> str:
|
||
return path.as_posix().replace("\\", "/") # Windows path sometimes still has backslashes?
|
||
|
||
|
||
def oot_get_assets_path(base_path: str, check_exists: bool = True, use_decomp_path: bool = True):
|
||
# get the extracted path
|
||
extracted = bpy.context.scene.fast64.oot.get_extracted_path()
|
||
decomp_path = bpy.context.scene.ootDecompPath if use_decomp_path else "."
|
||
|
||
# get the file's path
|
||
file_path = Path(f"{decomp_path}/{base_path}").resolve()
|
||
|
||
# check if the path exists
|
||
if not file_path.exists():
|
||
file_path = Path(f"{bpy.context.scene.ootDecompPath}/{extracted}/{base_path}").resolve()
|
||
|
||
# if it doesn't check if the extracted path exists (we want to skip that for PNG files)
|
||
if check_exists and not file_path.exists():
|
||
raise PluginError(f"ERROR: that file don't exist ({repr(base_path)})")
|
||
|
||
return file_path
|
||
|
||
|
||
def get_include_data(include: str, strip: bool = False):
|
||
"""
|
||
Returns the file data pointed by an include's path (useful to parse *.inc.c files)
|
||
|
||
Parameters:
|
||
- `include`: the line where the include directive is located
|
||
- `strip`: set to True to return the data without any newlines or whitespaces
|
||
"""
|
||
|
||
# remove the unwanted parts
|
||
include = include.replace("\n", "").removeprefix("#include ").replace('"', "")
|
||
|
||
if bpy.context.scene.gameEditorMode in {"OOT", "MM"}:
|
||
file_path = oot_get_assets_path(include)
|
||
else:
|
||
raise PluginError(f"ERROR: game not supported ({bpy.context.scene.gameEditorMode})")
|
||
|
||
data = removeComments(file_path.read_text())
|
||
|
||
if strip:
|
||
return data.replace("\n", "").replace(" ", "")
|
||
|
||
# return the data as a string
|
||
return data
|