diff --git a/README.md b/README.md index 39eaccd..b468008 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ Selecting F3DEX3 as your microcode unlocks a large number of additional presets For cel shading, it is recommended to start with one of the cel shading presets, then modify the settings under the `Use Cel Shading` panel. Hover over each UI control for additional information about how that setting works. +### [Repo Settings](https://fast64.readthedocs.io/en/latest/common/repo_settings/repo_settings.html) + ### Fast64 Development If you'd like to develop in VSCode, follow this tutorial to get proper autocomplete. Skip the linter for now, we'll need to make sure the entire project gets linted before enabling autosave linting because the changes will be massive. https://b3d.interplanety.org/en/using-microsoft-visual-studio-code-as-external-ide-for-writing-blender-scripts-add-ons/ diff --git a/__init__.py b/__init__.py index c259425..8233d1a 100644 --- a/__init__.py +++ b/__init__.py @@ -6,7 +6,7 @@ from bpy.path import abspath from . import addon_updater_ops from .fast64_internal.game_data import game_data -from .fast64_internal.utility import prop_split, multilineLabel, set_prop_if_in_data +from .fast64_internal.utility import prop_split, multilineLabel, set_prop_if_in_data, Matrix4x4Property from .fast64_internal.repo_settings import ( draw_repo_settings, @@ -338,6 +338,7 @@ def upgrade_changed_props(): SM64_Properties.upgrade_changed_props() MK64_Properties.upgrade_changed_props() SM64_ObjectProperties.upgrade_changed_props() + SM64_BoneProperties.upgrade_changed_props() OOT_ObjectProperties.upgrade_changed_props() for scene in bpy.data.scenes: settings: Fast64Settings_Properties = scene.fast64.settings @@ -433,6 +434,7 @@ def register(): register_class(ExampleAddonPreferences) addon_updater_ops.register(bl_info) + register_class(Matrix4x4Property) initOOTActorProperties() utility_anim_register() mat_register() @@ -490,6 +492,7 @@ def unregister(): mat_unregister() bsdf_conv_unregister() bsdf_conv_panel_unregsiter() + unregister_class(Matrix4x4Property) del bpy.types.Scene.fullTraceback del bpy.types.Scene.ignoreTextureRestrictions diff --git a/fast64_internal/f3d/f3d_parser.py b/fast64_internal/f3d/f3d_parser.py index 6b19b2e..57f2dbb 100644 --- a/fast64_internal/f3d/f3d_parser.py +++ b/fast64_internal/f3d/f3d_parser.py @@ -376,8 +376,6 @@ def math_eval(s, f3d): elif isinstance(node, ast.Name): if hasattr(f3d, node.id): return getattr(f3d, node.id) - else: - return node.id elif isinstance(node, ast.Num): return node.n elif isinstance(node, ast.UnaryOp): diff --git a/fast64_internal/operators.py b/fast64_internal/operators.py index fbf8bc7..335a0a9 100644 --- a/fast64_internal/operators.py +++ b/fast64_internal/operators.py @@ -1,10 +1,11 @@ from cProfile import Profile from pstats import SortKey, Stats -from typing import Optional +from typing import TypeVar, Iterable, Optional import bpy, mathutils from bpy.types import Operator, Context, UILayout, EnumProperty from bpy.utils import register_class, unregister_class +from bpy.props import IntProperty, StringProperty from .utility import ( cleanupTempMeshes, @@ -13,6 +14,7 @@ from .utility import ( parentObject, store_original_meshes, store_original_mtx, + deselectAllObjects, ) from .f3d.f3d_material import createF3DMat @@ -79,6 +81,110 @@ class OperatorBase(Operator): bpy.ops.object.mode_set(mode=starting_mode_set) +CollectionMember = TypeVar("CollectionMember") + + +class CollectionOperatorBase(OperatorBase): + """ + A basic collection operator, implements basic add/remove/move/clear operations, + but can support more by the subclass implementing the .lower equivelent of the op_name. + See some examples in sm64/custom_cmd/operators.py + """ + + # index -1 means no index, so on an add that would mean adding at the end with no copy of the previous element + index: IntProperty(default=-1) + op_name: StringProperty() + copy_on_add: bool = False + object_name: str = "item" # simple name to be used in descriptions + + @classmethod + def description(cls, context: Context, properties: dict) -> str: + op_name: str = properties.get("op_name", "") + description = op_name.capitalize() + index = properties.get("index", -1) + if index != -1: + description += f" (copy of {index})" + + object_name = cls.object_name + if op_name == "CLEAR": + object_name += "s" + description += f" {object_name}" + return description + + @classmethod + def collection(cls, context: Context, op_values: dict) -> Iterable[CollectionMember]: + """Abstract method for getting the collection from the context""" + raise NotImplementedError() + + @classmethod + def is_enabled(cls, context: Context, **op_values) -> bool: + """Checks if the operation being drawn should be enabled in the UI, for example clear requires the collection to not be empty""" + collection = cls.collection(context, op_values) + op_name: str = op_values.get("op_name", "") + match op_name: + case "MOVE_UP": + return op_values.get("index") > 0 + case "MOVE_DOWN": + return op_values.get("index") < len(collection) - 1 + case "CLEAR": + return len(collection) > 0 + case _: + lower = op_name.lower() + "_enabled" + if hasattr(cls, lower): + return getattr(cls, lower)(context, collection) + return True + + @classmethod + def draw_row(cls, row: UILayout, index: int, **op_values): + """Draw add/remove/move/clear operations, clear only draws in a element-less index (-1)""" + + def draw_op(icon: str, op_name: str): + cls.draw_props(row, icon, "", op_name=op_name, index=index, **op_values) + + draw_op("ADD", "ADD") + if index == -1: + draw_op("TRASH", "CLEAR") + else: + draw_op("REMOVE", "REMOVE") + draw_op("TRIA_DOWN", "MOVE_DOWN") + draw_op("TRIA_UP", "MOVE_UP") + + def add( + self, _context: Context, collection: Iterable[CollectionMember] + ) -> tuple[CollectionMember | None, CollectionMember]: + """Returns the previous element and the newly created element""" + collection.add() + old_arg: CollectionMember | None = None + new_arg: CollectionMember = collection[-1] + if self.index != -1: + collection.move(len(collection) - 1, self.index + 1) + old_arg = collection[self.index] + new_arg = collection[self.index + 1] + if self.copy_on_add: + copyPropertyGroup(old_arg, new_arg) + return old_arg, new_arg + + def execute_operator(self, context: Context): + collection = self.__class__.collection(context, self.properties) + match self.op_name: + case "ADD": + self.add(context, collection) + case "REMOVE": + collection.remove(self.index) + case "MOVE_UP": + collection.move(self.index, self.index - 1) + case "MOVE_DOWN": + collection.move(self.index, self.index + 1) + case "CLEAR": + collection.clear() + case _: + lower = self.op_name.lower() + if hasattr(self, lower): + getattr(self, lower)(context, collection) + else: + raise NotImplementedError(f'Unimplemented internal op "{self.op_name}"') + + class SearchEnumOperatorBase(OperatorBase): bl_description = "Search Enum" bl_label = "Search" diff --git a/fast64_internal/repo_settings.py b/fast64_internal/repo_settings.py index 93c2504..36cc91f 100644 --- a/fast64_internal/repo_settings.py +++ b/fast64_internal/repo_settings.py @@ -98,7 +98,7 @@ def save_repo_settings(scene: Scene, path: os.PathLike): data["sm64"] = save_sm64_repo_settings(scene) with open(abspath(path), "w", encoding="utf-8") as json_file: - json.dump(data, json_file, indent=2) + json.dump(data, json_file, indent="\t") def draw_repo_settings(layout: UILayout, context: Context): diff --git a/fast64_internal/sm64/README.md b/fast64_internal/sm64/README.md index fcaa1f5..780cbc2 100644 --- a/fast64_internal/sm64/README.md +++ b/fast64_internal/sm64/README.md @@ -57,9 +57,6 @@ Then after applying the rest pose and skinning, you would apply those operations ## Decomp To start, set your base decomp folder in SM64 General Settings. This allows the plugin to automatically add headers/includes to the correct locations. You can always choose to export to a custom location, although headers/includes won't be written. -## Repo settings -Fast64 can save and load repo settings files. By default, they're named fast64.json. These files have RDP defaults, microcode, and more. They also have game-specific settings (OOT will support these in the future). Fast64 will set the path for the settings and auto-load them if auto-load is enabled as soon as the user picks an sm64 decomp path. - ### Decomp Export Types Most exports will let you choose an export type. @@ -154,6 +151,8 @@ To resolve pointer addresses, for each pointer address, # Convert offset to segmented address data[pointer_address] = encode_segmented_address(export_address + current_offset) +### [Custom Commands](https://fast64.readthedocs.io/en/latest/sm64/custom_commands/custom_commands.html) + ### Common Issues Game crashes: Invalid function address for switch/function/held object bones. Animation root translation/rotation not exporting: Make sure you are animating the root bone, not the armature object. diff --git a/fast64_internal/sm64/__init__.py b/fast64_internal/sm64/__init__.py index a9b4299..72e65ce 100644 --- a/fast64_internal/sm64/__init__.py +++ b/fast64_internal/sm64/__init__.py @@ -95,6 +95,8 @@ from .animation import ( SM64_ActionAnimProperty, ) +from .custom_cmd import custom_cmd_register, custom_cmd_unregister + class SM64_ActionProperty(PropertyGroup): """ @@ -135,6 +137,7 @@ def sm64_panel_unregister(): def sm64_register(register_panels: bool): + custom_cmd_register() tools_operators_register() tools_props_register() anim_register() @@ -156,6 +159,7 @@ def sm64_register(register_panels: bool): def sm64_unregister(unregister_panels: bool): + custom_cmd_unregister() tools_operators_unregister() tools_props_unregister() anim_unregister() diff --git a/fast64_internal/sm64/animation/operators.py b/fast64_internal/sm64/animation/operators.py index 8f0e642..7f7ea14 100644 --- a/fast64_internal/sm64/animation/operators.py +++ b/fast64_internal/sm64/animation/operators.py @@ -90,6 +90,7 @@ class SM64_PreviewAnim(OperatorBase): anim_props.played_action = played_action +# TODO: update these to use CollectionOperatorBase class SM64_AnimTableOps(OperatorBase): bl_idname = "scene.sm64_table_operations" bl_label = "Table Operations" diff --git a/fast64_internal/sm64/animation/utility.py b/fast64_internal/sm64/animation/utility.py index 4c6df15..276cb40 100644 --- a/fast64_internal/sm64/animation/utility.py +++ b/fast64_internal/sm64/animation/utility.py @@ -5,14 +5,14 @@ import re from bpy.types import Context, Object, Action, PoseBone from ...utility import findStartBones, PluginError, toAlnum -from ..sm64_geolayout_bone import animatableBoneTypes +from ..sm64_geolayout_utility import is_bone_animatable if TYPE_CHECKING: from .properties import SM64_ActionAnimProperty, SM64_AnimProperties, SM64_ArmatureAnimProperties def is_obj_animatable(obj: Object) -> bool: - if obj.type == "ARMATURE" or (obj.type == "MESH" and obj.geo_cmd_static in animatableBoneTypes): + if obj.type == "ARMATURE" or (obj.type == "MESH" and obj.geo_cmd_static == "DisplayListWithOffset"): return True return False @@ -56,12 +56,12 @@ def get_anim_owners(obj: Object): if children is None: return for child in children: - if child.geo_cmd_static in animatableBoneTypes: + if child.geo_cmd_static == "DisplayListWithOffset": raise PluginError("Cannot have child mesh with animation, use an armature") check_children(child.children) if obj.type == "MESH": # Object will be treated as a bone - if obj.geo_cmd_static in animatableBoneTypes: + if obj.geo_cmd_static == "DisplayListWithOffset": check_children(obj.children) return [obj] else: @@ -81,7 +81,7 @@ def get_anim_owners(obj: Object): bones_to_process = bones_to_process[1:] # Only handle 0x13 bones for animation - if current_bone.geo_cmd in animatableBoneTypes: + if is_bone_animatable(current_bone): anim_bones.append(current_pose_bone) # Traverse children in alphabetical order. diff --git a/fast64_internal/sm64/custom_cmd/__init__.py b/fast64_internal/sm64/custom_cmd/__init__.py new file mode 100644 index 0000000..88d0133 --- /dev/null +++ b/fast64_internal/sm64/custom_cmd/__init__.py @@ -0,0 +1,12 @@ +from .properties import props_register, props_unregister +from .operators import operators_register, operators_unregister + + +def custom_cmd_register(): + props_register() + operators_register() + + +def custom_cmd_unregister(): + props_unregister() + operators_unregister() diff --git a/fast64_internal/sm64/custom_cmd/exporting.py b/fast64_internal/sm64/custom_cmd/exporting.py new file mode 100644 index 0000000..14ec389 --- /dev/null +++ b/fast64_internal/sm64/custom_cmd/exporting.py @@ -0,0 +1,406 @@ +import dataclasses +import math +import operator +import struct +import ast +from io import StringIO +from typing import Iterable, NamedTuple, Optional, TypeVar, Union + +from ...utility import ( + PluginError, + get_clean_color, + quantize_color, + cast_integer, + to_s16, + cast_integer, + encodeSegmentedAddr, +) + +from ..sm64_constants import SegmentData +from ..sm64_geolayout_utility import BaseDisplayListNode + +from .utility import getDrawLayerName + +BIT_COUNTS = {"CHAR": 8, "SHORT": 16, "INT": 32, "LONG": 64, "FLOAT": 32, "DOUBLE": 64} + +T = TypeVar("T") + + +def flatten(iterable: Iterable[T]) -> tuple[T]: + if not isinstance(iterable, Iterable) or isinstance(iterable, str): + return (iterable,) + flat = [] + for x in iterable: + if isinstance(x, Iterable): + flat.extend(flatten(x)) + else: + flat.append(x) + return tuple(flat) + + +bin_ops = { + 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.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, + ast.Not: operator.not_, + ast.NotEq: operator.ne, + ast.And: operator.and_, + ast.Or: operator.or_, + ast.In: operator.contains, + ast.NotIn: lambda a, b: not operator.contains(a, b), + ast.Is: operator.is_, + ast.IsNot: operator.is_not, + ast.Eq: operator.eq, + ast.Lt: operator.lt, + ast.LtE: operator.le, + ast.Gt: operator.gt, + ast.GtE: operator.ge, + ast.Invert: operator.invert, +} + +builtins_map = { + "round": round, + "abs": abs, + "tuple": tuple, + "list": list, + "set": set, + "dict": dict, + "len": len, + "range": range, + "min": min, + "max": max, + "sum": sum, + "sorted": sorted, + "all": all, + "any": any, + "enumerate": enumerate, + "flatten": flatten, + "cast_integer": cast_integer, +} +collection_constructors = {ast.List: list, ast.Tuple: tuple, ast.Set: set} + + +def math_eval(s, start_scope: dict[str, object] | None = None): + if start_scope is None: + start_scope = {} + if isinstance(s, int): + return s + + s = s.strip() + node = ast.parse(s, mode="eval") + + def _eval(node: ast.expr, scope: dict[str, object]): + scope = scope.copy() + + def eval_comprehension(elt_node: ast.expr, generators: list[ast.comprehension], scope: dict[str, object]): + if not generators: + result = [_eval(elt_node, scope)] + else: + result = [] + first_comp, rest_comps = generators[0], generators[1:] + for value in _eval(first_comp.iter, scope): + new_scope = scope.copy() + if isinstance(first_comp.target, ast.Name): + new_scope[first_comp.target.id] = value + elif isinstance(first_comp.target, (ast.Tuple, ast.List, ast.Set)): + for i, elt in enumerate(first_comp.target.elts): + new_scope[elt.id] = value[i] + if all(_eval(if_node, new_scope) for if_node in first_comp.ifs): + sub_results = eval_comprehension(elt_node, rest_comps, new_scope) + result.extend(sub_results) + return result + + if isinstance(node, ast.Name): + if node.id in scope: + return scope[node.id] + elif hasattr(math, node.id): + return getattr(math, node.id) + else: + return builtins_map.get(node.id, node.id) + elif isinstance(node, ast.Constant): + return node.value + elif isinstance(node, ast.UnaryOp): + return bin_ops[type(node.op)](_eval(node.operand, scope)) + elif isinstance(node, ast.BinOp): + return bin_ops[type(node.op)](_eval(node.left, scope), _eval(node.right, scope)) + elif isinstance(node, ast.Call): + args = [_eval(x, scope) for x in node.args] + funcName = _eval(node.func, scope) + return funcName(*args) + elif isinstance(node, ast.ListComp): + return eval_comprehension(node.elt, node.generators, scope) + elif isinstance(node, ast.SetComp): + return set(eval_comprehension(node.elt, node.generators, scope)) + elif isinstance(node, ast.GeneratorExp): + return eval_comprehension(node.elt, node.generators, scope) + elif isinstance(node, tuple(collection_constructors.keys())): + return collection_constructors[type(node)](_eval(x, scope) for x in node.elts) + elif isinstance(node, ast.Expression): + return _eval(node.body, scope) + elif isinstance(node, ast.Subscript): + return _eval(node.value, scope)[_eval(node.slice, scope)] + elif isinstance(node, ast.Slice): + lower, upper, step = 0, None, None + if node.lower is not None: + lower = _eval(node.lower, scope) + if node.upper is not None: + upper = _eval(node.upper, scope) + if node.step is not None: + step = _eval(node.step, scope) + return slice(lower, upper, step) + elif isinstance(node, ast.IfExp): + if _eval(node.test, scope): + return _eval(node.body, scope) + else: + return _eval(node.orelse, scope) + elif isinstance(node, ast.Compare): + left = _eval(node.left, scope) + for op, right in zip(node.ops, node.comparators): + right = _eval(right, scope) + if not bin_ops[type(op)](left, right): + return False + left = right + return True + else: + raise Exception(f"Unsupported AST node: {ast.dump(node)}") + + return _eval(node.body, start_scope) + + +class ArgExport(NamedTuple): + value: float | int | bool | str + bit_count: int = 32 + signed: bool = True + + +@dataclasses.dataclass +class CustomCmd(BaseDisplayListNode): + data: dict + draw_layer: int | str | None = 0 + hasDL: bool = False + dlRef: str = None + name: str = "" + bleed_independently: bool = False + fMesh: "FMesh" = None + DLmicrocode: Union["GfxList", None] = None + # exists to get the override DL from an fMesh + override_hash: tuple | None = None + + def __post_init__(self): + self.hasDL &= self.data.get("dl_option") != "NONE" + self.group_children = self.data.get("group_children", True) + + @property + def drawLayer(self): + """HACK: drawLayer's default is usually per bone/object, but in the custom cmd system defaults are per argument. + We instead store a layer that can be none, and set it to a real value if the setter is called. + """ + if self.draw_layer is None: + return 0 + return self.draw_layer + + @drawLayer.setter + def drawLayer(self, value): + self.draw_layer = value + + @property + def args(self): + yield from self.data["args"] + if self.hasDL and "dl_command" in self.data: + yield {"name": "Displaylist", "arg_type": "DL"} + + def do_export_checks(self, children_count: int): + name = "" or self.data.get("name") or self.data.get("str_cmd") + name = f" ({name})" if name else "" + children_requirements = self.data.get("children_requirements", "ANY") + if children_requirements == "MUST" and children_count == 0: + raise PluginError(f"Command{name} must have at least one child node") + elif children_requirements == "NONE" and children_count > 0: + raise PluginError(f"Command{name} must have no children") + if self.data.get("dl_option") == "REQUIRED": + if self.DLmicrocode is None: + raise PluginError(f"Command{name} requires a displaylist") + + def to_arg(self, data: dict, binary=False) -> Iterable[ArgExport]: + def run_eval(value, bit_count=32, signed=True): + if ( + (not self.data["skip_eval"] or binary) + and isinstance(value, (int, float, complex, tuple, list)) + and (not isinstance(value, bool) or binary) + and "eval_expression" in data + ): + evaluated = math_eval(data["eval_expression"], {"x": value}) + yield from tuple(ArgExport(x, bit_count, signed) for x in flatten(evaluated)) + else: + yield from tuple(ArgExport(x, bit_count, signed) for x in flatten(value)) + + arg_type = data.get("arg_type") + round_to_sm64 = data.get("round_to_sm64", True) + match arg_type: + case "COLOR": + if round_to_sm64: + bit_counts = data.get("color_bits", (8, 8, 8, 8)) + color = get_clean_color(data["color"], True, False, True) + yield from run_eval(quantize_color(color, bit_counts), sum(bit_counts), False) + else: + yield from run_eval(get_clean_color(data["color"], True, True, True), 32, False) + case "PARAMETER": + if binary: + value = math_eval(data["parameter"], {}) + if isinstance(value, str): + raise PluginError("Strings not supported in binary") + yield from run_eval(value) + else: + yield from run_eval(data["parameter"]) + case "ENUM": + if data["enum"] >= len(data["enum_options"]): + option = {"int_value": 0, "str_value": "INVALID"} + else: + option = data["enum_options"][data["enum"]] + if binary: + yield from run_eval(option["int_value"]) + else: + yield from run_eval(option["str_value"]) + case "LAYER": + layer = data["layer"] if self.draw_layer is None or not data.get("inherit", True) else self.draw_layer + if binary: + layer = int(data["layer"]) + if "dl_command" in self.data: + layer = (1 << 7) | layer + yield from run_eval(layer, 8, False) + else: + yield from run_eval(getDrawLayerName(layer)) + case "BOOLEAN": + yield from run_eval(data["boolean"], 8) + case "NUMBER": + yield from run_eval(data["value"], 32) + case "TRANSLATION": + translation = data["translation"] + if round_to_sm64: + yield from run_eval(tuple(round(x) for x in translation), 16) + else: + yield from run_eval(tuple(x for x in translation), 32) + case "SCALE" | "MATRIX": + scale_matrix = data.get(arg_type.lower()) + if round_to_sm64 and arg_type == "SCALE": + yield from run_eval(round(scale_matrix * 0x10000)) + yield from run_eval(scale_matrix) + case "ROTATION": + rot_type = data["rot_type"] + rot = data.get(rot_type.lower()) + if round_to_sm64 and rot_type == "EULER": + yield from run_eval(tuple(to_s16(round(x)) for x in rot), 16) + else: + yield from run_eval(rot, 32) + case "DL": + has_dl, dl_ref = self.hasDL, self.dlRef + self.hasDL, self.dlRef = True, (data.get("dl") or None) + if binary: + yield from run_eval(self.get_dl_address(), 32) + else: + yield from run_eval(self.get_dl_name(), 32) + self.hasDL, self.dlRef = has_dl, dl_ref + case _: + raise PluginError(f"Unknown arg type {arg_type}") + + def to_c(self, depth: int = 0, max_length: int = 150) -> str: + data = StringIO() + dl_command = self.data.get("dl_command") + data.write(dl_command if dl_command is not None and self.hasDL else self.data["str_cmd"]) + data.write("(") + groups = [] + for i, arg_data in enumerate(self.args): + group = [] + try: + for value, _, _ in self.to_arg(arg_data): + if value is None: + value = "NULL" + elif isinstance(value, bool): + value = str(value).upper() + group.append(str(value)) + group_str = ", ".join(group) + if "name" in arg_data and arg_data["name"]: + group_str = f"/*{arg_data['name']}*/ {group_str}" + groups.append(group_str) + except Exception as exc: + raise PluginError(f'Failed to export arg "{arg_data.get("name", f"Arg {i}")}": {exc}') from exc + + if len("".join(groups)) > max_length: + separator = ",\n" + ("\t" * (depth + 1)) + data.write(separator.join(groups)) + else: + data.write(", ".join(groups)) + + data.write(")") + return data.getvalue() + + def to_binary_groups(self, segment_data: Optional[SegmentData] = None): + groups = [] + groups.append(("Command Index (𝗔𝘂𝘁𝗼𝗺𝗮𝘁𝗶𝗰)", self.data["int_cmd"].to_bytes(1, "big"))) + for i, arg_data in enumerate(self.args): + name = arg_data.get("name", f"Arg {i}") + try: + group = bytearray(0) + for value, bit_count, signed in self.to_arg(arg_data, True): + if value is None: + value = 0 + signed = arg_data.get("signed", signed) + if "value_type" in arg_data: + bit_count = BIT_COUNTS[arg_data["value_type"]] + if arg_data["value_type"] in {"FLOAT", "DOUBLE"}: + value = float(value) + else: + value = int(value) + if arg_data.get("seg_addr", False) and segment_data is not None: + value = encodeSegmentedAddr(value, segment_data) + if isinstance(value, bytes): + group += value + elif isinstance(value, float): + group += struct.pack("f" if bit_count == 32 else "d", value) + elif isinstance(value, int): + value = cast_integer(value, bit_count, signed) + group += value.to_bytes(math.ceil(bit_count / 8), "big", signed=signed) + else: + raise PluginError(f"{type(value)} not supported in binary") + groups.append((name, group)) + except Exception as exc: + raise PluginError(f'Failed to export arg "{name}": \n{exc}') from exc + + size = sum(len(data) for _, data in groups) + padding = size % 4 + if padding != 0: + groups.append(("Trailing Padding (𝗔𝘂𝘁𝗼𝗺𝗮𝘁𝗶𝗰)", bytes(4 - padding))) + return groups + + def to_binary(self, segment_data: Optional[SegmentData] = None): + return bytearray(b for _, data in self.to_binary_groups(segment_data) for b in data) + + def size(self, segment_data: Optional[SegmentData] = None): + return sum(len(data) for _, data in self.to_binary_groups(segment_data)) + + def get_ptr_offsets(self): + return [] + + def to_text_dump(self, segment_data: Optional[SegmentData] = None): + data = StringIO() + data.write(f"Size: {self.size(segment_data)} bytes.") + if segment_data is None: + data.write("\nNo segment range provided, won't encode to a respective segment") + for name, bytes in self.to_binary_groups(segment_data): + bytes_str = ", ".join(f"0x{byte:02x}" for byte in bytes) + if name: + data.write(f'\n\t"{name}": {bytes_str}') + else: + data.write(f"\n\t{bytes_str}") + return data.getvalue() diff --git a/fast64_internal/sm64/custom_cmd/operators.py b/fast64_internal/sm64/custom_cmd/operators.py new file mode 100644 index 0000000..254096b --- /dev/null +++ b/fast64_internal/sm64/custom_cmd/operators.py @@ -0,0 +1,182 @@ +from typing import TYPE_CHECKING, Iterable + +from bpy.utils import register_class, unregister_class +from bpy.props import StringProperty, IntProperty, EnumProperty +from bpy.types import Context, Scene + +from ...operators import OperatorBase, CollectionOperatorBase, SearchEnumOperatorBase +from ...utility import PluginError + +from .utility import custom_cmd_preset_update, duplicate_name, get_custom_cmd_preset_enum, get_custom_prop + +if TYPE_CHECKING: + from .properties import SM64_CustomCmdProperties, SM64_CustomArgProperties, SM64_CustomEnumProperties + + +def get_conf_type(context: Context): + custom = get_custom_prop(context).custom + return "PRESET_EDIT" if custom is None or custom.preset != "NONE" else "NO_PRESET" + + +class SM64_CustomCmdOps(CollectionOperatorBase): + bl_idname = "scene.sm64_custom_cmd_ops" + bl_label = "" + bl_options = {"UNDO"} + object_name = "custom command preset" + + index: IntProperty(default=-1) + op_name: StringProperty() + example_name: StringProperty(default="") + + @classmethod + def description(cls, context: Context, properties: dict) -> str: + op_name: str = properties.get("op_name", "") + if op_name == "COPY_EXAMPLE": + return "Copy example defines" + return super().description(context, properties) + + @classmethod + def collection(cls, context: Context, op_values: dict) -> Iterable["SM64_CustomCmdProperties"]: + return context.scene.fast64.sm64.custom_cmds + + def execute_operator(self, context): + presets = context.scene.fast64.sm64.custom_cmds + custom, owner = get_custom_prop(context) + conf_type = get_conf_type(context) + match self.op_name: + case "ADD": + presets.add() + new_preset: "SM64_CustomCmdProperties" = presets[-1] + old_preset: "SM64_CustomCmdProperties" | None = None + if self.index == -1: + if custom is not None: + old_preset = custom + else: + old_preset = presets[self.index] + + if old_preset is not None: + new_preset.from_dict(old_preset.to_dict(conf_type, owner, include_defaults=True), set_defaults=True) + old_name = old_preset.name + else: + old_name = None + existing_names = {preset.name for preset in presets if preset != new_preset} + new_preset.name = duplicate_name(new_preset.name, existing_names, old_name) + new_preset.tab = True + if self.index != -1: + presets.move(len(presets) - 1, self.index + 1) + if custom is not None: + custom.preset = new_preset.name + for area in context.screen.areas: # HACK: redraw everything + area.tag_redraw() + case "REMOVE": + presets.remove(self.index) + case "COPY_EXAMPLE": + preset = presets[self.index] if custom is None else custom + context.window_manager.clipboard = preset.get_examples(owner, conf_type)[self.example_name][1] + case _: + raise NotImplementedError(f'Unimplemented internal custom command preset op "{self.op_name}"') + custom_cmd_preset_update(self, context) + + +def get_args(context: Context, command_index: int) -> Iterable["SM64_CustomArgProperties"]: + owner = get_custom_prop(context).owner + if isinstance(owner, Scene): + return context.scene.fast64.sm64.custom_cmds[command_index].args + elif owner is not None: + return owner.fast64.sm64.custom.args + else: + raise PluginError("Invalid context") + + +class SM64_CustomArgsOps(CollectionOperatorBase): + bl_idname = "scene.sm64_custom_args_ops" + bl_label = "" + bl_options = {"UNDO"} + object_name = "arg" + + command_index: IntProperty(default=0) # for scene command presets + + @classmethod + def collection(cls, context: Context, op_values: dict): + return get_args(context, op_values.get("command_index", 0)) + + @classmethod + def description(cls, context: Context, properties: dict) -> str: + op_name: str = properties.get("op_name", "") + if op_name == "COPY_EXAMPLE": + return "Copy example enum list" + return super().description(context, properties) + + def add(self, context: Context, collection: Iterable["SM64_CustomArgProperties"]): + old, new = super().add(context, collection) + old_name = None + if old is not None: + old_name = old.name + new.from_dict( + old.to_dict(get_conf_type(context), owner=get_custom_prop(context).owner, include_defaults=True), + set_defaults=True, + ) + existing_names = {arg.name for arg in collection if arg != new} + new.name = duplicate_name(new.name, existing_names, old_name) + + def copy_example(self, context: Context, collection: Iterable["SM64_CustomArgProperties"]): + """Copy example of enum list to clipboard""" + arg: "SM64_CustomArgProperties" = collection[self.index] + context.window_manager.clipboard = arg.get_enum_list_example() + + def execute_operator(self, context: Context): + super().execute_operator(context) + custom_cmd_preset_update(self, context) + + +class SM64_CustomEnumOps(CollectionOperatorBase): + bl_idname = "scene.sm64_custom_enum_ops" + bl_label = "" + bl_options = {"UNDO"} + object_name = "enum option" + + command_index: IntProperty(default=0) # for scene command presets + arg_index: IntProperty(default=0) + + @classmethod + def collection(cls, context: Context, op_values: dict) -> Iterable["SM64_CustomEnumProperties"]: + args = get_args(context, op_values.get("command_index", 0)) + return args[op_values.get("arg_index", 0)].enum_options + + def add(self, context: Context, collection: Iterable["SM64_CustomArgProperties"]): + old, new = CollectionOperatorBase.add(self, context, collection) + old_name = None + if old is not None: + old_name = old.name + new.from_dict(old.to_dict()) + existing_names = {enum.name for enum in collection if enum != new} + new.name = duplicate_name(new.name, existing_names, old_name) + + +class SM64_SearchCustomCmds(SearchEnumOperatorBase): + bl_idname = "scene.sm64_search_custom_cmds" + bl_label = "Search Custom Commands" + bl_options = {"REGISTER", "UNDO"} + bl_property = "preset" + preset: EnumProperty(items=get_custom_cmd_preset_enum) + + def update_enum(self, context): + context.object.fast64.sm64.custom.preset = self.preset + + +classes = ( + SM64_CustomEnumOps, + SM64_CustomArgsOps, + SM64_CustomCmdOps, + SM64_SearchCustomCmds, +) + + +def operators_register(): + for cls in classes: + register_class(cls) + + +def operators_unregister(): + for cls in reversed(classes): + unregister_class(cls) diff --git a/fast64_internal/sm64/custom_cmd/properties.py b/fast64_internal/sm64/custom_cmd/properties.py new file mode 100644 index 0000000..bc89162 --- /dev/null +++ b/fast64_internal/sm64/custom_cmd/properties.py @@ -0,0 +1,1133 @@ +import math +from typing import TYPE_CHECKING, Optional +from io import StringIO +import mathutils + +from bpy.utils import register_class, unregister_class +from bpy.props import ( + StringProperty, + IntProperty, + BoolProperty, + EnumProperty, + FloatProperty, + FloatVectorProperty, + IntVectorProperty, + CollectionProperty, + PointerProperty, +) +from bpy.types import Object, Bone, UILayout, Context, PropertyGroup + +from ...utility import ( + Matrix4x4Property, + PluginError, + draw_and_check_tab, + get_first_set_prop, + multilineLabel, + prop_split, + toAlnum, + upgrade_old_prop, +) +from ...f3d.f3d_material import sm64EnumDrawLayers + +from ..sm64_constants import MIN_S32, MAX_S32 + +from .exporting import CustomCmd +from .operators import SM64_CustomArgsOps, SM64_CustomCmdOps, SM64_CustomEnumOps, SM64_SearchCustomCmds +from .utility import ( + AvailableOwners, + CustomCmdConf, + better_round, + custom_cmd_preset_update, + duplicate_name, + get_custom_cmd_preset, + get_custom_cmd_preset_enum, + get_custom_prop, + get_transforms, +) + +if TYPE_CHECKING: + from ..settings.properties import SM64_Properties + + +def update_internal_number(self: "SM64_CustomNumberProperties", context: Context): + use_limits = True + custom, owner = get_custom_prop(context) + if owner is None: + return + if custom is not None: + use_limits = custom.preset != "NONE" + if not math.isclose(self.floating, self.get_new_number(use_limits), rel_tol=1e-7): + self.floating = self.get_new_number(use_limits) + if self.integer != better_round(self.get_new_number(use_limits)): + self.integer = better_round(self.get_new_number(use_limits)) + self.set_step_min_max(*self.step_min_max) + + +def update_internal_number_and_check_preset(self: "SM64_CustomArgProperties", context: Context): + update_internal_number(self, context) + custom_cmd_preset_update(self, context) + + +class SM64_CustomNumberProperties(PropertyGroup): + is_integer: BoolProperty(name="Is Integer", default=False, update=update_internal_number_and_check_preset) + floating: FloatProperty(name="Float", default=0.0, precision=5, update=update_internal_number) + integer: IntProperty(name="Integer", default=0, update=update_internal_number) + floating_step: FloatProperty(name="Step", default=0.0, update=update_internal_number_and_check_preset) + floating_min: FloatProperty( + name="Min", default=-math.inf, min=-math.inf, max=math.inf, update=update_internal_number_and_check_preset + ) + floating_max: FloatProperty( + name="Max", default=math.inf, min=-math.inf, max=math.inf, update=update_internal_number_and_check_preset + ) + integer_step: IntProperty(name="Step", default=1, update=update_internal_number_and_check_preset) + integer_min: IntProperty( + name="Min", + default=MIN_S32, + min=MIN_S32, + max=MAX_S32, + update=update_internal_number_and_check_preset, + ) + integer_max: IntProperty( + name="Max", + default=MAX_S32, + min=MIN_S32, + max=MAX_S32, + update=update_internal_number_and_check_preset, + ) + + @property + def step_min_max(self): + if self.is_integer: + return self.integer_step, self.integer_min, self.integer_max + else: + return self.floating_step, self.floating_min, self.floating_max + + def set_step_min_max(self, step: float, min_value: float, max_value: float): + for name, value in zip(("step", "min", "max"), (step, min_value, max_value)): + if getattr(self, f"integer_{name}") != better_round(value): + setattr(self, f"integer_{name}", better_round(value)) + if not math.isclose(getattr(self, f"floating_{name}"), value, rel_tol=1e-7): + setattr(self, f"floating_{name}", value) + + def get_new_number(self, skip_limits=False): + new_value = self.integer if self.is_integer else self.floating + if skip_limits: + step, min_value, max_value = self.step_min_max + if step == 0: + new_value = max(min_value, min(new_value, max_value)) + else: + if min_value > -math.inf: + new_value -= min_value # start value from min + step_count = new_value // step # number of steps for the closest value + new_value = step_count * step + if min_value > -math.inf: + new_value += min_value + new_value = max(min_value, min(new_value, max_value)) + if self.is_integer: + return int(new_value) + return new_value + + def to_dict(self, conf_type: CustomCmdConf = "PRESET_EDIT"): + data = {"is_integer": self.is_integer} + if conf_type == "PRESET_EDIT": + if self.is_integer: + data.update({"step": self.integer_step, "min": self.integer_min, "max": self.integer_max}) + else: + data.update({"step": self.floating_step, "min": self.floating_min, "max": self.floating_max}) + return data, {"value": self.get_new_number()} + + def from_dict(self, data: dict, defaults: dict, set_defaults=True): + self.is_integer = data.get("is_integer", False) + if set_defaults: + value = defaults.get("value", 0) + self.floating = value + self.integer = better_round(value) + self.set_step_min_max( + data.get("step", 1.0 if self.is_integer else 0), data.get("min", -math.inf), data.get("max", math.inf) + ) + + def draw_props(self, name_split: UILayout, layout: UILayout, conf_type: CustomCmdConf): + col = layout.column() + if conf_type != "PRESET": + col.prop(self, "is_integer") + name_split.prop(self, "integer" if self.is_integer else "floating", text="") + usual_steps = {0, 1} if self.is_integer else {0} + if conf_type != "PRESET_EDIT" and self.step_min_max[0] not in usual_steps: + col.label(text=f"Increments of {self.step_min_max[0]}") + col.separator(factor=0.5) + if conf_type == "PRESET_EDIT": + typ = "integer" if self.is_integer else "floating" + prop_split(col, self, f"{typ}_min", "Min") + prop_split(col, self, f"{typ}_max", "Max") + prop_split(col, self, f"{typ}_step", "Step") + + +class SM64_CustomEnumProperties(PropertyGroup): + name: StringProperty(name="Name", default="Enum Name", update=custom_cmd_preset_update) + description: StringProperty(name="Description", default="Description", update=custom_cmd_preset_update) + str_value: StringProperty(name="Value", default="ENUM_NAME", update=custom_cmd_preset_update) + int_value: IntProperty(name="Value", default=0, update=custom_cmd_preset_update) + + def enum_tuple(self, i: int): + return (str(i), self.name, self.description.replace("\\n", "\n")) + + def to_dict(self): + return { + "name": self.name, + "description": self.description.replace("\\n", "\n"), + "str_value": self.str_value, + "int_value": self.int_value, + } + + def from_dict(self, data: dict): + self.name, self.description = data.get("name", "Name"), data.get("description", "Description") + self.str_value, self.int_value = data.get("str_value", "ENUM_NAME"), data.get("int_value", 0) + + def draw_props(self, layout: UILayout, op_row: UILayout, is_binary=False): + op_row.prop(self, "name", text="") + layout.prop(self, "description") + prop_split(layout, self, "int_value" if is_binary else "str_value", "Value") + + +def can_have_mesh(owner: Optional[AvailableOwners]): + return (isinstance(owner, Object) and owner.type == "MESH") or isinstance(owner, Bone) or owner is None + + +class SM64_CustomArgProperties(PropertyGroup): + name: StringProperty(name="Name", default="Argument Name", update=custom_cmd_preset_update) + show_as_preset: BoolProperty( + default=True, description="Show argument when used as a preset", update=custom_cmd_preset_update + ) + arg_type: EnumProperty( + name="Type", + items=[ + ("PARAMETER", "Parameter", "Parameter"), + ("BOOLEAN", "Boolean", "Boolean"), + ("NUMBER", "Number", "Number"), + ("COLOR", "Color", "Color"), + ("ENUM", "Enum", "Enum"), + ("", "Transforms", ""), + ("TRANSLATION", "Translation", "Translation"), + ("ROTATION", "Rotation", "Rotation"), + ("SCALE", "Scale", "Scale"), + ("MATRIX", "Matrix", "3x3 Matrix"), + ("", "", ""), + ("LAYER", "Layer", "Layer"), + ("DL", "Displaylist", "Displaylist"), + ], + update=custom_cmd_preset_update, + ) + inherit: BoolProperty( + name="Inherit", description="Inherit arg from owner", default=True, update=custom_cmd_preset_update + ) + apply_scale: BoolProperty(name="Blender to SM64 Scale", default=True, update=custom_cmd_preset_update) + round_to_sm64: BoolProperty(name="Round to Conventional Units", update=custom_cmd_preset_update) + seg_addr: BoolProperty(name="Encode To Segmented Address", default=True, update=custom_cmd_preset_update) + value_type: EnumProperty( + items=[ + ("AUTO", "Auto Type", "Auto"), + ("", "", ""), + ("CHAR", "Char", "Char"), + ("SHORT", "Short", "Short"), + ("INT", "Int", "Int"), + ("LONG", "Long", "Long"), + ("", "", ""), + ("FLOAT", "Float", "Float"), + ("DOUBLE", "Double", "Double"), + ], + default="AUTO", + ) + signed: BoolProperty(name="Signed", default=True) + + color: FloatVectorProperty( + name="Color", + size=4, + min=0.0, + max=1.0, + subtype="COLOR", + default=(1.0, 1.0, 1.0, 1.0), + update=custom_cmd_preset_update, + ) + color_bits: IntVectorProperty( + name="Color Bits", + description="Bits per channel. RGBA", + size=4, + default=(5, 5, 5, 1), + min=0, + max=8, + update=custom_cmd_preset_update, + ) + parameter: StringProperty(name="Parameter", default="0") + boolean: BoolProperty(name="Boolean", default=True) + number: PointerProperty(type=SM64_CustomNumberProperties) + layer: EnumProperty(items=sm64EnumDrawLayers, default="1") + relative: BoolProperty(name="Use Relative Transformation", default=True, update=custom_cmd_preset_update) + rot_type: EnumProperty( + name="Rotation", + items=[ + ("EULER", "Euler (XYZ deg)", "Euler XYZ order, degrees"), + ("QUATERNION", "Quaternion", "Quaternion"), + ("AXIS_ANGLE", "Axis Angle", "Axis angle"), + ], + update=custom_cmd_preset_update, + ) + translation_scale: FloatVectorProperty(name="Translation", size=3, default=(0.0, 0.0, 0.0), subtype="XYZ") + euler: FloatVectorProperty(name="Rotation", size=3, default=(0.0, 0.0, 0.0), subtype="EULER") + order: EnumProperty( + items=[ + ("XYZ", "XYZ", "XYZ"), + ("XZY", "XZY", "XZY"), + ("YXZ", "YXZ", "YXZ"), + ("YZX", "YZX", "YZX"), + ("ZXY", "ZXY", "ZXY"), + ("ZYX", "ZYX", "ZYX"), + ], + update=custom_cmd_preset_update, + ) + quaternion: FloatVectorProperty(name="Quaternion", size=4, default=(1.0, 0.0, 0.0, 0.0), subtype="QUATERNION") + axis_angle: FloatVectorProperty(name="Axis Angle", size=4, default=((1.0), 0.0, 0.0, 0.0), subtype="AXISANGLE") + matrix: PointerProperty(type=Matrix4x4Property) + dl: StringProperty(name="Displaylist", default="breakable_box_seg8_dl_cork_box") + + enum_tab: BoolProperty(name="Enum Options", default=False) + enum_options: CollectionProperty(type=SM64_CustomEnumProperties, name="Options") + enum_option: EnumProperty( + name="Enum Option", + items=lambda self, _context: [e.enum_tuple(i) for i, e in enumerate(self.enum_options)] + or [("0", "Invalid", "Invalid")], + ) + + eval_expression: StringProperty( + name="Eval Expression", + default="", + description="Apply a limited math expression to the values of this argument group, as seen in scale nodes.\nLeave empty to skip this step", + update=custom_cmd_preset_update, + ) + + @property + def is_transform(self): + return self.arg_type in {"MATRIX", "TRANSLATION", "ROTATION", "SCALE"} + + @property + def modifable_value_type(self): + return self.arg_type not in {"LAYER", "DL"} + + @property + def can_be_signed(self): + return self.modifable_value_type and self.value_type not in {"FLOAT", "DOUBLE", "AUTO"} + + @property + def can_round_to_sm64(self): + return self.arg_type in {"TRANSLATION", "COLOR", "SCALE"} or ( + self.arg_type == "ROTATION" and self.rot_type == "EULER" + ) + + @property + def has_order(self): + return self.arg_type in {"TRANSLATION", "SCALE"} or (self.arg_type == "ROTATION" and self.rot_type == "EULER") + + def show_eval_expression(self, custom_cmd: "SM64_CustomCmdProperties", is_binary: bool): + if is_binary: + return True + if custom_cmd.skips_eval(is_binary): + return False + return self.arg_type not in {"PARAMETER", "BOOLEAN", "ENUM", "LAYER", "DL"} + + def can_inherit(self, owner: Optional[AvailableOwners]): + """Scene still includes all, the inherented property will be defaults, like identity matrix""" + valid_types = {"MATRIX", "TRANSLATION", "ROTATION"} + is_mesh = isinstance(owner, Object) and owner.type == "MESH" + if not isinstance(owner, Bone): + valid_types.add("SCALE") + if is_mesh or owner is None: + valid_types.add("LAYER") + if can_have_mesh(owner): + valid_types.add("DL") + return self.arg_type in valid_types + + def inherits(self, owner: Optional[AvailableOwners]): + return self.can_inherit(owner) and self.inherit + + def inherits_without_default(self, owner: Optional[AvailableOwners]): + """Inherits without a default, layers for example inherit but have a default in case of no geometry""" + return self.inherits(owner) and self.arg_type not in {"LAYER"} + + def modifable_inherit(self, owner: Optional[AvailableOwners]): + """Can be modified in presets, inherit becomes a default value therefor ignored by the hashing""" + return self.can_inherit(owner) and self.arg_type in {"DL"} + + def show_inherit_toggle(self, owner: Optional[AvailableOwners], conf_type: CustomCmdConf): + return (self.can_inherit(owner) and conf_type != "PRESET") or self.modifable_inherit(owner) + + def show_segmented_toggle(self, owner: Optional[AvailableOwners], conf_type: CustomCmdConf): + return ( + conf_type != "PRESET" + and ((not self.inherits(owner) or conf_type == "PRESET_EDIT") and self.arg_type in {"DL"}) + or (self.arg_type in {"PARAMETER"} and self.value_type in {"INT", "LONG"} and not self.signed) + ) + + def shows_name(self, owner: Optional[AvailableOwners]): + return not self.inherits_without_default(owner) or self.show_inherit_toggle(owner, "PRESET") + + def will_draw(self, owner: Optional[AvailableOwners], conf_type: CustomCmdConf): + return (self.shows_name(owner) and self.show_as_preset) or conf_type != "PRESET" + + def get_transform( + self, + owner: Optional[AvailableOwners], + world_matrix: Optional[mathutils.Matrix], + local_matrix: Optional[mathutils.Matrix], + blender_scale=1.0, + ): + inherit = self.inherits(owner) + if inherit: + world_matrix, local_matrix = world_matrix or mathutils.Matrix.Identity( + 4 + ), local_matrix or mathutils.Matrix.Identity(4) + matrix = local_matrix if self.relative else world_matrix + if not self.apply_scale: + blender_scale = 1.0 + match self.arg_type: + case "MATRIX": + matrix = matrix if inherit else self.matrix.to_matrix() + if blender_scale != 1.0: + trans, rot, scale = matrix.decompose() + matrix = ( + mathutils.Matrix.Translation(trans * blender_scale).to_4x4() + @ rot.to_matrix().to_4x4() + @ mathutils.Matrix.Diagonal(scale).to_4x4() + ) + return tuple(tuple(y for y in x) for x in matrix) + case "TRANSLATION": + return tuple( + getattr( + (matrix.to_translation() if inherit else mathutils.Vector(self.translation_scale)) + * blender_scale, + self.order.lower(), + ) + ) + case "ROTATION": + match self.rot_type: + case "EULER": + euler = matrix.to_euler(self.order) if inherit else mathutils.Euler(self.euler, self.order) + return tuple(math.degrees(x) for x in euler) + case "QUATERNION": + return tuple((matrix.to_quaternion() if inherit else self.quaternion)) + case "AXIS_ANGLE": + quat = ( + matrix.to_quaternion() + if inherit + else mathutils.Quaternion(self.axis_angle[:3], self.axis_angle[3]) + ) + axis, angle = quat.to_axis_angle() + return tuple((tuple(axis), math.degrees(angle))) + case "SCALE": + scale = getattr( + matrix.to_scale() if inherit else mathutils.Vector(self.translation_scale), self.order.lower() + ) + if self.round_to_sm64: + return sum(x for x in scale) / 3 + return tuple(scale) + + def to_dict( + self, + conf_type: CustomCmdConf, + owner: Optional[AvailableOwners] = None, + world_matrix: Optional[mathutils.Matrix] = None, + local_matrix: Optional[mathutils.Matrix] = None, + blender_scale=1.0, + include_defaults=True, + is_export=False, + ): + data = {} + defaults = {} + if conf_type != "PRESET" or is_export: + if conf_type != "NO_PRESET": + data["name"] = self.name + data["show_as_preset"] = self.show_as_preset + data["arg_type"] = self.arg_type + if self.modifable_inherit(owner): + defaults["inherit"] = self.inherit + elif self.can_inherit(owner): + data["inherit"] = self.inherit + if self.eval_expression: + data["eval_expression"] = self.eval_expression + if self.modifable_value_type and self.value_type != "AUTO": + data["value_type"] = self.value_type + if self.can_be_signed: + data["signed"] = self.signed + if self.show_segmented_toggle(owner, conf_type): + data["seg_addr"] = self.seg_addr + if self.can_round_to_sm64: + data["round_to_sm64"] = self.round_to_sm64 + if self.has_order: + data["order"] = self.order + match self.arg_type: + case "NUMBER": + number_data, number_defaults = self.number.to_dict(conf_type) + defaults.update(number_defaults) + data.update(number_data) + case "ENUM": + defaults["enum"] = int(self.enum_option) + data["enum_options"] = tuple(option.to_dict() for option in self.enum_options) + case "COLOR": + defaults["color"] = tuple(self.color) + if self.round_to_sm64: + data["color_bits"] = tuple(self.color_bits) + case _: + name = self.arg_type.lower() + if self.is_transform: + data["relative"] = self.relative + data["apply_scale"] = self.apply_scale and self.arg_type in {"MATRIX", "TRANSLATION"} + if self.arg_type == "ROTATION": + data["rot_type"] = self.rot_type + if self.arg_type == "ROTATION": + name = self.rot_type.lower() + defaults[name] = self.get_transform(owner, world_matrix, local_matrix, blender_scale=blender_scale) + elif (not self.inherits_without_default(owner) or conf_type == "PRESET_EDIT") and hasattr(self, name): + defaults[name] = getattr(self, name) + if defaults and include_defaults: + if conf_type == "PRESET_EDIT" and not is_export: + data["defaults"] = defaults + else: + data.update(defaults) + return data + + def from_dict(self, data: dict, index=0, set_defaults=False): + defaults = data.get("defaults") + if not defaults: + defaults = data + self.name = data.get("name", f"Arg {index}") + self.show_as_preset = data.get("show_as_preset", True) + self.arg_type = data.get("arg_type", "PARAMETER") + self.inherit = data.get("inherit", True) + self.eval_expression = data.get("eval_expression", "") + self.value_type = data.get("value_type", "AUTO") + self.signed = data.get("signed", True) + self.seg_addr = data.get("seg_addr", True) + self.relative = data.get("relative", True) + self.apply_scale = data.get("apply_scale", True) + self.round_to_sm64 = data.get("round_to_sm64", False) + self.rot_type = data.get("rot_type", "EULER") + self.order = data.get("order", "XYZ") + self.enum_options.clear() + for option in data.get("enum_options", []): + self.enum_options.add() + self.enum_options[-1].from_dict(option) + self.color_bits = data.get("color_bits", (8, 8, 8, 8)) + self.number.from_dict(data, defaults, set_defaults) + if not set_defaults: + return + self.enum_option = str(defaults.get("enum", 0)) + if "scale" in defaults and self.round_to_sm64: + self.translation_scale = [defaults.get("scale", 0)] * 3 + else: + self.translation_scale = defaults.get("translation", None) or defaults.get("scale", None) or [0, 0, 0] + self.euler = [math.radians(x) for x in defaults.get("euler", [0, 0, 0])] + self.quaternion = defaults.get("quaternion", [1, 0, 0, 0]) + axis_angle = defaults.get("axis_angle", [[0, 0, 0], 0]) + self.axis_angle = (*axis_angle[0], math.radians(axis_angle[1])) + if "matrix" in defaults: + self.matrix.from_matrix(defaults.get("matrix")) + else: + self.matrix.from_matrix(mathutils.Matrix.Identity(4)) + for prop in ["color", "parameter", "layer", "boolean", "dl"]: + setattr(self, prop, defaults.get(prop, getattr(self, prop))) + + def example_macro_args( + self, cmd_prop: "SM64_CustomCmdProperties", previous_arg_names: set[str], conf_type: CustomCmdConf = "NO_PRESET" + ): + def add_name(args: list[str]): + name = self.name + if not name or (cmd_prop.preset == "NONE" and conf_type == "NO_PRESET"): + name = self.arg_type.lower() + name = duplicate_name(name, previous_arg_names) + previous_arg_names.add(name) + return ", ".join(toAlnum(name + arg).lower() for arg in args) + + if self.has_order: + if self.arg_type == "SCALE" and self.round_to_sm64: + return add_name(("",)) + return add_name(tuple(f"_{x}" for x in self.order.lower())) + elif self.arg_type == "ROTATION": + return add_name( + {"QUATERNION": ("_w", "_x", "_y", "_z"), "AXIS_ANGLE": ("_x", "_y", "_z", "_a")}[self.rot_type] + ) + + match self.arg_type: + case "MATRIX": + return add_name(tuple(f"_{x}_{y}" for x in range(4) for y in range(4))) + case "COLOR": + if self.round_to_sm64: + return add_name(("",)) + return add_name(("_r", "_g", "_b", "_a")) + case "PARAMETER" | "LAYER" | "BOOLEAN" | "NUMBER" | "DL" | "ENUM": + return add_name(("",)) + case _: + raise PluginError(f"Unknown arg type {self.arg_type}") + + def draw_transforms( + self, + name_split: UILayout, + inherit_info: UILayout, + layout: UILayout, + owner: Optional[AvailableOwners], + conf_type: CustomCmdConf = "NO_PRESET", + ): + col = layout.column() + inherit = self.inherits(owner) + if conf_type != "PRESET": + if inherit: + col.prop(self, "relative") + if self.arg_type == "ROTATION": + prop_split(col, self, "rot_type", "Rotation Type") + if self.arg_type in {"TRANSLATION", "MATRIX"}: + col.prop(self, "apply_scale") + if self.has_order: + prop_split(col, self, "order", "Order") + force_scale = conf_type == "PRESET_EDIT" and self.arg_type == "SCALE" + if inherit and force_scale: + inherit_info.label(text="Not supported in bones.", icon="INFO") + if not inherit or force_scale: + if self.arg_type in {"TRANSLATION", "SCALE"}: + name_split.prop(self, "translation_scale", text="") + elif self.arg_type == "ROTATION": + name_split.prop(self, self.rot_type.lower(), text="") + elif self.arg_type == "MATRIX": + self.matrix.draw_props(col) + + def get_enum_list_example(self): + macro_define = StringIO() + fixed_name = toAlnum(self.name) + if self.name: + macro_define.write(f"// {self.name}'s enums\n") + macro_define.write(f"enum {fixed_name.replace('_', '')} {{\n") + else: + macro_define.write("enum {{\n") + for option in self.enum_options: + macro_define.write(f"\t{option.str_value} = {option.int_value},\n") + if self.name: + macro_define.write(f"\t{fixed_name.upper()}_COUNT\n") + else: + macro_define.write("\tCOUNT\n") + macro_define.write("};") + return macro_define.getvalue() + + def draw_enum( + self, + name_split: UILayout, + layout: UILayout, + command_index: int, + arg_index: int, + conf_type: CustomCmdConf = "NO_PRESET", + is_binary=False, + ): + name_split.prop(self, "enum_option", text="") + if conf_type == "PRESET": + return + col = layout.column() + options_box = col.box().column() + if not draw_and_check_tab(options_box, self, "enum_tab"): + return + SM64_CustomEnumOps.draw_row(options_box.row(), -1, command_index=command_index, arg_index=arg_index) + option: SM64_CustomEnumProperties + for i, option in enumerate(self.enum_options): + op_row = options_box.row() + option.draw_props(options_box, op_row, is_binary) + SM64_CustomEnumOps.draw_row(op_row, i, command_index=command_index, arg_index=arg_index) + + col.separator() + box = col.box().column() + multilineLabel(box, self.get_enum_list_example().replace("\t", " " * 5)) + SM64_CustomArgsOps.draw_props( + box, + "COPYDOWN", + "Copy Enum List Example", + op_name="COPY_EXAMPLE", + command_index=command_index, + ) + + def draw_props( + self, + arg_row: UILayout, + layout: UILayout, + owner: Optional[AvailableOwners], + custom_cmd: "SM64_CustomCmdProperties", + command_index: int, + arg_index: int, + conf_type: CustomCmdConf = "NO_PRESET", + is_binary=False, + ): + inherit = self.inherits(owner) + col = layout.column() + + if conf_type != "NO_PRESET": + name_split = col.split(factor=0.5) + if conf_type == "PRESET" and self.shows_name(owner) and self.name != "": + name_split.label(text=self.name) + elif conf_type == "PRESET_EDIT": + name_row = name_split.row() + name_row.prop(self, "show_as_preset", text="") + name_row.prop(self, "name", text="") + else: + name_split = col + + if conf_type != "PRESET": + arg_row.prop(self, "arg_type", text="") + if self.can_round_to_sm64: + col.prop(self, "round_to_sm64") + + inherit_info = col + if self.show_inherit_toggle(owner, conf_type): + if conf_type == "PRESET": + inherit_info = name_split + name_split = col + inherit_info = inherit_info.row() + inherit_info.alignment = "LEFT" + inherit_info.prop(self, "inherit") + + match self.arg_type: + case "NUMBER": + self.number.draw_props(name_split, col, conf_type) + case "ENUM": + self.draw_enum(name_split, col, command_index, arg_index, conf_type, is_binary) + case "LAYER" | "DL": + if inherit and conf_type == "PRESET_EDIT": + inherit_info.label(text="Not supported in object empties.", icon="INFO") + if not inherit or conf_type == "PRESET_EDIT": + name_split.prop(self, self.arg_type.lower(), text="") + case "COLOR": + name_split.prop(self, "color", text="") + quantize_split = col.row() + if self.round_to_sm64: + quantize_split.prop(self, "color_bits", text="") + case _: + if self.is_transform: + self.draw_transforms(name_split, inherit_info, col, owner, conf_type) + elif hasattr(self, self.arg_type.lower()): + name_split.prop(self, self.arg_type.lower(), text="") + + if conf_type != "PRESET": + if self.show_eval_expression(custom_cmd, is_binary): + prop_split(col, self, "eval_expression", "Expression") + if is_binary: + if self.modifable_value_type: + type_split = col.row() + type_split.prop(self, "value_type", text="") + if self.can_be_signed: + type_split.prop(self, "signed") + if self.show_segmented_toggle(owner, conf_type): + col.prop(self, "seg_addr") + + +def custom_cmd_change_preset(self: "SM64_CustomCmdProperties", context: Context): + if self.preset == "NONE": + return + preset_cmd = get_custom_cmd_preset(self, context) + if preset_cmd is None: + self.preset = "NONE" + return + self.saved_hash = "" + self.from_dict(preset_cmd.to_dict("PRESET_EDIT", None, include_defaults=True), set_defaults=True) + self.saved_hash = self.preset_hash + custom_cmd_preset_update(self, context) + + +class SM64_CustomCmdProperties(PropertyGroup): + version: IntProperty(name="SM64_CustomCmdProperties Version", default=0) + + tab: BoolProperty(default=False) + preset: EnumProperty(items=get_custom_cmd_preset_enum, update=custom_cmd_change_preset) + name: StringProperty(name="Name", default="Custom Command Name", update=custom_cmd_preset_update) + cmd_type: EnumProperty( + name="Type", + items=[ + ("Level", "Level", "Level script Command"), + ("Geo", "Geo", "Geolayout Command"), + ("Collision", "Collision", "Collision Command"), + ], + update=custom_cmd_preset_update, + ) + str_cmd: StringProperty(name="Command", default="CUSTOM_CMD", update=custom_cmd_preset_update) + int_cmd: IntProperty(name="Command", default=0, update=custom_cmd_preset_update) + skip_eval: BoolProperty( + name="Skip Eval", + description="Skip evaluating values outside binary", + default=True, + update=custom_cmd_preset_update, + ) + + # Geo + children_requirements: EnumProperty( + name="Children Requirements", + items=[ + ("ANY", "None", "No requirements"), + ("", "", ""), + ("MUST", "Must Have Children", "Must have at least one child node"), + ("NONE", "No Children", "Must have no children nodeS"), + ], + update=custom_cmd_preset_update, + ) + group_children: BoolProperty( + name="Group Children", + description="Use GEO_OPEN/CLOSE_NODE to group the node's children", + default=True, + update=custom_cmd_preset_update, + ) + dl_option: EnumProperty( + name="DL Option", + items=[ + ("NONE", "None", "No geometry will be inherited, deform will be off in bones"), + ("", "", ""), + ( + "OPTIONAL", + "Optional", + "Can inherit geometry, or will use a NULL value, also allows the use of dl ext commands like GEO_TRANSLATE/GEO_TRANSLATE_WITH_DL", + ), + ("REQUIRED", "Required", "Must inherit geometry, otherwise an error will occur"), + ], + default="OPTIONAL", + update=custom_cmd_preset_update, + ) + use_dl_cmd: BoolProperty( + name="Displaylist Command", + description="Add a displaylist arg at the end of the command if there is geometry. In c, use this macro, in binary OR the first layer with 0x80", + update=custom_cmd_preset_update, + ) + dl_command: StringProperty( + name="Displaylist Command", default="GEO_CUSTOM_CMD_WITH_DL", update=custom_cmd_preset_update + ) + is_animated: BoolProperty(name="Is Animated", update=custom_cmd_preset_update) + + # Level + section: EnumProperty( + name="Section", + items=[ + ( + "HIEARCHY", + "Hierarchy", + "If parented to level, add it before the areas, otherwise in the respective area", + ), + ("AREA", "Area", "Add it to an area, errors if parented to the level"), + ("LEVEL", "Level", "Add it to the level, errors if parented to an area"), + ("FORCE_LEVEL", "Force to Level", "Add it to the level, even if parented to an area"), + ], + update=custom_cmd_preset_update, + ) + + args_tab: BoolProperty(default=True) + args: CollectionProperty(type=SM64_CustomArgProperties) + examples_tab: BoolProperty(default=False) + + saved_hash: StringProperty() + locked: BoolProperty() + + @property + def preset_hash(self): + return str(hash(str(self.to_dict("PRESET_EDIT", include_defaults=False).items()))) + + def upgrade_object(self, obj: Object): + if self.version != 0: + return + found_cmd, arg = upgrade_old_prop(self, "str_cmd", obj, "customGeoCommand"), get_first_set_prop( + obj, "customGeoCommandArgs" + ) + if found_cmd: + self.cmd_type = "Geo" + if arg is not None: + self.args.add() + self.args[-1].arg_type = "PARAMETER" + self.args[-1].parameter = arg + + def upgrade_bone(self, bone: Bone): + if self.version != 0: + return + upgrade_old_prop(self, "str_cmd", self, "custom_geo_cmd_macro") + args = get_first_set_prop(self, "custom_geo_cmd_args") + if args is not None: + self.args.clear() + self.args.add() + self.args[-1].arg_type = "PARAMETER" + self.args[0].parameter = args + old_cmd = bone.get("geo_cmd") + if old_cmd is not None: + if old_cmd in {15, 16}: # custom animated / custom non-animated + bone.geo_cmd = "Custom" + if old_cmd == 15: + self.is_animated = True + self.version = 1 + + def get_cmd_type(self, owner: Optional[AvailableOwners] = None): + if isinstance(owner, Bone): + return "Geo" + return self.cmd_type + + def skips_eval(self, is_binary: bool): + if is_binary: + return False + return self.skip_eval + + def can_animate(self, owner: Optional[AvailableOwners] = None): + return self.get_cmd_type(owner) == "Geo" and (isinstance(owner, Bone) or owner is None) + + def can_have_mesh(self, owner: Optional[AvailableOwners] = None): + return self.get_cmd_type(owner) == "Geo" and can_have_mesh(owner) + + def adds_dl_ext(self, owner: Optional[AvailableOwners] = None): + return self.can_have_mesh(owner) and self.dl_option == "OPTIONAL" and self.use_dl_cmd + + def to_dict( + self, + conf_type: CustomCmdConf, + owner: Optional[AvailableOwners] = None, + world_matrix: Optional[mathutils.Matrix] = None, + local_matrix: Optional[mathutils.Matrix] = None, + blender_scale=1.0, + include_defaults=True, + is_export=False, + ): + preset_export = conf_type == "PRESET" and is_export + data = {} + if conf_type == "PRESET_EDIT" or preset_export: + data["name"] = self.name + if conf_type != "PRESET" or is_export: + data.update( + { + "cmd_type": self.get_cmd_type(owner), + "str_cmd": self.str_cmd, + "int_cmd": self.int_cmd, + "skip_eval": self.skip_eval, + } + ) + if can_have_mesh(owner): + if conf_type == "PRESET_EDIT" or preset_export: + data["children_requirements"] = self.children_requirements + if data.get("children_requirements") != "NONE": + data["group_children"] = self.group_children + data["dl_option"] = self.dl_option + if self.adds_dl_ext(owner): + data["dl_command"] = self.dl_command + if self.can_animate(owner): + data["is_animated"] = self.is_animated + if self.get_cmd_type(owner) == "Level": + data["section"] = self.section + self.args: list[SM64_CustomArgProperties] + data["args"] = [ + arg.to_dict(conf_type, owner, world_matrix, local_matrix, blender_scale, include_defaults, is_export) + for arg in self.args + ] + return data + + def from_dict(self, data: dict, set_defaults=True): + try: + self.locked = True # dont check preset hashes while setting values + self.name = data.get("name", "My Custom Command") + self.cmd_type = data.get("cmd_type", "Level") + self.str_cmd = data.get("str_cmd", "CUSTOM_COMMAND") + self.int_cmd = data.get("int_cmd", 0) + self.skip_eval = data.get("skip_eval", True) + self.children_requirements = data.get("children_requirements", "ANY") + self.group_children = data.get("group_children", True) + self.dl_option = data.get("dl_option", "NONE") + self.is_animated = data.get("is_animated", False) + self.use_dl_cmd = "dl_command" in data + self.dl_command = data.get("dl_command", "GEO_CUSTOM_CMD_WITH_DL") + self.section = data.get("section", "HIEARCHY") + args = data.get("args", []) + if set_defaults: + self.args.clear() + else: + for i in range(len(args), len(self.args)): + self.args.remove(i) + for i, arg in enumerate(args): + if i >= len(self.args): + self.args.add() + self.args[i].from_dict(arg, i, set_defaults) + finally: + self.locked = False + + def get_final_cmd( + self, + owner: Optional[AvailableOwners], + blender_scale: float, + world_matrix: mathutils.Matrix, + local_matrix: mathutils.Matrix, + layer: Optional[str | int] = None, + has_dl=False, + dl_ref: Optional[str] = None, + name="", + conf_type: Optional[CustomCmdConf] = None, + ): + if conf_type is None: + conf_type = "NO_PRESET" if self.preset == "NONE" else "PRESET" + return CustomCmd( + self.to_dict(conf_type, owner, world_matrix, local_matrix, blender_scale, is_export=True), + layer, + has_dl, + dl_ref, + name, + ) + + def example_macro_define(self, conf_type: CustomCmdConf = "NO_PRESET", use_dl_cmd=False, max_len=100): + macro_define = StringIO() + macro_define.write(f"// {self.name}\n") + macro_define.write("#define ") + macro_define.write(self.dl_command if use_dl_cmd else self.str_cmd) + macro_define.write("(") + previous_arg_names = set() + macro_args = [arg.example_macro_args(self, previous_arg_names, conf_type) for arg in self.args] + if use_dl_cmd: + macro_args.append(f'/*Displaylist*/ {duplicate_name("displaylist", previous_arg_names)}') + joined_args = ", ".join(macro_args) + if len(joined_args) > max_len: + joined_args = ", \\\n\t\t".join(macro_args) + macro_define.write("\\\n\t\t") + macro_define.write(f"{joined_args}) \\\n") + macro_define.write("\t(/* Your code goes here */)") + return macro_define.getvalue() + + def get_examples(self, owner: Optional[AvailableOwners], conf_type: CustomCmdConf, blender_scale=100.0): + cmd_examples = { + "Without DL": ( + self.get_final_cmd(owner, blender_scale, *get_transforms(owner), has_dl=False, conf_type=conf_type), + self.example_macro_define(conf_type, False, 25), + ) + } + if self.adds_dl_ext(owner): + cmd_examples["With DL"] = ( + self.get_final_cmd(owner, blender_scale, *get_transforms(owner), has_dl=True, conf_type=conf_type), + self.example_macro_define(conf_type, True, 25), + ) + return cmd_examples + + def draw_examples( + self, + layout: UILayout, + owner: Optional[AvailableOwners], + conf_type: CustomCmdConf, + blender_scale: float, + is_binary=False, + command_index=0, + ): + col = layout.column() + cmd_examples = self.get_examples(owner, conf_type, blender_scale) + try: + for name, (cmd, macro_example) in cmd_examples.items(): + box = col.box().column() + if len(cmd_examples) > 1: + box.label(text=name) + if is_binary: + multilineLabel(box, cmd.to_text_dump()) + continue + multilineLabel(box, cmd.to_c(max_length=25).replace("\t", " " * 5)) + SM64_CustomCmdOps.draw_props( + box, + "COPYDOWN", + "Copy example to clipboard", + op_name="COPY_EXAMPLE", + index=command_index, + example_name=name, + ) + multilineLabel(box, macro_example.replace("\t", " " * 5)) + except Exception as exc: + multilineLabel(box, f"Error: {exc}") + + def draw_props( + self, + layout: UILayout, + is_binary: bool, + owner: Optional[AvailableOwners] = None, + conf_type: CustomCmdConf = "NO_PRESET", + blender_scale=100.0, + command_index=-1, + ): + cmd_type = self.get_cmd_type(owner) + col = layout.column() + if self.preset != "NONE": + conf_type = "PRESET" + if conf_type != "PRESET_EDIT": + preset_row = col.row() + label_row = preset_row.row() + label_row.alignment = "LEFT" + label_row.label(text="Preset") + SM64_SearchCustomCmds.draw_props(preset_row, self, "preset", "") + SM64_CustomCmdOps.draw_props(preset_row, "PRESET_NEW", "", op_name="ADD", index=-1) + if conf_type != "PRESET": + if conf_type == "PRESET_EDIT": + prop_split(col, self, "name", "Preset Name") + if not isinstance(owner, Bone): # bone is always Geo + prop_split(col, self, "cmd_type", "Type") + prop_split(col, self, "int_cmd" if is_binary else "str_cmd", "Command") + if not is_binary and conf_type != "NO_PRESET": + col.prop(self, "skip_eval") + col.separator() + + if self.can_have_mesh(owner): + if conf_type == "PRESET_EDIT": + prop_split(col, self, "children_requirements", "Children Requirements") + if conf_type != "PRESET_EDIT" or self.children_requirements != "NONE": + col.prop(self, "group_children") + prop_split(col, self, "dl_option", "Displaylist Option") + if self.dl_option == "OPTIONAL": + row = col.row() + row.prop(self, "use_dl_cmd") + if self.use_dl_cmd: + row.prop(self, "dl_command", text="") + if self.can_animate(owner): + col.prop(self, "is_animated") + if conf_type == "PRESET_EDIT" and cmd_type == "Level": + col.prop(self, "section") + + if conf_type != "PRESET" and draw_and_check_tab(col, self, "args_tab", text=f"Arguments ({len(self.args)})"): + SM64_CustomArgsOps.draw_row(col.row(), -1, command_index=command_index) + + if self.args_tab or conf_type == "PRESET": + arg: SM64_CustomArgProperties + for i, arg in enumerate(self.args): + if not arg.will_draw(owner, conf_type): + continue + ops_row = col.row() + if conf_type != "PRESET": + num_row = ops_row.row() + num_row.alignment = "LEFT" + num_row.label(text=str(i)) + SM64_CustomArgsOps.draw_row(ops_row, i, command_index=command_index) + arg.draw_props(ops_row, col, owner, self, command_index, i, conf_type, is_binary) + if conf_type != "PRESET": + col.separator(factor=1.0) + + if conf_type != "PRESET" and draw_and_check_tab(col, self, "examples_tab", text="Examples"): + self.draw_examples(col, owner, conf_type, blender_scale, is_binary, command_index) + + +def draw_custom_cmd_presets(sm64_props: "SM64_Properties", layout: UILayout): + col = layout.column() + if not draw_and_check_tab(col, sm64_props, "custom_cmds_tab", icon="SETTINGS"): + return + basic_op_row = col.row() + SM64_CustomCmdOps.draw_props(basic_op_row, "ADD", "", op_name="ADD") + preset: SM64_CustomCmdProperties + for i, preset in enumerate(sm64_props.custom_cmds): + op_row = col.row() + if draw_and_check_tab(op_row, preset, "tab", preset.name): + preset.draw_props(col, sm64_props.binary_export, conf_type="PRESET_EDIT", command_index=i) + SM64_CustomCmdOps.draw_props(op_row, "ADD", "", op_name="ADD", index=i) + SM64_CustomCmdOps.draw_props(op_row, "REMOVE", "", op_name="REMOVE", index=i) + + +classes = ( + SM64_CustomNumberProperties, + SM64_CustomEnumProperties, + SM64_CustomArgProperties, + SM64_CustomCmdProperties, +) + + +def props_register(): + for cls in classes: + register_class(cls) + + +def props_unregister(): + for cls in reversed(classes): + unregister_class(cls) diff --git a/fast64_internal/sm64/custom_cmd/utility.py b/fast64_internal/sm64/custom_cmd/utility.py new file mode 100644 index 0000000..8768d84 --- /dev/null +++ b/fast64_internal/sm64/custom_cmd/utility.py @@ -0,0 +1,126 @@ +from typing import Literal, NamedTuple, Optional, TYPE_CHECKING +from re import fullmatch + +import mathutils +from bpy.types import Object, Bone, Context, SpaceView3D, Scene + +from ...utility import z_up_to_y_up_matrix +from ..sm64_geolayout_utility import updateBone + +if TYPE_CHECKING: + from .properties import SM64_CustomCmdProperties + +AvailableOwners = Object | Bone | Scene +CustomCmdConf = Literal["PRESET", "PRESET_EDIT", "NO_PRESET"] # type of configuration + + +def getDrawLayerName(drawLayer): + from ..sm64_geolayout_classes import getDrawLayerName + + return getDrawLayerName(drawLayer) + + +def duplicate_name(name, existing_names: set, old_name: Optional[str] = None): + if not name in existing_names: + return name + num = 0 + if old_name is not None: + number_match = fullmatch(r"(.*?)\.(\d+)$", old_name) + if number_match is not None: # if name already a duplicate/copy, add number + name, num = number_match.group(1), int(number_match.group(2)) + else: + name, num = old_name, 0 + + for i in range(1, len(existing_names) + 1): + new_name = f"{name}.{num+i:03}" + if new_name not in existing_names: # only use name if it's unique + return new_name + assert False, "Failed to generate unique name" + + +def get_custom_prop(context: Context): + """If owner is a scene, custom is always None""" + + class CustomContext(NamedTuple): + custom: Optional["SM64_CustomCmdProperties"] + owner: Optional[AvailableOwners] + + if isinstance(context.space_data, SpaceView3D): + return CustomContext(None, context.scene) + else: + if hasattr(context, "bone") and context.bone is not None: + return CustomContext(context.bone.fast64.sm64.custom, context.bone) + if hasattr(context, "object") and context.object is not None: + return CustomContext(context.object.fast64.sm64.custom, context.object) + return CustomContext(None, None) + + +def get_custom_cmd_preset( + custom_cmd: "SM64_CustomCmdProperties", context: Context +) -> Optional["SM64_CustomCmdProperties"]: + if custom_cmd.preset == "": + return None + presets: dict["SM64_CustomCmdProperties"] = { + custom.name: custom for custom in context.scene.fast64.sm64.custom_cmds + } + return presets[custom_cmd.preset] + + +def check_preset_hashes(owner: AvailableOwners, context: Context): + if owner.fast64.sm64.custom.locked: + return + custom_cmd: "SM64_CustomCmdProperties" = owner.fast64.sm64.custom + if custom_cmd.preset == "NONE": + return + preset_cmd = get_custom_cmd_preset(custom_cmd, context) + if preset_cmd is None: + custom_cmd.preset = "NONE" + elif custom_cmd.saved_hash != preset_cmd.preset_hash: + custom_cmd.from_dict( + preset_cmd.to_dict("PRESET_EDIT", owner, *get_transforms(owner), include_defaults=False), set_defaults=False + ) + custom_cmd.saved_hash = preset_cmd.preset_hash + + +def custom_cmd_preset_update(_self, context: Context): + owner = get_custom_prop(context).owner + if isinstance(owner, Scene): # current context is scene, check all + for obj in context.scene.objects: + check_preset_hashes(obj, context) + if obj.type == "ARMATURE": + for bone in obj.data.bones: + check_preset_hashes(bone, context) + elif owner is not None: + check_preset_hashes(owner, context) + if isinstance(owner, Bone): + updateBone(owner, context) + + +def get_custom_cmd_preset_enum(_self, context: Context): + if isinstance(get_custom_prop(context)[1], Bone): + allowed_types = {"Geo"} + else: + allowed_types = {"Level", "Geo", "Special"} + return [("NONE", "No Preset", "No preset selected")] + [ + (preset.name, preset.name, f"{preset.name} ({preset.cmd_type})") + for preset in (context.scene.fast64.sm64.custom_cmds) + if preset.cmd_type in allowed_types + ] + + +def better_round(value: float): # round, but handle inf + return round(max(-(2**31), min(2**31 - 1, value))) + + +def get_transforms(owner: Optional[AvailableOwners] = None): + if isinstance(owner, Object): + return tuple( + z_up_to_y_up_matrix @ x @ z_up_to_y_up_matrix.inverted() for x in [owner.matrix_world, owner.matrix_local] + ) + elif isinstance(owner, Bone): + relative = owner.matrix_local + if owner.parent is not None: + relative = owner.parent.matrix_local.inverted() @ relative + return tuple(z_up_to_y_up_matrix @ x @ z_up_to_y_up_matrix.inverted() for x in [owner.matrix_local, relative]) + else: + return (z_up_to_y_up_matrix @ mathutils.Matrix.Identity(4) @ z_up_to_y_up_matrix.inverted(),) * 2 diff --git a/fast64_internal/sm64/settings/properties.py b/fast64_internal/sm64/settings/properties.py index 1e60d7d..cb9a57c 100644 --- a/fast64_internal/sm64/settings/properties.py +++ b/fast64_internal/sm64/settings/properties.py @@ -2,7 +2,15 @@ import os from pathlib import Path import bpy from bpy.types import PropertyGroup, UILayout, Context -from bpy.props import BoolProperty, StringProperty, EnumProperty, IntProperty, FloatProperty, PointerProperty +from bpy.props import ( + BoolProperty, + StringProperty, + EnumProperty, + IntProperty, + FloatProperty, + PointerProperty, + CollectionProperty, +) from bpy.path import abspath from bpy.utils import register_class, unregister_class @@ -17,6 +25,7 @@ from ...utility import ( ) from ..sm64_constants import defaultExtendSegment4, OLD_BINARY_LEVEL_ENUMS from ..sm64_objects import SM64_CombinedObjectProperties +from ..custom_cmd.properties import SM64_CustomCmdProperties, draw_custom_cmd_presets from ..sm64_utility import export_rom_ui_warnings, import_rom_ui_warnings from ..tools import SM64_AddrConvProperties from ..animation.properties import SM64_AnimProperties @@ -49,6 +58,8 @@ class SM64_Properties(PropertyGroup): goal: EnumProperty(items=enum_sm64_goal_type, name="Goal", default="All") combined_export: bpy.props.PointerProperty(type=SM64_CombinedObjectProperties) animation: PointerProperty(type=SM64_AnimProperties) + custom_cmds: CollectionProperty(type=SM64_CustomCmdProperties) + custom_cmds_tab: BoolProperty(default=True, name="Custom Commands") address_converter: PointerProperty(type=SM64_AddrConvProperties) blender_to_sm64_scale: FloatProperty( @@ -196,6 +207,8 @@ class SM64_Properties(PropertyGroup): data["write_all"] = self.write_all if not self.hackersm64: data["designated"] = self.designated_prop + if self.custom_cmds: + data["custom_cmds"] = [preset.to_dict("PRESET_EDIT") for preset in self.custom_cmds] return data def from_repo_settings(self, data: dict): @@ -206,6 +219,11 @@ class SM64_Properties(PropertyGroup): set_prop_if_in_data(self, "lighting_engine_presets", data, "lighting_engine_presets") set_prop_if_in_data(self, "write_all", data, "write_all") set_prop_if_in_data(self, "designated_prop", data, "designated") + if "custom_cmds" in data: + self.custom_cmds.clear() + for preset_data in data.get("custom_cmds", []): + self.custom_cmds.add() + self.custom_cmds[-1].from_dict(preset_data) def draw_repo_settings(self, layout: UILayout): col = layout.column() @@ -218,6 +236,7 @@ class SM64_Properties(PropertyGroup): if self.matstack_fix: col.prop(self, "lighting_engine_presets") col.prop(self, "write_all") + draw_custom_cmd_presets(self, col.box()) def draw_props(self, layout: UILayout, show_repo_settings: bool = True): col = layout.column() diff --git a/fast64_internal/sm64/sm64_collision.py b/fast64_internal/sm64/sm64_collision.py index 39e8a09..d3982cc 100644 --- a/fast64_internal/sm64/sm64_collision.py +++ b/fast64_internal/sm64/sm64_collision.py @@ -135,11 +135,11 @@ class Collision: if len(self.specials) > 0: data.source += "\tCOL_SPECIAL_INIT(" + str(len(self.specials)) + "),\n" for special in self.specials: - data.source += "\t" + special.to_c() + data.source += "\t" + special.to_c(1) + ",\n" if len(self.water_boxes) > 0: data.source += "\tCOL_WATER_BOX_INIT(" + str(len(self.water_boxes)) + "),\n" for waterBox in self.water_boxes: - data.source += "\t" + waterBox.to_c() + data.source += "\t" + waterBox.to_c(1) + ",\n" data.source += "\tCOL_END()\n" + "};\n" return data diff --git a/fast64_internal/sm64/sm64_geolayout_bone.py b/fast64_internal/sm64/sm64_geolayout_bone.py index 855d168..9bb8074 100644 --- a/fast64_internal/sm64/sm64_geolayout_bone.py +++ b/fast64_internal/sm64/sm64_geolayout_bone.py @@ -1,10 +1,11 @@ import bpy from bpy.ops import object -from bpy.types import Bone, Object, Panel, Operator, Armature, Mesh, Material, PropertyGroup +from bpy.types import Bone, Object, Context, Panel, Operator, Armature, Mesh, Material, PropertyGroup from bpy.utils import register_class, unregister_class -from ..utility import PluginError, prop_split, obj_scale_is_unified +from ..utility import PluginError, get_first_set_prop, prop_split, obj_scale_is_unified, upgrade_old_prop from ..f3d.f3d_material import sm64EnumDrawLayers -from .sm64_geolayout_utility import createBoneGroups, addBoneToGroup +from .sm64_geolayout_utility import updateBone +from .custom_cmd.properties import SM64_CustomCmdProperties from bpy.props import ( StringProperty, @@ -34,12 +35,10 @@ enumBoneType = [ ("Ignore", "Ignore", "Ignore bones when exporting."), ("SwitchOption", "Switch Option", "Switch Option"), ("DisplayListWithOffset", "Animated Part (0x13)", "Animated Part (Animatable Bone)"), - ("CustomAnimated", "Custom Animated", "Custom Bone used for animation"), - ("CustomNonAnimated", "Custom (Non-animated)", "Custom geolayout bone, non animated"), + ("", "", ""), + ("Custom", "Custom", "Custom bone using command presets"), ] -animatableBoneTypes = {"DisplayListWithOffset", "CustomAnimated"} - enumGeoStaticType = [ ("Billboard", "Billboard (0x14)", "Billboard"), ("DisplayListWithOffset", "Animated Part (0x13)", "Animated Part (Animatable Bone)"), @@ -81,13 +80,14 @@ enumMatOverrideOptions = [ ] -def drawGeoInfo(panel: Panel, bone: Bone): - bone_props: "SM64_BoneProperties" = bone.fast64.sm64 +def drawGeoInfo(panel: Panel, context: Context): panel.layout.box().label(text="Geolayout Inspector") + bone = context.bone if bone is None: panel.layout.label(text="Edit geolayout properties in Pose mode.") return - + bone_props: "SM64_BoneProperties" = bone.fast64.sm64 + sm64_props: "SM64_Properties" = context.scene.fast64.sm64 col = panel.layout.column() prop_split(col, bone, "geo_cmd", "Geolayout Command") @@ -109,7 +109,6 @@ def drawGeoInfo(panel: Panel, bone: Bone): "DisplayList", "Scale", "DisplayListWithOffset", - "CustomAnimated", ]: drawLayerWarningBox(col, bone, "draw_layer") if bpy.context.scene.exportInlineF3D: @@ -149,14 +148,10 @@ def drawGeoInfo(panel: Panel, bone: Bone): infoBoxRenderArea.label(text="See the object properties window for the armature instead.") prop_split(col, bone, "culling_radius", "Culling Radius") - elif bone.geo_cmd in {"CustomAnimated", "CustomNonAnimated"}: - prop_split(col, bone_props, "custom_geo_cmd_macro", "Geo Command Macro") - if bone.geo_cmd == "CustomNonAnimated": - prop_split(col, bone_props, "custom_geo_cmd_args", "Geo Command Args") - else: # It's animated - infobox = col.box() - infobox.label(text="Command's args will be filled with layer, translate, and rotate", icon="INFO") - infobox.label(text="e.g. `GEO_CUSTOM(layer, tX, tY, tZ, rX, rY, rZ, displayList)`") + elif bone.geo_cmd == "Custom": + bone_props.custom.draw_props( + col, sm64_props.binary_export, context.bone, "NO_PRESET", sm64_props.blender_to_sm64_scale + ) # if bone.geo_cmd == 'SwitchOption': # prop_split(col, bone, 'switch_bone', 'Switch Bone') @@ -180,7 +175,7 @@ class GeolayoutBonePanel(Panel): return context.scene.gameEditorMode == "SM64" def draw(self, context): - drawGeoInfo(self, context.bone) + drawGeoInfo(self, context) class GeolayoutArmaturePanel(Panel): @@ -458,23 +453,9 @@ def getSwitchOptionBone(switchArmature): return optionBones[0] -def updateBone(bone, context): - armatureObj = context.object - - createBoneGroups(armatureObj) - if bone.geo_cmd not in animatableBoneTypes: - addBoneToGroup(armatureObj, bone.name, bone.geo_cmd) - object.mode_set(mode="POSE") - else: - addBoneToGroup(armatureObj, bone.name, None) - object.mode_set(mode="POSE") - - class SM64_BoneProperties(PropertyGroup): version: IntProperty(name="SM64_BoneProperties Version", default=0) - - custom_geo_cmd_macro: StringProperty(name="Geo Command Macro", default="GEO_BONE") - custom_geo_cmd_args: StringProperty(name="Geo Command Args", default="") + custom: PointerProperty(type=SM64_CustomCmdProperties) revert_previous_mat: BoolProperty(name="Revert Previous Material", default=False) revert_after_mat: BoolProperty( name="Revert After Material", @@ -483,6 +464,16 @@ class SM64_BoneProperties(PropertyGroup): ) revert_before_func: BoolProperty(name="Revert Before Function", default=True) + def upgrade_bone(self, bone): + self.custom.upgrade_bone(bone) + + @staticmethod + def upgrade_changed_props(): + for obj in bpy.data.objects: + if obj.type == "ARMATURE": + for bone in obj.data.bones: + bone.fast64.sm64.upgrade_bone(bone) + sm64_bone_classes = ( AddSwitchOption, diff --git a/fast64_internal/sm64/sm64_geolayout_classes.py b/fast64_internal/sm64/sm64_geolayout_classes.py index 74c0acf..d0c5908 100644 --- a/fast64_internal/sm64/sm64_geolayout_classes.py +++ b/fast64_internal/sm64/sm64_geolayout_classes.py @@ -50,6 +50,8 @@ from .sm64_geolayout_constants import ( GEO_SETUP_OBJ_RENDER, GEO_SET_BG, ) +from .sm64_geolayout_utility import BaseDisplayListNode +from .custom_cmd.exporting import CustomCmd from .sm64_utility import convert_addr_to_func drawLayerNames = { @@ -279,45 +281,6 @@ class Geolayout: return drawLayers -class BaseDisplayListNode: - """Base displaylist node with common helper functions dealing with displaylists""" - - dl_ext = "WITH_DL" # add dl_ext to geo command if command has a displaylist - override_layer = False - dlRef: str | GfxList | None - - def get_dl_address(self): - assert not isinstance(self.dlRef, str), "dlRef string not supported in binary" - if isinstance(self.dlRef, GfxList): - return self.dlRef.startAddress - if self.hasDL and self.DLmicrocode is not None: - return self.DLmicrocode.startAddress - return None - - def get_dl_name(self): - if isinstance(self.dlRef, GfxList): - return self.dlRef.name - if self.hasDL and (self.dlRef or self.DLmicrocode is not None): - return self.dlRef or self.DLmicrocode.name - return "NULL" - - def get_c_func_macro(self, base_cmd: str): - return f"{base_cmd}_{self.dl_ext}" if self.hasDL else base_cmd - - def c_func_macro(self, base_cmd: str, *args: str): - """ - Supply base command and all arguments for command. - if self.hasDL: - this will add self.dl_ext to the command, and - adds the name of the displaylist to the end of the command - Example return: 'GEO_YOUR_COMMAND_WITH_DL(arg, arg2),' - """ - all_args = list(args) - if self.hasDL: - all_args.append(self.get_dl_name()) - return f'{self.get_c_func_macro(base_cmd)}({", ".join(all_args)}),' - - class TransformNode: def __init__(self, node): self.node = node @@ -329,6 +292,19 @@ class TransformNode: self.revert_previous_mat = False self.revert_after_mat = False + def do_export_checks(self): + if self.node is not None: + if hasattr(self.node, "do_export_checks"): + self.node.do_export_checks(len(self.children)) + + @property + def groups(self): + if isinstance(self.node, tuple(nodeGroupClasses)): + return True + if hasattr(self.node, "group_children"): + return self.node.group_children + return False + def convertToDynamic(self): if self.node.hasDL: funcNode = FunctionNode(self.node.DLmicrocode.name, self.node.drawLayer) @@ -364,7 +340,7 @@ class TransformNode: if self.node is not None: if getattr(self.node, "hasDL", False): return True - if type(self.node) in (JumpNode, SwitchNode, FunctionNode, ShadowNode, CustomNode, CustomAnimatedNode): + if type(self.node) in (JumpNode, SwitchNode, FunctionNode, ShadowNode, CustomCmd): return True for child in self.children: if child.has_data(): @@ -373,7 +349,7 @@ class TransformNode: def size(self): size = self.node.size() if self.node is not None else 0 - if len(self.children) > 0 and type(self.node) in nodeGroupClasses: + if len(self.children) > 0 and self.groups: size += 8 # node open/close for child in self.children: size += child.size() @@ -383,6 +359,7 @@ class TransformNode: # Function commands usually effect the following command, so it is similar # to a parent child relationship. def to_binary(self, segmentData): + self.do_export_checks() if self.node is not None: data = self.node.to_binary(segmentData) else: @@ -391,37 +368,39 @@ class TransformNode: if type(self.node) is FunctionNode: raise PluginError("An FunctionNode cannot have children.") - if type(self.node) in nodeGroupClasses: + if self.groups: data.extend(bytearray([GEO_NODE_OPEN, 0x00, 0x00, 0x00])) for child in self.children: data.extend(child.to_binary(segmentData)) - if type(self.node) in nodeGroupClasses: + if self.groups: data.extend(bytearray([GEO_NODE_CLOSE, 0x00, 0x00, 0x00])) elif type(self.node) is SwitchNode: raise PluginError("A switch bone must have at least one child bone.") return data def to_c(self, depth): + self.do_export_checks() if self.node is not None: - nodeC = self.node.to_c() + nodeC = self.node.to_c(depth) if nodeC is not None: # Should only be the case for DisplayListNode with no DL - data = depth * "\t" + self.node.to_c() + "\n" + data = ("\t" * depth) + f"{nodeC},\n" else: data = "" else: data = "" if len(self.children) > 0: - if type(self.node) in nodeGroupClasses: - data += depth * "\t" + "GEO_OPEN_NODE(),\n" + if self.groups: + data += ("\t" * depth) + "GEO_OPEN_NODE(),\n" for child in self.children: - data += child.to_c(depth + (1 if type(self.node) in nodeGroupClasses else 0)) - if type(self.node) in nodeGroupClasses: - data += depth * "\t" + "GEO_CLOSE_NODE(),\n" + data += child.to_c(depth + (1 if self.groups else 0)) + if self.groups: + data += ("\t" * depth) + "GEO_CLOSE_NODE(),\n" elif type(self.node) is SwitchNode: raise PluginError("A switch bone must have at least one child bone.") return data def toTextDump(self, nodeLevel, segmentData): + self.do_export_checks() data = "" if self.node is not None: command = self.node.to_binary(segmentData) @@ -434,11 +413,11 @@ class TransformNode: data += "\n" if len(self.children) > 0: - if type(self.node) in nodeGroupClasses: + if self.groups: data += "\t" * nodeLevel + "04 00 00 00\n" for child in self.children: - data += child.toTextDump(nodeLevel + (1 if type(self.node) in nodeGroupClasses else 0), segmentData) - if type(self.node) in nodeGroupClasses: + data += child.toTextDump(nodeLevel + (1 if self.groups else 0), segmentData) + if self.groups: data += "\t" * nodeLevel + "05 00 00 00\n" elif type(self.node) is SwitchNode: raise PluginError("A switch bone must have at least one child bone.") @@ -488,9 +467,9 @@ class JumpNode: command.extend(startAddress) return command - def to_c(self): + def to_c(self, _depth=0): geo_name = self.geoRef or self.geolayout.name - return "GEO_BRANCH(" + ("1, " if self.storeReturn else "0, ") + geo_name + ")," + return "GEO_BRANCH(" + ("1, " if self.storeReturn else "0, ") + geo_name + ")" LastMaterials = dict[int, tuple[FMaterial | None, list[tuple[GfxList, dict[type, GbiMacro]]]]] @@ -604,6 +583,12 @@ class FunctionNode: self.func_param = func_param self.hasDL = False + def do_export_checks(self, children_count: int): + if children_count > 0: + raise PluginError( + "Function bones cannot have children. They instead affect the next sibling bone in alphabetical order." + ) + def size(self): return 8 @@ -614,8 +599,8 @@ class FunctionNode: addFuncAddress(command, self.geo_func) return command - def to_c(self): - return "GEO_ASM(" + str(self.func_param) + ", " + convert_addr_to_func(self.geo_func) + ")," + def to_c(self, _depth=0): + return "GEO_ASM(" + str(self.func_param) + ", " + convert_addr_to_func(self.geo_func) + ")" class HeldObjectNode: @@ -634,7 +619,7 @@ class HeldObjectNode: addFuncAddress(command, self.geo_func) return command - def to_c(self): + def to_c(self, _depth=0): return ( "GEO_HELD_OBJECT(0, " + str(convertFloatToShort(self.translate[0])) @@ -644,7 +629,7 @@ class HeldObjectNode: + str(convertFloatToShort(self.translate[2])) + ", " + convert_addr_to_func(self.geo_func) - + ")," + + ")" ) @@ -659,8 +644,8 @@ class StartNode: command = bytearray([GEO_START, 0x00, 0x00, 0x00]) return command - def to_c(self): - return "GEO_NODE_START()," + def to_c(self, _depth=0): + return "GEO_NODE_START()" class EndNode: @@ -674,8 +659,8 @@ class EndNode: command = bytearray([GEO_END, 0x00, 0x00, 0x00]) return command - def to_c(self): - return "GEO_END()," + def to_c(self, _depth=0): + return "GEO_END()" # Geolayout node hierarchy is first generated without material/draw layer @@ -699,8 +684,8 @@ class SwitchNode: addFuncAddress(command, self.switchFunc) return command - def to_c(self): - return "GEO_SWITCH_CASE(" + str(self.defaultCase) + ", " + convert_addr_to_func(self.switchFunc) + ")," + def to_c(self, _depth=0): + return "GEO_SWITCH_CASE(" + str(self.defaultCase) + ", " + convert_addr_to_func(self.switchFunc) + ")" class TranslateRotateNode(BaseDisplayListNode): @@ -771,7 +756,7 @@ class TranslateRotateNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): if self.fieldLayout == 0: return self.c_func_macro( "GEO_TRANSLATE_ROTATE", @@ -838,7 +823,7 @@ class TranslateNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): return self.c_func_macro( "GEO_TRANSLATE_NODE", getDrawLayerName(self.drawLayer), @@ -881,7 +866,7 @@ class RotateNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): return self.c_func_macro( "GEO_ROTATION_NODE", getDrawLayerName(self.drawLayer), @@ -923,7 +908,7 @@ class BillboardNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): return self.c_func_macro( "GEO_BILLBOARD_WITH_PARAMS", getDrawLayerName(self.drawLayer), @@ -958,11 +943,11 @@ class DisplayListNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): if not self.hasDL: return None args = [getDrawLayerName(self.drawLayer), self.get_dl_name()] - return f"GEO_DISPLAY_LIST({join_c_args(args)})," + return f"GEO_DISPLAY_LIST({join_c_args(args)})" class ShadowNode: @@ -982,9 +967,9 @@ class ShadowNode: command.extend(self.shadowScale.to_bytes(2, "big")) return command - def to_c(self): + def to_c(self, _depth=0): return ( - "GEO_SHADOW(" + str(self.shadowType) + ", " + str(self.shadowSolidity) + ", " + str(self.shadowScale) + ")," + "GEO_SHADOW(" + str(self.shadowType) + ", " + str(self.shadowSolidity) + ", " + str(self.shadowScale) + ")" ) @@ -1016,7 +1001,7 @@ class ScaleNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): return self.c_func_macro( "GEO_SCALE", getDrawLayerName(self.drawLayer), str(int(round(self.scaleValue * 0x10000))) ) @@ -1035,12 +1020,12 @@ class StartRenderAreaNode: command.extend(convertFloatToShort(self.cullingRadius).to_bytes(2, "big")) return command - def to_c(self): + def to_c(self, _depth=0): cullingRadius = convertFloatToShort(self.cullingRadius) # if abs(cullingRadius) > 2**15 - 1: # raise PluginError("A render area node has a culling radius that does not fit an s16.\n Radius is " +\ # str(cullingRadius) + ' when converted to SM64 units.') - return "GEO_CULLING_RADIUS(" + str(convertFloatToShort(self.cullingRadius)) + ")," + return "GEO_CULLING_RADIUS(" + str(convertFloatToShort(self.cullingRadius)) + ")" class RenderRangeNode: @@ -1058,13 +1043,13 @@ class RenderRangeNode: command.extend(convertFloatToShort(self.maxDist).to_bytes(2, "big")) return command - def to_c(self): + def to_c(self, _depth=0): minDist = convertFloatToShort(self.minDist) maxDist = convertFloatToShort(self.maxDist) # if (abs(minDist) > 2**15 - 1) or (abs(maxDist) > 2**15 - 1): # raise PluginError("A render range (LOD) node has a range that does not fit an s16.\n Range is " +\ # str(minDist) + ', ' + str(maxDist) + ' when converted to SM64 units.') - return "GEO_RENDER_RANGE(" + str(minDist) + ", " + str(maxDist) + ")," + return "GEO_RENDER_RANGE(" + str(minDist) + ", " + str(maxDist) + ")" class DisplayListWithOffsetNode(BaseDisplayListNode): @@ -1095,7 +1080,7 @@ class DisplayListWithOffsetNode(BaseDisplayListNode): command.extend(bytearray([0x00] * 4)) return command - def to_c(self): + def to_c(self, _depth=0): args = [ getDrawLayerName(self.drawLayer), str(convertFloatToShort(self.translate[0])), @@ -1103,7 +1088,7 @@ class DisplayListWithOffsetNode(BaseDisplayListNode): str(convertFloatToShort(self.translate[2])), self.get_dl_name(), # This node requires 'NULL' if there is no DL ] - return f"GEO_ANIMATED_PART({join_c_args(args)})," + return f"GEO_ANIMATED_PART({join_c_args(args)})" class ScreenAreaNode: @@ -1129,10 +1114,10 @@ class ScreenAreaNode: command.extend(dimensions[1].to_bytes(2, "big", signed=True)) return command - def to_c(self): + def to_c(self, _depth=0): if self.useDefaults: return ( - "GEO_NODE_SCREEN_AREA(10, " + "SCREEN_WIDTH/2, SCREEN_HEIGHT/2, " + "SCREEN_WIDTH/2, SCREEN_HEIGHT/2)," + "GEO_NODE_SCREEN_AREA(10, " + "SCREEN_WIDTH/2, SCREEN_HEIGHT/2, " + "SCREEN_WIDTH/2, SCREEN_HEIGHT/2)" ) else: return ( @@ -1146,7 +1131,7 @@ class ScreenAreaNode: + str(self.dimensions[0]) + ", " + str(self.dimensions[1]) - + ")," + + ")" ) @@ -1164,8 +1149,8 @@ class OrthoNode: command.extend(bytearray(pack(">f", self.scale))) return command - def to_c(self): - return "GEO_NODE_ORTHO(" + format(self.scale, ".4f") + ")," + def to_c(self, _depth=0): + return "GEO_NODE_ORTHO(" + format(self.scale, ".4f") + ")" class FrustumNode: @@ -1189,9 +1174,9 @@ class FrustumNode: command.extend(bytes.fromhex("8029AA3C")) return command - def to_c(self): + def to_c(self, _depth=0): if not self.useFunc: - return "GEO_CAMERA_FRUSTUM(" + format(self.fov, ".4f") + ", " + str(self.near) + ", " + str(self.far) + ")," + return "GEO_CAMERA_FRUSTUM(" + format(self.fov, ".4f") + ", " + str(self.near) + ", " + str(self.far) + ")" else: return ( "GEO_CAMERA_FRUSTUM_WITH_FUNC(" @@ -1200,7 +1185,7 @@ class FrustumNode: + str(self.near) + ", " + str(self.far) - + ", geo_camera_fov)," + + ", geo_camera_fov)" ) @@ -1216,8 +1201,8 @@ class ZBufferNode: command = bytearray([GEO_SET_Z_BUF, 0x01 if self.enable else 0x00, 0x00, 0x00]) return command - def to_c(self): - return "GEO_ZBUFFER(" + ("1" if self.enable else "0") + ")," + def to_c(self, _depth=0): + return "GEO_ZBUFFER(" + ("1" if self.enable else "0") + ")" class CameraNode: @@ -1243,7 +1228,7 @@ class CameraNode: addFuncAddress(command, self.geo_func) return command - def to_c(self): + def to_c(self, _depth=0): return ( "GEO_CAMERA(" + str(self.camType) @@ -1261,7 +1246,7 @@ class CameraNode: + str(self.lookAt[2]) + ", " + convert_addr_to_func(self.geo_func) - + ")," + + ")" ) @@ -1277,8 +1262,8 @@ class RenderObjNode: command = bytearray([GEO_SETUP_OBJ_RENDER, 0x00, 0x00, 0x00]) return command - def to_c(self): - return "GEO_RENDER_OBJ()," + def to_c(self, _depth=0): + return "GEO_RENDER_OBJ()" class BackgroundNode: @@ -1300,61 +1285,11 @@ class BackgroundNode: addFuncAddress(command, self.geo_func) return command - def to_c(self): + def to_c(self, _depth=0): if self.isColor: - return "GEO_BACKGROUND_COLOR(0x" + format(self.backgroundValue, "04x").upper() + ")," + return "GEO_BACKGROUND_COLOR(0x" + format(self.backgroundValue, "04x").upper() + ")" else: - return "GEO_BACKGROUND(" + str(self.backgroundValue) + ", " + convert_addr_to_func(self.geo_func) + ")," - - -class CustomNode: - def __init__(self, command: str, args: str): - self.command = command - self.args = args or "" # command may not have args - self.hasDL = False - - def size(self): - return 8 - - def to_binary(self, segmentData): - raise PluginError("Custom Geo Nodes are not supported for binary exports.") - - def to_c(self): - return f"{self.command}({self.args})," - - -class CustomAnimatedNode(BaseDisplayListNode): - def __init__(self, command: str, drawLayer, translate, rotate, dlRef: str = None): - self.command = command - self.drawLayer = drawLayer - self.hasDL = True - self.translate = translate - self.rotate = rotate - self.fMesh = None - self.DLmicrocode = None - self.dlRef = dlRef - # exists to get the override DL from an fMesh - self.override_hash = None - - def size(self): - return 16 - - def get_ptr_offsets(self): - return [] - - def to_binary(self, segmentData): - raise PluginError("Custom Geo Nodes are not supported for binary exports.") - - def to_c(self): - args = [ - getDrawLayerName(self.drawLayer), - str(convertFloatToShort(self.translate[0])), - str(convertFloatToShort(self.translate[1])), - str(convertFloatToShort(self.translate[2])), - *(str(radians_to_s16(r)) for r in self.rotate.to_euler("XYZ")), - self.get_dl_name(), # This node requires 'NULL' if there is no DL - ] - return f"{self.command}({join_c_args(args)})," + return "GEO_BACKGROUND(" + str(self.backgroundValue) + ", " + convert_addr_to_func(self.geo_func) + ")" nodeGroupClasses = [ @@ -1374,8 +1309,6 @@ nodeGroupClasses = [ ZBufferNode, CameraNode, RenderRangeNode, - CustomNode, - CustomAnimatedNode, ] DLNodes = [ @@ -1386,5 +1319,4 @@ DLNodes = [ ScaleNode, DisplayListNode, DisplayListWithOffsetNode, - CustomAnimatedNode, ] diff --git a/fast64_internal/sm64/sm64_geolayout_parser.py b/fast64_internal/sm64/sm64_geolayout_parser.py index f4aee26..dfcd324 100644 --- a/fast64_internal/sm64/sm64_geolayout_parser.py +++ b/fast64_internal/sm64/sm64_geolayout_parser.py @@ -4,7 +4,8 @@ from ..f3d.f3d_parser import createBlankMaterial, parseF3DBinary from ..panels import SM64_Panel from .sm64_level_parser import parse_level_binary from .sm64_constants import enumLevelNames -from .sm64_geolayout_bone import enumShadowType, animatableBoneTypes, enumBoneType +from .sm64_geolayout_bone import enumShadowType +from .sm64_geolayout_utility import is_bone_animatable from .sm64_geolayout_constants import getGeoLayoutCmdLength, nodeGroupCmds, GEO_BRANCH_STORE from .sm64_utility import import_rom_checks @@ -169,13 +170,6 @@ def parseGeoLayout( if bpy.app.version < (4, 0, 0) and useArmature: armatureObj.data.layers[1] = True - """ - if useMetarig: - metaBones = [bone for bone in armatureObj.data.bones if \ - bone.layers[boneLayers['meta']] or bone.layers[boneLayers['visual']]] - for bone in metaBones: - addBoneToGroup(armatureObj, bone.name, 'Ignore') - """ return armatureMeshGroups, armatureObj @@ -536,11 +530,9 @@ def traverseArmatureForMetarig(armatureObj, boneName, parentName): if bpy.app.version >= (4, 0, 0): if "Ignore" in bone.collections: return - nonAnimatableBoneTypes = set([item[0] for item in enumBoneType]) - animatableBoneTypes - isAnimatableBone = not any([item in bone.collections for item in nonAnimatableBoneTypes]) - if isAnimatableBone: + if is_bone_animatable(bone): processBoneMeta(armatureObj, boneName, parentName) - nextParentName = boneName if isAnimatableBone else parentName + nextParentName = boneName if is_bone_animatable(bone) else parentName bone = armature.bones[boneName] # re-obtain reference after edit mode changes childrenNames = [child.name for child in bone.children] @@ -730,9 +722,8 @@ def createBone(armatureObj, parentBoneName, boneName, currentTransform, boneGrou bone.tail += currentTransform.to_quaternion() @ mathutils.Vector((0, 1, 0)) * 0.02 boneName = bone.name - addBoneToGroup(armatureObj, bone.name, boneGroup) - bone = armatureObj.data.bones[boneName] bone.geo_cmd = boneGroup if boneGroup is not None else "DisplayListWithOffset" + addBoneToGroup(armatureObj, bone.name) return boneName @@ -802,7 +793,7 @@ def createSwitchOption( bMesh = bmesh.new() bMesh.from_mesh(mesh) - addBoneToGroup(switchArmature, boneName, "SwitchOption") + addBoneToGroup(switchArmature, boneName) return boneName, (switchArmature, bMesh, obj), finalTransform, finalNextParentTransform diff --git a/fast64_internal/sm64/sm64_geolayout_utility.py b/fast64_internal/sm64/sm64_geolayout_utility.py index f422205..d94be95 100644 --- a/fast64_internal/sm64/sm64_geolayout_utility.py +++ b/fast64_internal/sm64/sm64_geolayout_utility.py @@ -1,7 +1,20 @@ import bpy +from bpy.types import Object, Armature, Bone, PoseBone + +from ..f3d.f3d_gbi import GfxList from ..utility import PluginError +def is_bone_animatable(bone: Bone): + bone_props: "SM64_BoneProperties" = bone.fast64.sm64 + geo_cmd: str = bone.geo_cmd + if geo_cmd == "DisplayListWithOffset": + return True + elif geo_cmd == "Custom" and bone_props.custom.is_animated: + return True + return False + + def getBoneGroupByName(armatureObj, name): for boneGroup in armatureObj.pose.bone_groups: if boneGroup.name == name: @@ -42,6 +55,8 @@ boneNodeProperties = { "StartRenderArea": BoneNodeProperties(True, "THEME13"), # 0x20 "Ignore": BoneNodeProperties(False, "THEME08"), # Used for rigging "SwitchOption": BoneNodeProperties(False, "THEME11"), + "DisplayListWithOffset": BoneNodeProperties(True, "THEME00"), + "Custom": BoneNodeProperties(True, "THEME15"), } boneLayers = {"anim": 0, "other": 1, "meta": 2, "visual": 3} @@ -66,51 +81,94 @@ def createBoneGroups(armatureObj): boneGroup.color_set = properties.theme -def addBoneToGroup(armatureObj, boneName, groupName): - armature = armatureObj.data - if groupName is None: - if bpy.context.mode != "OBJECT": - bpy.ops.object.mode_set(mode="OBJECT") - posebone = armatureObj.pose.bones[boneName] - bone = armature.bones[boneName] - bone.use_deform = True +def addBoneToGroup(armature_obj: Object, name: str): + armature: Armature = armature_obj.data + pose_bone: PoseBone = armature_obj.pose.bones[name] + bone: Bone = armature.bones[name] + geo_cmd: str = bone.geo_cmd + if geo_cmd not in boneNodeProperties: + raise PluginError(f"Bone group {geo_cmd} doesn't exist.") + + lock_location, lock_rotation, lock_scale = False, False, False + + if is_bone_animatable(bone): if bpy.app.version >= (4, 0, 0): if not "anim" in armature.collections: armature.collections.new(name="anim") armature.collections["anim"].assign(bone) else: - posebone.bone_group = None + pose_bone.bone_group = None bone.layers = createBoneLayerMask([boneLayers["anim"]]) - posebone.lock_location = (False, False, False) - posebone.lock_rotation = (False, False, False) - posebone.lock_scale = (False, False, False) - return - - elif groupName not in boneNodeProperties: - raise PluginError("Bone group " + groupName + " doesn't exist.") - - if bpy.context.mode != "OBJECT": - bpy.ops.object.mode_set(mode="OBJECT") - - posebone = armatureObj.pose.bones[boneName] - bone = armatureObj.data.bones[boneName] if bpy.app.version >= (4, 0, 0): - armature.collections[groupName].assign(bone) + armature.collections[geo_cmd].assign(bone) else: - posebone.bone_group_index = getBoneGroupIndex(armatureObj, groupName) + pose_bone.bone_group_index = getBoneGroupIndex(armature_obj, geo_cmd) - if groupName != "Ignore": - bone.use_deform = boneNodeProperties[groupName].deform - if groupName != "DisplayList": - if bpy.app.version >= (4, 0, 0): - if not "other" in armature.collections: - armature.collections.new(name="other") - armature.collections["other"].assign(bone) - else: - bone.layers = createBoneLayerMask([boneLayers["other"]]) + if geo_cmd == "Custom": + custom = bone.fast64.sm64.custom + bone.use_deform = custom.dl_option != "NONE" + if not custom.is_animated: + lock_location = lock_rotation = lock_scale = True + elif geo_cmd != "Ignore": + bone.use_deform = boneNodeProperties[geo_cmd].deform + if geo_cmd != "SwitchOption": + lock_location = True + lock_rotation = lock_scale = True + if geo_cmd not in {"Ignore", "DisplayList"}: + if bpy.app.version >= (4, 0, 0): + if not "other" in armature.collections: + armature.collections.new(name="other") + armature.collections["other"].assign(bone) + else: + bone.layers = createBoneLayerMask([boneLayers["other"]]) - if groupName != "SwitchOption": - posebone.lock_location = (True, True, True) - posebone.lock_rotation = (True, True, True) - posebone.lock_scale = (True, True, True) + pose_bone.lock_location = (lock_location, lock_location, lock_location) + pose_bone.lock_rotation = (lock_rotation, lock_rotation, lock_rotation) + pose_bone.lock_scale = (lock_scale, lock_scale, lock_scale) + + +def updateBone(bone, context): + armatureObj = context.object + + createBoneGroups(armatureObj) + addBoneToGroup(armatureObj, bone.name) + + +class BaseDisplayListNode: + """Base displaylist node with common helper functions dealing with displaylists""" + + dl_ext = "WITH_DL" # add dl_ext to geo command if command has a displaylist + override_layer = False + dlRef: str | GfxList | None + + def get_dl_address(self): + assert not isinstance(self.dlRef, str), "dlRef string not supported in binary" + if isinstance(self.dlRef, GfxList): + return self.dlRef.startAddress + if self.hasDL and self.DLmicrocode is not None: + return self.DLmicrocode.startAddress + return None + + def get_dl_name(self): + if isinstance(self.dlRef, GfxList): + return self.dlRef.name + if self.hasDL and (self.dlRef or self.DLmicrocode is not None): + return self.dlRef or self.DLmicrocode.name + return "NULL" + + def get_c_func_macro(self, base_cmd: str): + return f"{base_cmd}_{self.dl_ext}" if self.hasDL else base_cmd + + def c_func_macro(self, base_cmd: str, *args: str): + """ + Supply base command and all arguments for command. + if self.hasDL: + this will add self.dl_ext to the command, and + adds the name of the displaylist to the end of the command + Example return: 'GEO_YOUR_COMMAND_WITH_DL(arg, arg2),' + """ + all_args = list(args) + if self.hasDL: + all_args.append(self.get_dl_name()) + return f'{self.get_c_func_macro(base_cmd)}({", ".join(all_args)})' diff --git a/fast64_internal/sm64/sm64_geolayout_writer.py b/fast64_internal/sm64/sm64_geolayout_writer.py index c57d3b8..f51edea 100644 --- a/fast64_internal/sm64/sm64_geolayout_writer.py +++ b/fast64_internal/sm64/sm64_geolayout_writer.py @@ -9,7 +9,7 @@ from io import BytesIO from ..operators import ObjectDataExporter from ..panels import SM64_Panel from .sm64_objects import InlineGeolayoutObjConfig, inlineGeoLayoutObjects -from .sm64_geolayout_bone import getSwitchOptionBone, animatableBoneTypes +from .sm64_geolayout_bone import getSwitchOptionBone from .sm64_camera import saveCameraSettingsToGeolayout from .sm64_f3d_writer import SM64Model, SM64GfxFormatter from .sm64_texscroll import modifyTexScrollFiles, modifyTexScrollHeadersGroup @@ -20,6 +20,7 @@ from .sm64_utility import export_rom_checks, starSelectWarning, update_actor_inc from ..utility import ( PluginError, VertexWeightError, + z_up_to_y_up_matrix, setOrigin, raisePluginError, findStartBones, @@ -113,13 +114,11 @@ from .sm64_geolayout_classes import ( RotateNode, TranslateRotateNode, FunctionNode, - CustomNode, BillboardNode, ScaleNode, RenderRangeNode, ShadowNode, DisplayListWithOffsetNode, - CustomAnimatedNode, HeldObjectNode, Geolayout, ) @@ -130,6 +129,17 @@ if typing.TYPE_CHECKING: from .sm64_geolayout_bone import SM64_BoneProperties +def get_custom_cmd_with_transform(node: "CustomNode", parentTransformNode, translate, rotate): + types = {a["arg_type"] for a in node.data["args"]} + has_translation, has_rotation, has_scale = "TRANSLATION" in types, "ROTATION" in types, "SCALE" in types + if (not has_translation and not isZeroTranslation(translate)) or (not has_rotation and not isZeroRotation(rotate)): + field = 0 if not (has_translation or has_rotation) else (1 if has_rotation else 2) + parentTransformNode = addParentNode( + parentTransformNode, TranslateRotateNode(node.drawLayer, field, False, translate, rotate) + ) + return node, parentTransformNode + + def appendSecondaryGeolayout(geoDirPath, geoName1, geoName2, additionalNode=""): geoPath = os.path.join(geoDirPath, "geo.inc.c") geoFile = open(geoPath, "a", newline="\n") @@ -490,6 +500,7 @@ def convertArmatureToGeolayout(armatureObj, obj, convertTransformMatrix, camera, None, None, None, + None, meshGeolayout.nodes[i], [], name, @@ -1148,7 +1159,7 @@ def duplicateNode(transformNode, parentNode, index): def partOfGeolayout(obj): - useGeoEmpty = obj.type == "EMPTY" and checkSM64EmptyUsesGeoLayout(obj.sm64_obj_type) + useGeoEmpty = obj.type == "EMPTY" and checkSM64EmptyUsesGeoLayout(obj) return obj.type == "MESH" or useGeoEmpty @@ -1219,8 +1230,6 @@ def processPreInlineGeo( node = JumpNode(True, None, obj.geoReference) elif inlineGeoConfig.name == "Geo Displaylist": node = DisplayListNode(int(obj.draw_layer_static), obj.dlReference) - elif inlineGeoConfig.name == "Custom Geo Command": - node = CustomNode(obj.customGeoCommand, obj.customGeoCommandArgs) addParentNode(parentTransformNode, node) # Allow this node to be translated/rotated @@ -1242,7 +1251,23 @@ def processInlineGeoNode( elif inlineGeoConfig.name == "Geo Rotation Node": node = RotateNode(obj.draw_layer_static, obj.useDLReference, rotate, obj.dlReference) elif inlineGeoConfig.name == "Geo Scale": - node = ScaleNode(obj.draw_layer_static, scale, obj.useDLReference, obj.dlReference) + node = ScaleNode(obj.draw_layer_static, scale[0], obj.useDLReference, obj.dlReference) + elif inlineGeoConfig.name == "Custom": + local_matrix = ( + mathutils.Matrix.Translation(translate) + @ rotate.to_matrix().to_4x4() + @ mathutils.Matrix.Diagonal(scale).to_4x4() + ) + node = obj.fast64.sm64.custom.get_final_cmd( + obj, + bpy.context.scene.fast64.sm64.blender_to_sm64_scale, + z_up_to_y_up_matrix @ mathutils.Matrix(obj.get("original_mtx_world")) @ z_up_to_y_up_matrix.inverted(), + local_matrix, + obj.draw_layer_static, + obj.useDLReference, + obj.dlReference, + ) + node, parentTransformNode = get_custom_cmd_with_transform(node, parentTransformNode, translate, rotate) else: raise PluginError(f"Ooops! Didnt implement inline geo exporting for {inlineGeoConfig.name}") @@ -1263,11 +1288,11 @@ def processMesh( ): # final_transform = copy.deepcopy(transformMatrix) - useGeoEmpty = obj.type == "EMPTY" and checkSM64EmptyUsesGeoLayout(obj.sm64_obj_type) + useGeoEmpty = obj.type == "EMPTY" and checkSM64EmptyUsesGeoLayout(obj) useSwitchNode = obj.type == "EMPTY" and obj.sm64_obj_type == "Switch" - useInlineGeo = obj.type == "EMPTY" and checkIsSM64InlineGeoLayout(obj.sm64_obj_type) + useInlineGeo = obj.type == "EMPTY" and checkIsSM64InlineGeoLayout(obj) addRooms = isRoot and obj.type == "EMPTY" and obj.sm64_obj_type == "Area Root" and obj.enableRoomSwitch @@ -1277,7 +1302,7 @@ def processMesh( inlineGeoConfig: InlineGeolayoutObjConfig = inlineGeoLayoutObjects.get(obj.sm64_obj_type) processed_inline_geo = False - isPreInlineGeoLayout = checkIsSM64PreInlineGeoLayout(obj.sm64_obj_type) + isPreInlineGeoLayout = checkIsSM64PreInlineGeoLayout(obj) if useInlineGeo and isPreInlineGeoLayout: processed_inline_geo = True processPreInlineGeo(inlineGeoConfig, obj, parentTransformNode) @@ -1356,7 +1381,7 @@ def processMesh( else: if useInlineGeo and not processed_inline_geo: node, parentTransformNode = processInlineGeoNode( - inlineGeoConfig, obj, parentTransformNode, translate, rotate, scale[0] + inlineGeoConfig, obj, parentTransformNode, translate, rotate, scale ) processed_inline_geo = True @@ -1563,6 +1588,7 @@ def processBone( transformMatrix, lastTranslateName, lastRotateName, + last_scale_name, lastDeformName, parentTransformNode, materialOverrides, @@ -1598,40 +1624,38 @@ def processBone( rotateParent = None rotate = bone.matrix_local.decompose()[1] + # Get scale + if last_scale_name is not None: + scaleParent = armatureObj.data.bones[last_scale_name] + scale = (scaleParent.matrix_local.inverted() @ bone.matrix_local).decompose()[2] + else: + scaleParent = None + scale = bone.matrix_local.decompose()[2] + translation = mathutils.Matrix.Translation(translate) rotation = rotate.to_matrix().to_4x4() zeroTranslation = isZeroTranslation(translate) zeroRotation = isZeroRotation(rotate) + zero_scale = isZeroScaleChange(scale) # hasDL = bone.use_deform hasDL = True - if bone.geo_cmd in animatableBoneTypes: - if bone.geo_cmd == "CustomAnimated": - if not bone.fast64.sm64.custom_geo_cmd_macro: - raise PluginError(f'Bone "{boneName}" on armature "{armatureObj.name}" needs a geo command macro.') - node = CustomAnimatedNode(bone.fast64.sm64.custom_geo_cmd_macro, int(bone.draw_layer), translate, rotate) + if bone.geo_cmd == "DisplayListWithOffset": + if not zeroRotation: + node = DisplayListWithOffsetNode(int(bone.draw_layer), hasDL, mathutils.Vector((0, 0, 0))) + + parentTransformNode = addParentNode( + parentTransformNode, TranslateRotateNode(1, 0, False, translate, rotate) + ) + lastTranslateName = boneName lastRotateName = boneName - else: # DisplayListWithOffset - if not zeroRotation: - node = DisplayListWithOffsetNode(int(bone.draw_layer), hasDL, mathutils.Vector((0, 0, 0))) - - parentTransformNode = addParentNode( - parentTransformNode, TranslateRotateNode(1, 0, False, translate, rotate) - ) - - lastTranslateName = boneName - lastRotateName = boneName - else: - node = DisplayListWithOffsetNode(int(bone.draw_layer), hasDL, translate) - lastTranslateName = boneName + else: + node = DisplayListWithOffsetNode(int(bone.draw_layer), hasDL, translate) + lastTranslateName = boneName final_transform = transformMatrix @ translation - elif bone.geo_cmd == "CustomNonAnimated": - if bone.fast64.sm64.custom_geo_cmd_macro == "": - raise PluginError(f'Bone "{boneName}" on armature "{armatureObj.name}" needs a geo command macro.') - node = CustomNode(bone.fast64.sm64.custom_geo_cmd_macro, bone.fast64.sm64.custom_geo_cmd_args) elif bone.geo_cmd == "Function": if bone.geo_func == "": raise PluginError("Function bone " + boneName + " function value is empty.") @@ -1704,6 +1728,21 @@ def processBone( final_transform = transformMatrix @ mathutils.Matrix.Scale(node.scaleValue, 4) elif bone.geo_cmd == "StartRenderArea": node = StartRenderAreaNode(bone.culling_radius) + elif bone.geo_cmd == "Custom": + local_matrix = mathutils.Matrix.LocRotScale(translate, rotate, scale) + world_matrix = z_up_to_y_up_matrix @ bone.matrix_local @ z_up_to_y_up_matrix.inverted() + node = bone_props.custom.get_final_cmd( + bone, bpy.context.scene.fast64.sm64.blender_to_sm64_scale, world_matrix, local_matrix, None, hasDL + ) + node, parentTransformNode = get_custom_cmd_with_transform(node, parentTransformNode, translate, rotate) + if not has_scale and not zero_scale: + parentTransformNode = addParentNode(parentTransformNode, ScaleNode(node.drawLayer, scale[0], False)) + if has_translation: + lastTranslateName = boneName + elif has_rotation: + lastRotateName = boneName + elif has_scale: + last_scale_name = boneName else: raise PluginError("Invalid geometry command: " + bone.geo_cmd) @@ -1809,12 +1848,6 @@ def processBone( if not isinstance(transformNode.node, SwitchNode): # print(boneGroup.name if boneGroup is not None else "Offset") if len(bone.children) > 0: - # print("\tHas Children") - if bone.geo_cmd == "Function": - raise PluginError( - "Function bones cannot have children. They instead affect the next sibling bone in alphabetical order." - ) - # Handle child nodes # nonDeformTransformData should be modified to be sent to children, # otherwise it should not be modified for parent. @@ -1829,6 +1862,7 @@ def processBone( final_transform, lastTranslateName, lastRotateName, + last_scale_name, lastDeformName, transformNode, materialOverrides, @@ -1864,6 +1898,7 @@ def processBone( final_transform, lastTranslateName, lastRotateName, + last_scale_name, lastDeformName, nextStartNode, materialOverrides, @@ -1955,6 +1990,7 @@ def processBone( optionBone.name, optionBone.name, optionBone.name, + optionBone.name, startNode, materialOverrides, namePrefix + "_" + optionBone.name, diff --git a/fast64_internal/sm64/sm64_level_writer.py b/fast64_internal/sm64/sm64_level_writer.py index f075b22..9694460 100644 --- a/fast64_internal/sm64/sm64_level_writer.py +++ b/fast64_internal/sm64/sm64_level_writer.py @@ -2,7 +2,7 @@ from pathlib import Path import bpy, os, math, re, shutil, mathutils from collections import defaultdict from typing import NamedTuple -from dataclasses import dataclass +from dataclasses import dataclass, field from bpy.utils import register_class, unregister_class from ..panels import SM64_Panel from ..operators import ObjectDataExporter @@ -22,6 +22,7 @@ from .sm64_utility import ( ) from ..utility import ( + yUpToZUp, PluginError, getDataFromFile, saveDataToFile, @@ -305,6 +306,7 @@ class LevelScript: self.marioStart = None self.persistentBlocks = PersistentBlocks.new() self.sub_scripts: LevelScript = [] + self.custom_cmds: list["SM64_CustomCmdProperties"] = [] # this is basically a smaller script jumped to from the main one def add_subscript(self, name: str): @@ -357,6 +359,7 @@ class LevelScript: macrosToString(self.segmentLoads), f"\tALLOC_LEVEL_POOL(),", f"\t{self.mario}", + *[f"\t{cmd.to_c(1)}," for cmd in self.custom_cmds], macrosToString(self.levelFunctions), macrosToString(self.modelLoads), f"{self.get_persistent_block(PersistentBlocks.levelCommands, nTabs=1)}\n", @@ -794,6 +797,7 @@ def export_area_c( raise PluginError(f"Error while creating area {area_root.areaIndex}: {str(exc)}") from exc if area.mario_start is not None: prev_level_script.marioStart = area.mario_start + prev_level_script.custom_cmds += area.custom_cmds persistentBlockString = prev_level_script.get_persistent_block( PersistentBlocks.areaCommands, nTabs=2, areaIndex=str(area.index) ) @@ -893,6 +897,23 @@ def exportLevelC(obj, transformMatrix, level_name, exportDir, savePNG, customExp if len(childAreas) == 0: raise PluginError("The level root has no child empties with the 'Area Root' object type.") + for child in obj.children: + if child.type == "EMPTY" and child.sm64_obj_type == "Custom": + custom_props = child.fast64.sm64.custom + if custom_props.preset != "NONE" and custom_props.section == "AREA": + raise PluginError( + f"Object {obj.name} is parented to the level root but should be parented to an area root." + ) + prev_level_script.custom_cmds.append( + custom_props.get_final_cmd( + obj, + bpy.context.scene.fast64.sm64.blender_to_sm64_scale, + child.matrix_world @ yUpToZUp, + child.matrix_local, + name=obj.name, + ) + ) + uses_env_fx = False echoLevels = ["0x00", "0x00", "0x00"] zoomFlags = [False, False, False, False] diff --git a/fast64_internal/sm64/sm64_objects.py b/fast64_internal/sm64/sm64_objects.py index 9aa99e6..ecf4a5a 100644 --- a/fast64_internal/sm64/sm64_objects.py +++ b/fast64_internal/sm64/sm64_objects.py @@ -1,10 +1,11 @@ import math, bpy, mathutils -import os -import traceback from bpy.utils import register_class, unregister_class from bpy.types import UILayout from re import findall, sub from pathlib import Path + +from bpy.utils import register_class, unregister_class + from ..panels import SM64_Panel from ..operators import ObjectDataExporter @@ -12,6 +13,8 @@ from ..utility import ( PluginError, CData, Vector, + yUpToZUp, + y_up_to_z_up, directory_ui_warnings, filepath_ui_warnings, toAlnum, @@ -64,7 +67,6 @@ from .sm64_geolayout_classes import ( RotateNode, TranslateRotateNode, FunctionNode, - CustomNode, BillboardNode, ScaleNode, ) @@ -77,6 +79,8 @@ from .animation import ( SM64_ArmatureAnimProperties, ) +from .custom_cmd.properties import SM64_CustomCmdProperties + enumTerrain = [ ("Custom", "Custom", "Custom"), ("TERRAIN_GRASS", "Grass", "Grass"), @@ -237,26 +241,28 @@ inlineGeoLayoutObjects = { "Geo Billboard": InlineGeolayoutObjConfig("Geo Billboard", BillboardNode, can_have_dl=True, uses_location=True), "Geo Scale": InlineGeolayoutObjConfig("Geo Scale", ScaleNode, can_have_dl=True, uses_scale=True), "Geo Displaylist": InlineGeolayoutObjConfig("Geo Displaylist", DisplayListNode, must_have_dl=True), - "Custom Geo Command": InlineGeolayoutObjConfig("Custom Geo Command", CustomNode), + "Custom": InlineGeolayoutObjConfig("Custom", "CustomCmd"), } # When adding new types related to geolayout, # Make sure to add exceptions to enumSM64EmptyWithGeolayout enumObjectType = [ - ("None", "None", "None"), - ("Level Root", "Level Root", "Level Root"), - ("Area Root", "Area Root", "Area Root"), - ("Object", "Object", "Object"), - ("Macro", "Macro", "Macro"), - ("Special", "Special", "Special"), - ("Mario Start", "Mario Start", "Mario Start"), - ("Whirlpool", "Whirlpool", "Whirlpool"), - ("Water Box", "Water Box", "Water Box"), - ("Camera Volume", "Camera Volume", "Camera Volume"), - ("Switch", "Switch Node", "Switch Node"), - ("Puppycam Volume", "Puppycam Volume", "Puppycam Volume"), - ("", "Inline Geolayout Commands", ""), # This displays as a column header for the next set of options - *[(key, key, key) for key in inlineGeoLayoutObjects.keys()], + ("None", "None", "None", 0), + ("Level Root", "Level Root", "Level Root", 1), + ("Area Root", "Area Root", "Area Root", 2), + ("Object", "Object", "Object", 3), + ("Macro", "Macro", "Macro", 4), + ("Special", "Special", "Special", 5), + ("Mario Start", "Mario Start", "Mario Start", 6), + ("Whirlpool", "Whirlpool", "Whirlpool", 7), + ("Water Box", "Water Box", "Water Box", 8), + ("Camera Volume", "Camera Volume", "Camera Volume", 9), + ("Switch", "Switch Node", "Switch Node", 10), + ("Puppycam Volume", "Puppycam Volume", "Puppycam Volume", 11), + ("", "Inline Geolayout Commands", "", 12), # This displays as a column header for the next set of options + *[(key, key, key, i) for i, key in enumerate(list(inlineGeoLayoutObjects.keys())[:-1], start=13)], # exclude custom + ("", "", "", 12), + ("Custom", "Custom", "Custom level script command", 21), ] enumPuppycamMode = [ @@ -303,7 +309,7 @@ class SM64_Object: self.rotation = rotation self.name = name # to sort by when exporting - def to_c(self): + def to_c(self, _depth=0): if self.acts == 0x1F: return ( "OBJECT(" @@ -360,7 +366,7 @@ class SM64_Whirpool: self.position = position self.name = "whirlpool" # for sorting - def to_c(self): + def to_c(self, _depth=0): return ( "WHIRPOOL(" + str(self.index) @@ -385,7 +391,7 @@ class SM64_Macro_Object: self.position = position self.rotation = rotation - def to_c(self): + def to_c(self, _depth=0): if self.bparam is None: return ( "MACRO_OBJECT(" @@ -437,7 +443,7 @@ class SM64_Special_Object: data.extend(int(self.bparam).to_bytes(2, "big")) return data - def to_c(self): + def to_c(self, _depth=0): if self.rotation is None: return ( "SPECIAL_OBJECT(" @@ -448,7 +454,7 @@ class SM64_Special_Object: + str(int(round(self.position[1]))) + ", " + str(int(round(self.position[2]))) - + "),\n" + + ")" ) elif self.bparam is None: return ( @@ -462,7 +468,7 @@ class SM64_Special_Object: + str(int(round(self.position[2]))) + ", " + str(int(round(math.degrees(self.rotation[1])))) - + "),\n" + + ")" ) else: return ( @@ -478,7 +484,7 @@ class SM64_Special_Object: + str(int(round(math.degrees(self.rotation[1])))) + ", " + str(self.bparam) - + "),\n" + + ")" ) @@ -489,7 +495,7 @@ class SM64_Mario_Start: self.rotation = rotation self.name = "Mario" # for sorting - def to_c(self): + def to_c(self, _depth=0): return ( "MARIO_POS(" + str(self.area) @@ -526,6 +532,7 @@ class SM64_Area: self.mario_start = None self.splines = [] self.startDialog = startDialog + self.custom_cmds = [] def macros_name(self): return self.name + "_macro_objs" @@ -537,7 +544,7 @@ class SM64_Area: data += "\t\t" + warpNode + ",\n" # export objects in name order for obj in sorted(self.objects, key=(lambda obj: obj.name)): - data += "\t\t" + obj.to_c() + ",\n" + data += "\t\t" + obj.to_c(2) + ",\n" data += "\t\tTERRAIN(" + self.collision.name + "),\n" if includeRooms: data += "\t\tROOMS(" + self.collision.rooms_name() + "),\n" @@ -558,7 +565,7 @@ class SM64_Area: data.header = "extern const MacroObject " + self.macros_name() + "[];\n" data.source += "const MacroObject " + self.macros_name() + "[] = {\n" for macro in self.macros: - data.source += "\t" + macro.to_c() + ",\n" + data.source += "\t" + macro.to_c(1) + ",\n" data.source += "\tMACRO_OBJECT_END(),\n};\n\n" return data @@ -566,13 +573,13 @@ class SM64_Area: def to_c_camera_volumes(self): data = "" for camVolume in self.cameraVolumes: - data += "\t" + camVolume.to_c() + "\n" + data += "\t" + camVolume.to_c(1) + ",\n" return data def to_c_puppycam_volumes(self): data = "" for puppycamVolume in self.puppycamVolumes: - data += "\t" + puppycamVolume.to_c() + "\n" + data += "\t" + puppycamVolume.to_c(1) + ",\n" return data def hasCutsceneSpline(self): @@ -609,7 +616,7 @@ class CollisionWaterBox: data.extend(int(round(self.height)).to_bytes(2, "big", signed=True)) return data - def to_c(self): + def to_c(self, _depth=0): data = ( "COL_WATER_BOX(" + ("0x00" if self.waterBoxType == "Water" else "0x32") @@ -623,7 +630,7 @@ class CollisionWaterBox: + str(int(round(self.high[1]))) + ", " + str(int(round(self.height))) - + "),\n" + + ")" ) return data @@ -641,7 +648,7 @@ class CameraVolume: def to_binary(self): raise PluginError("Binary exporting not implemented for camera volumens.") - def to_c(self): + def to_c(self, _depth=0): data = ( "{" + str(self.area) @@ -661,7 +668,7 @@ class CameraVolume: + str(int(round(self.scale[2]))) + ", " + str(convertRadiansToS16(self.rotation[1])) - + "}," + + "}" ) return data @@ -694,7 +701,7 @@ class PuppycamVolume: def to_binary(self): raise PluginError("Binary exporting not implemented for puppycam volumes.") - def to_c(self): + def to_c(self, _depth=0): data = ( "{" + str(self.level) @@ -730,7 +737,7 @@ class PuppycamVolume: + str(int(round(self.camFocus[1]))) + ", " + str(int(round(self.camFocus[2]))) - + "}," + + "}" ) return data @@ -805,12 +812,40 @@ def process_sm64_objects(obj, area, rootMatrix, transformMatrix, specialsOnly): ) # Hacky solution to handle Z-up to Y-up conversion - rotation = (originalRotation @ mathutils.Quaternion((1, 0, 0), math.radians(90.0))).to_euler("ZXY") + rotation = (originalRotation @ y_up_to_z_up).to_euler("ZXY") if obj.type == "EMPTY": if obj.sm64_obj_type == "Area Root" and obj.areaIndex != area.index: return - if specialsOnly: + obj_props: SM64_ObjectProperties = obj.fast64.sm64 + if obj.sm64_obj_type == "Custom" and ( + (obj_props.custom.cmd_type == "Collision" and specialsOnly) + or ((obj_props.custom.cmd_type == "Level") and not specialsOnly) + ): + # HACK: alternatively we could just ignore transformMatrix since it only has the blender to sm64 scale + sm64_scale = bpy.context.scene.fast64.sm64.blender_to_sm64_scale + reverse = mathutils.Matrix.Diagonal((sm64_scale,) * 3).to_4x4().inverted() + local_matrix = reverse @ final_transform @ yUpToZUp + cmd = obj_props.custom.get_final_cmd( + obj, + bpy.context.scene.fast64.sm64.blender_to_sm64_scale, + reverse @ (transformMatrix @ obj.matrix_world) @ yUpToZUp, + local_matrix, + name=obj.name, + ) + if specialsOnly: + area.specials.append(cmd) + else: + if obj_props.custom.preset != "NONE": + if obj_props.custom.section == "FORCE_LEVEL": + area.custom_cmds.append(cmd) + return + elif obj_props.custom.section == "LEVEL": + raise PluginError( + f"Object {obj.name} is parented to an area but should be parented to the level root instead." + ) + area.objects.append(cmd) + elif specialsOnly: if obj.sm64_obj_type == "Special": preset = obj.sm64_special_enum if obj.sm64_special_enum != "Custom" else obj.sm64_obj_preset area.specials.append( @@ -1123,11 +1158,6 @@ class SM64ObjectPanel(bpy.types.Panel): prop_split(box, obj.fast64.sm64.geo_asm, "param", "Parameter") return - elif obj.sm64_obj_type == "Custom Geo Command": - prop_split(box, obj, "customGeoCommand", "Geo Macro") - prop_split(box, obj, "customGeoCommandArgs", "Parameters") - return - if obj_details.can_have_dl: prop_split(box, obj, "draw_layer_static", "Draw Layer") @@ -1152,7 +1182,7 @@ class SM64ObjectPanel(bpy.types.Panel): info_box.label(text="Scale", icon="DOT") if len(obj.children): - if checkIsSM64PreInlineGeoLayout(obj.sm64_obj_type): + if checkIsSM64PreInlineGeoLayout(obj): box.box().label(text="Children of this object will just be the following geo commands.") else: box.box().label(text="Children of this object will be wrapped in GEO_OPEN_NODE and GEO_CLOSE_NODE.") @@ -1181,12 +1211,14 @@ class SM64ObjectPanel(bpy.types.Panel): parent_box.separator() def draw(self, context): + sm64_props = context.scene.fast64.sm64 + prop_split(self.layout, context.scene, "gameEditorMode", "Game") box = self.layout.box().column() column = self.layout.box().column() # added just for puppycam trigger importing box.box().label(text="SM64 Object Inspector") obj = context.object - props = obj.fast64.sm64 + obj_props: SM64_ObjectProperties = obj.fast64.sm64 prop_split(box, obj, "sm64_obj_type", "Object Type") if obj.sm64_obj_type == "Object": @@ -1257,7 +1289,7 @@ class SM64ObjectPanel(bpy.types.Panel): prop_split(box, levelObj, "backgroundSegment", "Custom Background Segment") segmentExportBox = box.box() segmentExportBox.label( - text=f"Exported Segment: _{levelObj.backgroundSegment}_{context.scene.fast64.sm64.compression_format}SegmentRomStart" + text=f"Exported Segment: _{levelObj.backgroundSegment}_{sm64_props.compression_format}SegmentRomStart" ) box.prop(obj, "useBackgroundColor") # box.box().label(text = 'Background IDs defined in include/geo_commands.h.') @@ -1268,7 +1300,7 @@ class SM64ObjectPanel(bpy.types.Panel): obj.starGetCutscenes.draw(box) elif obj.sm64_obj_type == "Area Root": - area_props = props.area + area_props = obj_props.area # Code that used to be in area inspector prop_split(box, obj, "areaIndex", "Area Index") box.prop(obj, "noMusic", text="Disable Music") @@ -1391,12 +1423,19 @@ class SM64ObjectPanel(bpy.types.Panel): prop_split(box, obj, "switchParam", "Parameter") box.box().label(text="Children will ordered alphabetically.") + elif obj.sm64_obj_type == "Custom": + custom_props: SM64_CustomCmdProperties = obj_props.custom + custom_props.draw_props(box, sm64_props.binary_export, obj, blender_scale=sm64_props.blender_to_sm64_scale) + elif obj.sm64_obj_type in inlineGeoLayoutObjects: self.draw_inline_obj(box, obj) elif obj.sm64_obj_type == "None": box.box().label(text="This can be used as an empty transform node in a geolayout hierarchy.") + else: + multilineLabel(box, "Unknown object type: " + obj.sm64_obj_type) + def draw_acts(self, obj, layout): layout.label(text="Acts") acts = layout.row() @@ -2513,7 +2552,7 @@ class WarpNodeProperty(bpy.types.PropertyGroup): ret.z = int(round(-difference.y * bpy.context.scene.blenderF3DScale)) return ret - def to_c(self): + def to_c(self, _depth=0): if self.warpType == "Instant": offset = Vector() @@ -2970,13 +3009,14 @@ class SM64_SegmentProperties(bpy.types.PropertyGroup): class SM64_ObjectProperties(bpy.types.PropertyGroup): version: bpy.props.IntProperty(name="SM64_ObjectProperties Version", default=0) - cur_version = 3 # version after property migration + cur_version = 4 # version after property migration geo_asm: bpy.props.PointerProperty(type=SM64_GeoASMProperties) level: bpy.props.PointerProperty(type=SM64_LevelProperties) area: bpy.props.PointerProperty(type=SM64_AreaProperties) game_object: bpy.props.PointerProperty(type=SM64_GameObjectProperties) segment_loads: bpy.props.PointerProperty(type=SM64_SegmentProperties) + custom: bpy.props.PointerProperty(type=SM64_CustomCmdProperties) animation: bpy.props.PointerProperty(type=SM64_ArmatureAnimProperties) @@ -2987,6 +3027,7 @@ class SM64_ObjectProperties(bpy.types.PropertyGroup): SM64_GeoASMProperties.upgrade_object(obj) if obj.fast64.sm64.version < 3: SM64_GameObjectProperties.upgrade_object(obj) + obj.fast64.sm64.custom.upgrade_object(obj) obj.fast64.sm64.version = SM64_ObjectProperties.cur_version @@ -3201,9 +3242,6 @@ def sm64_obj_register(): bpy.types.Object.geoReference = bpy.props.StringProperty(name="Geolayout variable name or hex address for binary") - bpy.types.Object.customGeoCommand = bpy.props.StringProperty(name="Geolayout macro command", default="") - bpy.types.Object.customGeoCommandArgs = bpy.props.StringProperty(name="Geolayout macro arguments", default="") - bpy.types.Object.enableRoomSwitch = bpy.props.BoolProperty(name="Enable Room System") diff --git a/fast64_internal/utility.py b/fast64_internal/utility.py index 4bed3e4..6277b70 100644 --- a/fast64_internal/utility.py +++ b/fast64_internal/utility.py @@ -5,6 +5,7 @@ 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 @@ -32,6 +33,25 @@ 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 @@ -40,7 +60,11 @@ 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))) -yUpToZUp = mathutils.Quaternion((1, 0, 0), math.radians(90.0)).to_matrix().to_4x4() +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"), @@ -789,6 +813,8 @@ def store_original_mtx(): # 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): @@ -949,35 +975,35 @@ def duplicateHierarchy(obj, ignoreAttr, includeEmpties, areaIndex): raise Exception(str(e)) -enumSM64PreInlineGeoLayoutObjects = {"Geo ASM", "Geo Branch", "Geo Displaylist", "Custom Geo Command"} +enumSM64PreInlineGeoLayoutObjects = {"Geo ASM", "Geo Branch", "Geo Displaylist"} -def checkIsSM64PreInlineGeoLayout(sm64_obj_type): - return sm64_obj_type in enumSM64PreInlineGeoLayoutObjects +def checkIsSM64PreInlineGeoLayout(obj): + return obj.sm64_obj_type in enumSM64PreInlineGeoLayoutObjects enumSM64InlineGeoLayoutObjects = { - "Geo ASM", - "Geo Branch", "Geo Translate/Rotate", "Geo Translate Node", "Geo Rotation Node", "Geo Billboard", "Geo Scale", - "Geo Displaylist", - "Custom Geo Command", } -def checkIsSM64InlineGeoLayout(sm64_obj_type): - return sm64_obj_type in enumSM64InlineGeoLayoutObjects +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(sm64_obj_type): - return sm64_obj_type in enumSM64EmptyWithGeolayout or checkIsSM64InlineGeoLayout(sm64_obj_type) +def checkSM64EmptyUsesGeoLayout(obj): + return obj.sm64_obj_type in enumSM64EmptyWithGeolayout or checkIsSM64InlineGeoLayout(obj) def selectMeshChildrenOnly(obj, ignoreAttr, includeEmpties, areaIndex): @@ -986,7 +1012,7 @@ def selectMeshChildrenOnly(obj, ignoreAttr, includeEmpties, areaIndex): return ignoreObj = ignoreAttr is not None and getattr(obj, ignoreAttr) isMesh = obj.type == "MESH" - isEmpty = obj.type == "EMPTY" and includeEmpties and checkSM64EmptyUsesGeoLayout(obj.sm64_obj_type) + 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 @@ -1020,6 +1046,8 @@ def cleanupTempMeshes(): 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) @@ -1124,6 +1152,17 @@ def writeInsertableFile(filepath, dataType, address_ptrs, startPtr, 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)) @@ -1344,8 +1383,14 @@ def exportColor(lightColor): return [scaleToU8(value) for value in gammaCorrect(lightColor)] -def get_clean_color(srgb: list, include_alpha=False, round_color=True) -> list: - return [round(channel, 4) if round_color else channel for channel in list(srgb[: 4 if include_alpha else 3])] +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): @@ -1775,6 +1820,10 @@ binOps = { 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, }