Browse Source

Merge remote-tracking branch 'hoikas/logic-nodes'

pull/10/head
Adam Johnson 10 years ago
parent
commit
52c122696d
  1. 4
      korman/nodes/node_conditions.py
  2. 10
      korman/nodes/node_core.py
  3. 270
      korman/nodes/node_messages.py
  4. 85
      korman/nodes/node_responder.py
  5. 6
      korman/operators/op_export.py
  6. 4
      korman/properties/modifiers/anim.py
  7. 38
      korman/properties/modifiers/logic.py
  8. 25
      korman/ui/modifiers/logic.py

4
korman/nodes/node_conditions.py

@ -49,7 +49,9 @@ class PlasmaClickableNode(PlasmaNodeBase, bpy.types.Node):
# Case: sitting modifier (exports from sit position empty)
if self.clickable:
clickable_bo = bpy.data.objects[self.clickable]
clickable_so = exporter.mgr.find_create_key(plSceneObject, bl=clickable_bo).object
clickable_so = exporter.mgr.find_create_object(plSceneObject, bl=clickable_bo)
# We're deep inside a potentially unrelated node tree...
exporter.export_coordinate_interface(clickable_so, clickable_bo)
else:
clickable_bo = parent_bo
clickable_so = parent_so

10
korman/nodes/node_core.py

@ -56,6 +56,16 @@ class PlasmaNodeBase:
return i
raise KeyError(key)
def find_input_sockets(self, key, idname=None):
for i in self.inputs:
if i.identifier == key:
if idname is None:
yield i
elif i.links:
node = i.links[0].from_node
if idname == node.bl_idname:
yield i
def find_output(self, key, idname=None):
for i in self.outputs:
if i.identifier == key:

270
korman/nodes/node_messages.py

@ -19,12 +19,276 @@ from PyHSPlasma import *
from .node_core import *
from ..properties.modifiers.region import footstep_surfaces, footstep_surface_ids
from ..exporter import ExportError
class PlasmaMessageSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket):
class PlasmaMessageSocketBase(PlasmaNodeSocketBase):
bl_color = (0.004, 0.282, 0.349, 1.0)
class PlasmaMessageSocket(PlasmaMessageSocketBase, bpy.types.NodeSocket):
pass
class PlasmaFootstepSoundMsgNode(PlasmaNodeBase, bpy.types.Node):
class PlasmaMessageNode(PlasmaNodeVariableInput):
@property
def has_callbacks(self):
"""This message has callbacks that can be waited on by a Responder"""
return False
def init(self, context):
self.inputs.new("PlasmaMessageSocket", "Sender", "sender")
def update(self):
self.ensure_sockets("PlasmaMessageSocket", "Sender", "sender")
class PlasmaAnimCmdMsgNode(PlasmaMessageNode, bpy.types.Node):
bl_category = "MSG"
bl_idname = "PlasmaAnimCmdMsgNode"
bl_label = "Animation Command"
bl_width_default = 190
anim_type = EnumProperty(name="Type",
description="Animation type to affect",
items=[("OBJECT", "Object", "Mesh Action"),
("TEXTURE", "Texture", "Texture Action")],
default="OBJECT")
object_name = StringProperty(name="Object",
description="Target object name")
material_name = StringProperty(name="Material",
description="Target material name")
texture_name = StringProperty(name="Texture",
description="Target texture slot name")
go_to = EnumProperty(name="Go To",
description="Where should the animation start?",
items=[("kGoToBegin", "Beginning", "The beginning"),
("kGoToLoopBegin", "Loop Beginning", "The beginning of the active loop"),
("CURRENT", "(Don't Change)", "The current position"),
("kGoToEnd", "Ending", "The end"),
("kGoToLoopEnd", "Loop Ending", "The end of the active loop")],
default="CURRENT")
action = EnumProperty(name="Action",
description="What do you want the animation to do?",
items=[("kContinue", "Play", "Plays the animation"),
("kPlayToPercent", "Play to Percent", "Plays the animation until a given percent is complete"),
("kPlayToTime", "Play to Frame", "Plays the animation up to a given frame number"),
("kStop", "Stop", "Stops the animation",),
("kToggleState", "Toggle", "Toggles between Play and Stop"),
("CURRENT", "(Don't Change)", "Don't change the animation's playing state")],
default="CURRENT")
play_direction = EnumProperty(name="Direction",
description="Which direction do you want to play from?",
items=[("kSetForwards", "Forward", "Play forwards"),
("kSetBackwards", "Backwards", "Play backwards"),
("CURRENT", "(Don't Change)", "Don't change the play direction")],
default="CURRENT")
play_to_percent = IntProperty(name="Play To",
description="Percentage at which to stop the animation",
subtype="PERCENTAGE",
min=0, max=100, default=50)
play_to_frame = IntProperty(name="Play To",
description="Frame at which to stop the animation",
min=0)
def _set_loop_name(self, context):
"""Updates loop_begin and loop_end when the loop name is changed"""
pass
looping = EnumProperty(name="Looping",
description="Is the animation looping?",
items=[("kSetLooping", "Yes", "The animation is looping",),
("CURRENT", "(Don't Change)", "Don't change the loop status"),
("kSetUnLooping", "No", "The animation is NOT looping")],
default="CURRENT")
loop_name = StringProperty(name="Active Loop",
description="Name of the active loop",
update=_set_loop_name)
loop_begin = IntProperty(name="Loop Begin",
description="Frame number at which the loop begins",
min=0)
loop_end = IntProperty(name="Loop End",
description="Frame number at which the loop ends",
min=0)
event = EnumProperty(name="Callback",
description="Event upon which to callback the Responder",
items=[("kEnd", "End", "When the action ends"),
("NONE", "(None)", "Don't notify the Responder at all"),
("kStop", "Stop", "When the action is stopped by a message")],
default="kEnd")
def draw_buttons(self, context, layout):
layout.prop(self, "anim_type")
if self.anim_type == "OBJECT":
layout.prop_search(self, "object_name", bpy.data, "objects")
else:
layout.prop_search(self, "material_name", bpy.data, "materials")
material = bpy.data.materials.get(self.material_name, None)
if material is not None:
layout.prop_search(self, "texture_name", material, "texture_slots")
layout.prop(self, "go_to")
layout.prop(self, "action")
layout.prop(self, "play_direction")
if self.action == "kPlayToPercent":
layout.prop(self, "play_to_percent")
elif self.action == "kPlayToTime":
layout.prop(self, "play_to_frame")
layout.prop(self, "looping")
col = layout.column()
col.enabled = self.looping != "CURRENT"
if self.anim_type != "OBJECT":
loops = None
else:
obj = bpy.data.objects.get(self.object_name, None)
loops = None if obj is None else obj.plasma_modifiers.animation_loop
if loops is not None and loops.enabled:
layout.prop_search(self, "loop_name", loops, "loops", icon="PMARKER_ACT")
else:
layout.prop(self, "loop_begin")
layout.prop(self, "loop_end")
layout.prop(self, "event")
def convert_callback_message(self, exporter, tree, so, msg, target, wait):
cb = plEventCallbackMsg()
cb.addReceiver(target)
cb.event = globals()[self.event]
cb.user = wait
msg.addCallback(cb)
def convert_message(self, exporter, tree, so):
msg = plAnimCmdMsg()
# We're either sending this off to an AGMasterMod or a LayerAnim
error = ExportError("Node '{}' in '{}' specifies an invalid animation".format(self.name, tree.name))
if self.anim_type == "OBJECT":
obj = bpy.data.objects.get(self.object_name, None)
if obj is None:
raise error
anim = obj.plasma_modifiers.animation
if not anim.enabled:
raise error
target = exporter.mgr.find_create_key(plAGMasterMod, bl=obj, name=anim.display_name)
else:
material = bpy.data.materials.get(self.material_name, None)
if material is None:
raise error
tex_slot = material.texture_slots.get(self.texture_name, None)
if tex_slot is None:
raise error
name = "{}_{}_LayerAnim".format(self.material_name, self.texture_name)
target = exporter.mgr.find_create_key(plLayerAnimation, name=name, so=so)
if target is None:
raise error
msg.addReceiver(target)
# Check the enum properties to see what commands we need to add
for prop in (self.go_to, self.action, self.play_direction, self.looping):
cmd = getattr(plAnimCmdMsg, prop, None)
if cmd is not None:
msg.setCmd(cmd, True)
# Easier part starts here???
fps = bpy.context.scene.render.fps
if self.action == "kPlayToPercent":
msg.time = self.play_to_percent
elif self.action == "kPlayToTime":
msg.time = self.play_to_frame / fps
# Implicit s better than explicit, I guess...
if self.loop_begin != self.loop_end:
# NOTE: loop name is not used in the engine AFAICT
msg.setCmd(plAnimCmdMsg.kSetLoopBegin, True)
msg.setCmd(plAnimCmdMsg.kSetLoopEnd, True)
msg.loopBegin = self.loop_begin / fps
msg.loopEnd = self.loop_end / fps
# Whew, this was crazy
return msg
@property
def has_callbacks(self):
return self.event != "NONE"
class PlasmaOneShotMsgNode(PlasmaMessageNode, bpy.types.Node):
bl_category = "MSG"
bl_idname = "PlasmaOneShotMsgNode"
bl_label = "One Shot"
bl_width_default = 210
pos = StringProperty(name="Position",
description="Object defining the OneShot position")
seek = EnumProperty(name="Seek",
description="How the avatar should approach the OneShot position",
items=[("SMART", "Smart Seek", "Let the engine figure out the best path"),
("DUMB", "Seek", "Shuffle to the OneShot position"),
("NONE", "Warp", "Warp the avatar to the OneShot position")],
default="SMART")
animation = StringProperty(name="Animation",
description="Name of the animation the avatar should execute")
marker = StringProperty(name="Marker",
description="Name of the marker specifying when to notify the Responder")
drivable = BoolProperty(name="Drivable",
description="Player retains control of the avatar during the OneShot",
default=False)
reversable = BoolProperty(name="Reversable",
description="Player can reverse the OneShot",
default=False)
def convert_callback_message(self, exporter, tree, so, msg, target, wait):
msg.addCallback(self.marker, target, wait)
def convert_message(self, exporter, tree, so):
msg = plOneShotMsg()
msg.addReceiver(self.get_key(exporter, tree, so))
return msg
def draw_buttons(self, context, layout):
layout.prop(self, "animation", text="Anim")
layout.prop(self, "marker")
row = layout.row()
row.prop(self, "drivable")
row.prop(self, "reversable")
layout.prop_search(self, "pos", bpy.data, "objects", icon="EMPTY_DATA")
layout.prop(self, "seek")
def export(self, exporter, tree, bo, so):
oneshotmod = self.get_key(exporter, tree, so).object
oneshotmod.animName = self.animation
oneshotmod.drivable = self.drivable
oneshotmod.reversable = self.reversable
oneshotmod.smartSeek = self.seek == "SMART"
oneshotmod.noSeek = self.seek == "NONE"
oneshotmod.seekDuration = 1.0
def get_key(self, exporter, tree, so):
name = self.create_key_name(tree)
if self.pos:
bo = bpy.data.objects.get(self.pos, None)
if bo is None:
raise ExportError("Node '{}' in '{}' specifies an invalid Position Empty".format(self.name, tree.name))
pos_so = exporter.mgr.find_create_object(plSceneObject, bl=bo)
return exporter.mgr.find_create_key(plOneShotMod, name=name, so=pos_so)
else:
return exporter.mgr.find_create_key(plOneShotMod, name=name, so=so)
@property
def has_callbacks(self):
return bool(self.marker)
class PlasmaOneShotCallbackSocket(PlasmaMessageSocketBase, bpy.types.NodeSocket):
marker = StringProperty(name="Marker",
description="Marker specifying the time at which to send a callback to this Responder")
def draw(self, context, layout, node, text):
layout.prop(self, "marker")
class PlasmaFootstepSoundMsgNode(PlasmaMessageNode, bpy.types.Node):
bl_category = "MSG"
bl_idname = "PlasmaFootstepSoundMsgNode"
bl_label = "Footstep Sound"
@ -40,7 +304,7 @@ class PlasmaFootstepSoundMsgNode(PlasmaNodeBase, bpy.types.Node):
def draw_buttons(self, context, layout):
layout.prop(self, "surface")
def convert_message(self, exporter):
def convert_message(self, exporter, tree, so):
msg = plArmatureEffectStateMsg()
msg.surface = footstep_surface_ids[self.surface]
return msg

85
korman/nodes/node_responder.py

@ -41,8 +41,6 @@ class PlasmaResponderNode(PlasmaNodeVariableInput, bpy.types.Node):
self.outputs.new("PlasmaRespStateSocket", "States", "states")
def draw_buttons(self, context, layout):
self.ensure_sockets("PlasmaConditionSocket", "Condition", "condition")
layout.prop(self, "detect_trigger")
layout.prop(self, "detect_untrigger")
layout.prop(self, "no_ff_sounds")
@ -71,10 +69,10 @@ class PlasmaResponderNode(PlasmaNodeVariableInput, bpy.types.Node):
def get_state(self, node):
for idx, (theNode, theState) in enumerate(self.states):
if theNode == node:
return (idx, theState)
return (idx, theState, True)
state = plResponderModifier_State()
self.states.append((node, state))
return (len(self.states) - 1, state)
return (len(self.states) - 1, state, False)
def save(self):
resp = self.responder
@ -85,9 +83,12 @@ class PlasmaResponderNode(PlasmaNodeVariableInput, bpy.types.Node):
# Convert the Responder states
stateMgr = ResponderStateMgr(self, responder)
for stateNode in self.find_outputs("states", "PlasmaResponderStateNode"):
stateNode.convert_state(exporter, stateMgr)
stateNode.convert_state(exporter, tree, so, stateMgr)
stateMgr.save()
def update(self):
self.ensure_sockets("PlasmaConditionSocket", "Condition", "condition")
class PlasmaResponderStateNode(PlasmaNodeVariableInput, bpy.types.Node):
bl_category = "LOGIC"
@ -104,15 +105,10 @@ class PlasmaResponderStateNode(PlasmaNodeVariableInput, bpy.types.Node):
self.outputs.new("PlasmaRespStateSocket", "Trigger", "gotostate").link_limit = 1
def draw_buttons(self, context, layout):
# This actually draws nothing, but it makes sure we have at least one empty input slot
# We need this because it's possible that multiple OTHER states can call us
self.ensure_sockets("PlasmaRespStateSocket", "Condition", "condition")
# Now draw a prop
layout.prop(self, "default_state")
def convert_state(self, exporter, stateMgr):
idx, state = stateMgr.get_state(self)
def convert_state(self, exporter, tree, so, stateMgr):
idx, state, converted = stateMgr.get_state(self)
# No sanity checking here. Hopefully nothing crazy has happened in the UI.
if self.default_state:
@ -123,30 +119,25 @@ class PlasmaResponderStateNode(PlasmaNodeVariableInput, bpy.types.Node):
if toStateNode is None:
state.switchToState = idx
else:
toIdx, toState = stateMgr.get_state(toStateNode)
toIdx, toState, converted = stateMgr.get_state(toStateNode)
state.switchToState = toIdx
if not converted:
toStateNode.convert_state(exporter, tree, so, stateMgr)
class CommandMgr:
def __init__(self):
self.commands = []
self.waits = {}
def add_command(self, node):
cmd = type("ResponderCommand", (), {"msg": None, "waitOn": -1})
def add_command(self, node, waitOn):
cmd = type("ResponderCommand", (), {"msg": None, "waitOn": waitOn})
self.commands.append((node, cmd))
return (len(self.commands) - 1, cmd)
def add_wait(self, parentCmd):
try:
idx = self.commands.index(parentCmd)
except ValueError:
# The parent command didn't export for some reason... Probably no message.
# So, wait on nothing!
return -1
else:
def add_wait(self, parentIdx):
wait = len(self.waits)
self.waits[wait] = idx
return idx
self.waits[wait] = parentIdx
return wait
def save(self, state):
for node, cmd in self.commands:
@ -161,9 +152,20 @@ class PlasmaResponderStateNode(PlasmaNodeVariableInput, bpy.types.Node):
for i in self.find_outputs("cmds", "PlasmaResponderCommandNode"):
# slight optimization--commands attached to states can't wait on other commands
# namely because it's impossible to wait on a command that doesn't exist...
i.convert_command(exporter, stateMgr.responder, commands, True)
i.convert_command(exporter, tree, so, stateMgr.responder, commands)
commands.save(state)
def update(self):
# This actually draws nothing, but it makes sure we have at least one empty input slot
# We need this because it's possible that multiple OTHER states can call us
self.ensure_sockets("PlasmaRespStateSocket", "Condition", "condition")
# Check to see if we're the default state
if not self.default_state:
inputs = list(self.find_input_sockets("condition", "PlasmaResponderNode"))
if len(inputs) == 1:
self.default_state = True
class PlasmaRespStateSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket):
bl_color = (0.388, 0.78, 0.388, 1.0)
@ -179,36 +181,31 @@ class PlasmaResponderCommandNode(PlasmaNodeBase, bpy.types.Node):
self.outputs.new("PlasmaMessageSocket", "Message", "msg")
self.outputs.new("PlasmaRespCommandSocket", "Trigger", "trigger")
def convert_command(self, exporter, responder, commandMgr, forceNoWait=False):
def convert_command(self, exporter, tree, so, responder, commandMgr, waitOn=-1):
# If this command has no message, there is no need to export it...
msgNode = self.find_output("msg")
if msgNode is not None:
idx, command = commandMgr.add_command(self)
# If the thingthatdoneit is another command, we need to register a wait.
# We could hack and assume the parent is idx-1, but that won't work if the parent has many
# child commands. Le whoops!
if not forceNoWait:
parentCmd = self.find_input("whodoneit", "PlasmaResponderCommandNode")
if parentCmd is not None:
command.waitOn = commandMgr.add_wait(parentCmd)
idx, command = commandMgr.add_command(self, waitOn)
# Finally, convert our message...
msg = msgNode.convert_message(exporter)
msg = msgNode.convert_message(exporter, tree, so)
self._finalize_message(exporter, responder, msg)
# If we have child commands, we need to make sure that we support chaining this message as a callback
# If not, we'll export our children and tell them to not actually wait on us.
haveChildren = self.find_output("trigger", "PlasmaResponderCommandNode") is not None
if haveChildren:
nowait = not self._add_msg_callback(exporter, responder, msg)
if haveChildren and msgNode.has_callbacks:
childWaitOn = commandMgr.add_wait(idx)
msgNode.convert_callback_message(exporter, tree, so, msg, responder.key, childWaitOn)
else:
childWaitOn = -1
command.msg = msg
else:
nowait = True
childWaitOn = -1
# Export any child commands
for i in self.find_outputs("trigger", "PlasmaResponderCommandNode"):
i.convert_command(exporter, responder, commandMgr, nowait)
i.convert_command(exporter, tree, so, responder, commandMgr, childWaitOn)
_bcast_flags = {
plArmatureEffectStateMsg: (plMessage.kPropagateToModifiers | plMessage.kNetPropagate),
@ -223,12 +220,6 @@ class PlasmaResponderCommandNode(PlasmaNodeBase, bpy.types.Node):
msg.BCastFlags = self._bcast_flags[_cls]
msg.BCastFlags |= plMessage.kLocalPropagate
def _add_msg_callback(self, exporter, responder, msg):
"""Prepares a given message to be a callback to the responder"""
# We do not support callback messages ATM
return False
class PlasmaRespCommandSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket):
bl_color = (0.451, 0.0, 0.263, 1.0)

6
korman/operators/op_export.py

@ -21,7 +21,7 @@ import pstats
from .. import exporter
from ..properties.prop_world import PlasmaAge
from ..properties.modifiers.logic import game_versions
class ExportOperator(bpy.types.Operator):
"""Exports ages for Cyan Worlds' Plasma Engine"""
@ -44,9 +44,7 @@ class ExportOperator(bpy.types.Operator):
"version": (EnumProperty, {"name": "Version",
"description": "Version of the Plasma Engine to target",
"default": "pvPots", # This should be changed when moul is easier to target!
"items": [("pvPrime", "Ages Beyond Myst (63.11)", "Targets the original Uru (Live) game", 2),
("pvPots", "Path of the Shell (63.12)", "Targets the most recent offline expansion pack", 1),
("pvMoul", "Myst Online: Uru Live (70)", "Targets the most recent online game", 0)]}),
"items": game_versions}),
}
# This wigs out and very bad things happen if it's not directly on the operator...

4
korman/properties/modifiers/anim.py

@ -123,9 +123,9 @@ class PlasmaAnimationModifier(PlasmaModifierProperties):
# We need both an AGModifier and an AGMasterMod
# TODO: grouped animations (eg one door, two objects)
agmod = exporter.mgr.add_object(plAGModifier, so=so, name=self.display_name)
agmod = exporter.mgr.find_create_object(plAGModifier, so=so, name=self.display_name)
agmod.channelName = bo.name
agmaster = exporter.mgr.add_object(plAGMasterMod, so=so, name=self.display_name)
agmaster = exporter.mgr.find_create_object(plAGMasterMod, so=so, name=self.display_name)
agmaster.addPrivateAnim(atcanim.key)
def post_export(self, exporter, bo, so):

38
korman/properties/modifiers/logic.py

@ -18,6 +18,28 @@ from bpy.props import *
from PyHSPlasma import *
from .base import PlasmaModifierProperties
from ...exporter import ExportError
game_versions = [("pvPrime", "Ages Beyond Myst (63.11)", "Targets the original Uru (Live) game"),
("pvPots", "Path of the Shell (63.12)", "Targets the most recent offline expansion pack"),
("pvMoul", "Myst Online: Uru Live (70)", "Targets the most recent online game")]
class PlasmaVersionedNodeTree(bpy.types.PropertyGroup):
name = StringProperty(name="Name")
version = EnumProperty(name="Version",
description="Plasma versions this node tree exports under",
items=game_versions,
options={"ENUM_FLAG"},
default=set(list(zip(*game_versions))[0]))
node_tree_name = StringProperty(name="Node Tree",
description="Node Tree to export")
@property
def node_tree(self):
try:
return bpy.data.node_groups[self.node_tree_name]
except KeyError:
raise ExportError("Node Tree {} does not exist!".format(self.node_tree_name))
class PlasmaAdvancedLogic(PlasmaModifierProperties):
pl_id = "advanced_logic"
@ -27,19 +49,25 @@ class PlasmaAdvancedLogic(PlasmaModifierProperties):
bl_description = "Plasma Logic Nodes"
bl_icon = "NODETREE"
tree_name = StringProperty(name="Node Tree", description="Plasma Logic Nodes")
logic_groups = CollectionProperty(type=PlasmaVersionedNodeTree)
active_group_index = IntProperty(options={"HIDDEN"})
def created(self, obj):
self.display_name = "Advanced Logic"
def export(self, exporter, bo, so):
tree = bpy.data.node_groups[self.tree_name]
tree.export(exporter, bo, so)
version = exporter.mgr.getVer()
for i in self.logic_groups:
our_versions = [globals()[j] for j in i.version]
if version in our_versions:
i.node_tree.export(exporter, bo, so)
@property
def requires_actor(self):
tree = bpy.data.node_groups[self.tree_name]
return tree.requires_actor
for i in self.logic_groups:
if i.node_tree.requires_actor:
return True
return False
class PlasmaSpawnPoint(PlasmaModifierProperties):

25
korman/ui/modifiers/logic.py

@ -15,8 +15,31 @@
import bpy
class LogicListUI(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
layout.prop(item, "name", emboss=False, text="", icon="NODETREE")
def advanced_logic(modifier, layout, context):
layout.prop_search(modifier, "tree_name", bpy.data, "node_groups", icon="NODETREE")
row = layout.row()
row.template_list("LogicListUI", "logic_groups", modifier, "logic_groups", modifier, "active_group_index",
rows=2, maxrows=3)
col = row.column(align=True)
op = col.operator("object.plasma_modifier_collection_add", icon="ZOOMIN", text="")
op.modifier = modifier.pl_id
op.collection = "logic_groups"
op.name_prefix = "Logic"
op.name_prop = "name"
op = col.operator("object.plasma_modifier_collection_remove", icon="ZOOMOUT", text="")
op.modifier = modifier.pl_id
op.collection = "logic_groups"
op.index = modifier.active_group_index
# Modify the loop points
if modifier.logic_groups:
logic = modifier.logic_groups[modifier.active_group_index]
row = layout.row()
row.prop_menu_enum(logic, "version")
row.prop_search(logic, "node_tree_name", bpy.data, "node_groups", icon="NODETREE", text="")
def spawnpoint(modifier, layout, context):
layout.label(text="Avatar faces negative Y.")

Loading…
Cancel
Save