diff --git a/korman/exporter/animation.py b/korman/exporter/animation.py
index 6740da9..e37b653 100644
--- a/korman/exporter/animation.py
+++ b/korman/exporter/animation.py
@@ -14,10 +14,13 @@
# along with Korman. If not, see .
import bpy
+from collections import defaultdict
+import functools
import itertools
import math
import mathutils
from PyHSPlasma import *
+from typing import *
import weakref
from . import utils
@@ -27,10 +30,10 @@ class AnimationConverter:
self._exporter = weakref.ref(exporter)
self._bl_fps = bpy.context.scene.render.fps
- def _convert_frame_time(self, frame_num):
+ def _convert_frame_time(self, frame_num : int) -> float:
return frame_num / self._bl_fps
- def convert_object_animations(self, bo, so):
+ def convert_object_animations(self, bo, so) -> None:
if not bo.plasma_object.has_animation_data:
return
@@ -72,7 +75,8 @@ class AnimationConverter:
# There is a race condition in the client with animation loading. It expects for modifiers
# to be listed on the SceneObject in a specific order. D'OH! So, always use these funcs.
agmod, agmaster = self.get_anigraph_objects(bo, so)
- atcanim = self._mgr.find_create_object(plATCAnim, so=so)
+ anim_mod = bo.plasma_modifiers.animation
+ atcanim = self._mgr.find_create_object(anim_mod.anim_type, so=so)
# Add the animation data to the ATC
for i in applicators:
@@ -89,21 +93,23 @@ class AnimationConverter:
if i is not None:
yield i.frame_range[index]
atcanim.name = "(Entire Animation)"
+ sdl_name = anim_mod.obj_sdl_anim
atcanim.start = self._convert_frame_time(min(get_ranges(obj_action, data_action, index=0)))
atcanim.end = self._convert_frame_time(max(get_ranges(obj_action, data_action, index=1)))
-
- # Marker points
- if obj_action is not None:
- for marker in obj_action.pose_markers:
- atcanim.setMarker(marker.name, self._convert_frame_time(marker.frame))
-
- # Fixme? Not sure if we really need to expose this...
- atcanim.easeInMin = 1.0
- atcanim.easeInMax = 1.0
- atcanim.easeInLength = 1.0
- atcanim.easeOutMin = 1.0
- atcanim.easeOutMax = 1.0
- atcanim.easeOutLength = 1.0
+ if isinstance(atcanim, plAgeGlobalAnim):
+ atcanim.globalVarName = anim_mod.obj_sdl_anim
+ if isinstance(atcanim, plATCAnim):
+ # Marker points
+ if obj_action is not None:
+ for marker in obj_action.pose_markers:
+ atcanim.setMarker(marker.name, self._convert_frame_time(marker.frame))
+ # Fixme? Not sure if we really need to expose this...
+ atcanim.easeInMin = 1.0
+ atcanim.easeInMax = 1.0
+ atcanim.easeInLength = 1.0
+ atcanim.easeOutMin = 1.0
+ atcanim.easeOutMax = 1.0
+ atcanim.easeOutLength = 1.0
def _convert_camera_animation(self, bo, so, obj_fcurves, data_fcurves):
if data_fcurves:
@@ -188,7 +194,7 @@ class AnimationConverter:
return None
energy_curve = next((i for i in fcurves if i.data_path == "energy" and i.keyframe_points), None)
- color_curves = sorted((i for i in fcurves if i.data_path == "color" and i.keyframe_points), key=lambda x: x.array_index)
+ color_curves = [i for i in fcurves if i.data_path == "color" and i.keyframe_points]
if energy_curve is None and color_curves is None:
return None
elif lamp.use_only_shadow:
@@ -199,10 +205,15 @@ class AnimationConverter:
return None
# OK Specular is easy. We just toss out the color as a point3.
- color_keyframes, color_bez = self._process_keyframes(color_curves, convert=lambda x: x * -1.0 if lamp.use_negative else None)
+ def convert_specular_animation(color):
+ if lamp.use_negative:
+ return map(lambda x: x * -1.0, color)
+ else:
+ return color
+ color_keyframes, color_bez = self._process_keyframes(color_curves, 3, lamp.color, convert_specular_animation)
if color_keyframes and lamp.use_specular:
channel = plPointControllerChannel()
- channel.controller = self._make_point3_controller(color_curves, color_keyframes, color_bez, lamp.color)
+ channel.controller = self._make_point3_controller(color_keyframes, color_bez)
applicator = plLightSpecularApplicator()
applicator.channelName = name
applicator.channel = channel
@@ -211,18 +222,20 @@ class AnimationConverter:
# Hey, look, it's a third way to process FCurves. YAY!
def convert_diffuse_animation(color, energy):
if lamp.use_negative:
- return { key: (0.0 - value) * energy[0] for key, value in color.items() }
+ proc = lambda x: x * -1.0 * energy[0]
else:
- return { key: value * energy[0] for key, value in color.items() }
- diffuse_defaults = { "color": lamp.color, "energy": lamp.energy }
+ proc = lambda x: x * energy[0]
+ return map(proc, color)
+ diffuse_channels = dict(color=3, energy=1)
+ diffuse_defaults = dict(color=lamp.color, energy=lamp.energy)
diffuse_fcurves = color_curves + [energy_curve,]
- diffuse_keyframes = self._process_fcurves(diffuse_fcurves, convert_diffuse_animation, diffuse_defaults)
+ diffuse_keyframes = self._process_fcurves(diffuse_fcurves, diffuse_channels, 3, convert_diffuse_animation, diffuse_defaults)
if not diffuse_keyframes:
return None
# Whew.
channel = plPointControllerChannel()
- channel.controller = self._make_point3_controller([], diffuse_keyframes, False, [])
+ channel.controller = self._make_point3_controller(diffuse_keyframes, False)
applicator = plLightDiffuseApplicator()
applicator.channelName = name
applicator.channel = channel
@@ -236,8 +249,16 @@ class AnimationConverter:
distance_fcurve = next((i for i in fcurves if i.data_path == "distance"), None)
if energy_fcurve is None and distance_fcurve is None:
return None
- light_converter = self._exporter().light
- intensity, atten_end = light_converter.convert_attenuation(lamp)
+
+ light_converter, report = self._exporter().light, self._exporter().report
+ omni_fcurves = [distance_fcurve, energy_fcurve]
+ omni_channels = dict(distance=1, energy=1)
+ omni_defaults = dict(distance=lamp.distance, energy=lamp.energy)
+
+ def convert_omni_atten(distance, energy):
+ intens = abs(energy[0])
+ atten_end = distance[0] if lamp.use_sphere else distance[0] * 2
+ return light_converter.convert_attenuation_linear(intens, atten_end)
# All types allow animating cutoff
if distance_fcurve is not None:
@@ -252,15 +273,9 @@ class AnimationConverter:
falloff = lamp.falloff_type
if falloff == "CONSTANT":
if energy_fcurve is not None:
- self._exporter().report.warn("Constant attenuation cannot be animated in Plasma", ident=3)
+ report.warn("Constant attenuation cannot be animated in Plasma", ident=3)
elif falloff == "INVERSE_LINEAR":
- def convert_linear_atten(distance, energy):
- intens = abs(energy[0])
- atten_end = distance[0] if lamp.use_sphere else distance[0] * 2
- return light_converter.convert_attenuation_linear(intens, atten_end)
-
- keyframes = self._process_fcurves([distance_fcurve, energy_fcurve], convert_linear_atten,
- {"distance": lamp.distance, "energy": lamp.energy})
+ keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten, omni_defaults)
if keyframes:
channel = plScalarControllerChannel()
channel.controller = self._make_scalar_leaf_controller(keyframes, False)
@@ -270,13 +285,8 @@ class AnimationConverter:
yield applicator
elif falloff == "INVERSE_SQUARE":
if self._mgr.getVer() >= pvMoul:
- def convert_quadratic_atten(distance, energy):
- intens = abs(energy[0])
- atten_end = distance[0] if lamp.use_sphere else distance[0] * 2
- return light_converter.convert_attenuation_quadratic(intens, atten_end)
-
- keyframes = self._process_fcurves([distance_fcurve, energy_fcurve], convert_quadratic_atten,
- {"distance": lamp.distance, "energy": lamp.energy})
+ report.port("Lamp {} Falloff animations are only supported in Myst Online: Uru Live", falloff, indent=3)
+ keyframes = self._process_fcurves(omni_fcurves, omni_channels, 1, convert_omni_atten, omni_defaults)
if keyframes:
channel = plScalarControllerChannel()
channel.controller = self._make_scalar_leaf_controller(keyframes, False)
@@ -285,19 +295,15 @@ class AnimationConverter:
applicator.channel = channel
yield applicator
else:
- self._exporter().report.port("Lamp Falloff '{}' animations only partially supported for this version of Plasma", falloff, indent=3)
+ report.warn("Lamp {} Falloff animations are not supported for this version of Plasma", falloff, indent=3)
else:
- self._exporter().report.warn("Lamp Falloff '{}' animations are not supported".format(falloff), ident=3)
+ report.warn("Lamp Falloff '{}' animations are not supported", falloff, ident=3)
def _convert_sound_volume_animation(self, name, fcurves, soundemit):
if not fcurves:
return None
- def convert_volume(value):
- if value == 0.0:
- return 0.0
- else:
- return math.log10(value) * 20.0
+ convert_volume = lambda x: math.log10(max(.01, x / 100.0)) * 20.0
for sound in soundemit.sounds:
path = "{}.volume".format(sound.path_from_id())
@@ -341,8 +347,11 @@ class AnimationConverter:
size = spot_size[0]
value = size - (blend * size)
return math.degrees(value)
- defaults = { "spot_blend": lamp.spot_blend, "spot_size": lamp.spot_size }
- keyframes = self._process_fcurves([blend_fcurve, size_fcurve], convert_spot_inner, defaults)
+
+ inner_fcurves = [blend_fcurve, size_fcurve]
+ inner_channels = dict(spot_blend=1, spot_size=1)
+ inner_defaults = dict(spot_blend=lamp.spot_blend, spot_size=lamp.spot_size)
+ keyframes = self._process_fcurves(inner_fcurves, inner_channels, 1, convert_spot_inner, inner_defaults)
if keyframes:
channel = plScalarControllerChannel()
@@ -352,7 +361,7 @@ class AnimationConverter:
applicator.channel = channel
yield applicator
- def _convert_transform_animation(self, name, fcurves, xform, allow_empty=False):
+ def _convert_transform_animation(self, name, fcurves, xform, allow_empty=False) -> Union[None, plMatrixChannelApplicator]:
tm = self.convert_transform_controller(fcurves, xform, allow_empty)
if tm is None and not allow_empty:
return None
@@ -367,13 +376,14 @@ class AnimationConverter:
return applicator
- def convert_transform_controller(self, fcurves, xform, allow_empty=False):
+ def convert_transform_controller(self, fcurves, xform, allow_empty=False) -> Union[None, plCompoundController]:
if not fcurves and not allow_empty:
return None
- pos = self.make_pos_controller(fcurves, xform)
- rot = self.make_rot_controller(fcurves, xform)
- scale = self.make_scale_controller(fcurves, xform)
+ pos = self.make_pos_controller(fcurves, "location", xform.to_translation())
+ # TODO: support rotation_quaternion
+ rot = self.make_rot_controller(fcurves, "rotation_euler", xform.to_euler())
+ scale = self.make_scale_controller(fcurves, "scale", xform.to_scale())
if pos is None and rot is None and scale is None:
if not allow_empty:
return None
@@ -384,17 +394,17 @@ class AnimationConverter:
tm.Z = scale
return tm
- def get_anigraph_keys(self, bo=None, so=None):
+ def get_anigraph_keys(self, bo=None, so=None) -> Tuple[plKey, plKey]:
mod = self._mgr.find_create_key(plAGModifier, so=so, bl=bo)
master = self._mgr.find_create_key(plAGMasterMod, so=so, bl=bo)
return mod, master
- def get_anigraph_objects(self, bo=None, so=None):
+ def get_anigraph_objects(self, bo=None, so=None) -> Tuple[plAGModifier, plAGMasterMod]:
mod = self._mgr.find_create_object(plAGModifier, so=so, bl=bo)
master = self._mgr.find_create_object(plAGMasterMod, so=so, bl=bo)
return mod, master
- def get_animation_key(self, bo, so=None):
+ def get_animation_key(self, bo, so=None) -> plKey:
# we might be controlling more than one animation. isn't that cute?
# https://www.youtube.com/watch?v=hspNaoxzNbs
# (but obviously this is not wrong...)
@@ -404,72 +414,65 @@ class AnimationConverter:
else:
return self.get_anigraph_keys(bo, so)[1]
- def make_matrix44_controller(self, fcurves, pos_path, scale_path, pos_default, scale_default):
- def convert_matrix_keyframe(**kwargs):
- pos = kwargs.get(pos_path)
- scale = kwargs.get(scale_path)
-
- # Since only some position curves may be supplied, construct dict with all positions
- allpos = dict(enumerate(pos_default))
- allscale = dict(enumerate(scale_default))
- allpos.update(pos)
- allscale.update(scale)
+ def make_matrix44_controller(self, fcurves, pos_path : str, scale_path : str, pos_default, scale_default) -> Union[None, plLeafController]:
+ def convert_matrix_keyframe(**kwargs) -> hsMatrix44:
+ pos = kwargs[pos_path]
+ scale = kwargs[scale_path]
matrix = hsMatrix44()
- # Note: scale and pos are dicts, so we can't unpack
- matrix.setTranslate(hsVector3(allpos[0], allpos[1], allpos[2]))
- matrix.setScale(hsVector3(allscale[0], allscale[1], allscale[2]))
+ matrix.setTranslate(hsVector3(*pos))
+ matrix.setScale(hsVector3(*scale))
return matrix
fcurves = [i for i in fcurves if i.data_path == pos_path or i.data_path == scale_path]
if not fcurves:
return None
+ channels = { pos_path: 3, scale_path: 3 }
default_values = { pos_path: pos_default, scale_path: scale_default }
- keyframes = self._process_fcurves(fcurves, convert_matrix_keyframe, default_values)
+ keyframes = self._process_fcurves(fcurves, channels, 1, convert_matrix_keyframe, default_values)
if not keyframes:
return None
# Now we make the controller
return self._make_matrix44_controller(keyframes)
- def make_pos_controller(self, fcurves, default_xform, convert=None):
- pos_curves = [i for i in fcurves if i.data_path == "location" and i.keyframe_points]
- keyframes, bez_chans = self._process_keyframes(pos_curves, convert)
+ def make_pos_controller(self, fcurves, data_path : str, default_xform, convert=None) -> Union[None, plLeafController]:
+ pos_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
+ keyframes, bez_chans = self._process_keyframes(pos_curves, 3, default_xform, convert)
if not keyframes:
return None
# At one point, I had some... insanity here to try to crush bezier channels and hand off to
# blah blah blah... As it turns out, point3 keyframe's tangents are vector3s :)
- ctrl = self._make_point3_controller(pos_curves, keyframes, bez_chans, default_xform.to_translation())
+ ctrl = self._make_point3_controller(keyframes, bez_chans)
return ctrl
- def make_rot_controller(self, fcurves, default_xform, convert=None):
- # TODO: support rotation_quaternion
- rot_curves = [i for i in fcurves if i.data_path == "rotation_euler" and i.keyframe_points]
- keyframes, bez_chans = self._process_keyframes(rot_curves, convert=None)
+ def make_rot_controller(self, fcurves, data_path : str, default_xform, convert=None) -> Union[None, plCompoundController, plLeafController]:
+ rot_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
+ keyframes, bez_chans = self._process_keyframes(rot_curves, 3, default_xform, convert=None)
if not keyframes:
return None
# Ugh. Unfortunately, it appears Blender's default interpolation is bezier. So who knows if
# many users will actually see the benefit here? Makes me sad.
if bez_chans:
- ctrl = self._make_scalar_compound_controller(rot_curves, keyframes, bez_chans, default_xform.to_euler())
+ ctrl = self._make_scalar_compound_controller(keyframes, bez_chans)
else:
- ctrl = self._make_quat_controller(rot_curves, keyframes, default_xform.to_euler())
+ ctrl = self._make_quat_controller( keyframes)
return ctrl
- def make_scale_controller(self, fcurves, default_xform, convert=None):
- scale_curves = [i for i in fcurves if i.data_path == "scale" and i.keyframe_points]
- keyframes, bez_chans = self._process_keyframes(scale_curves, convert)
+ def make_scale_controller(self, fcurves, data_path : str, default_xform, convert=None) -> plLeafController:
+ scale_curves = [i for i in fcurves if i.data_path == data_path and i.keyframe_points]
+ keyframes, bez_chans = self._process_keyframes(scale_curves, 3, default_xform, convert)
if not keyframes:
return None
# There is no such thing as a compound scale controller... in Plasma, anyway.
- ctrl = self._make_scale_value_controller(scale_curves, keyframes, bez_chans, default_xform)
+ ctrl = self._make_scale_value_controller(keyframes, bez_chans)
return ctrl
- def make_scalar_leaf_controller(self, fcurve, convert=None):
+ def make_scalar_leaf_controller(self, fcurve, convert=None) -> Union[None, plLeafController]:
keyframes, bezier = self._process_fcurve(fcurve, convert)
if not keyframes:
return None
@@ -477,7 +480,7 @@ class AnimationConverter:
ctrl = self._make_scalar_leaf_controller(keyframes, bezier)
return ctrl
- def _make_matrix44_controller(self, keyframes):
+ def _make_matrix44_controller(self, keyframes) -> plLeafController:
ctrl = plLeafController()
keyframe_type = hsKeyFrame.kMatrix44KeyFrame
exported_frames = []
@@ -487,52 +490,32 @@ class AnimationConverter:
exported.frame = keyframe.frame_num
exported.frameTime = keyframe.frame_time
exported.type = keyframe_type
- exported.value = keyframe.value
+ exported.value = keyframe.values[0]
exported_frames.append(exported)
ctrl.keys = (exported_frames, keyframe_type)
return ctrl
- def _make_point3_controller(self, fcurves, keyframes, bezier, default_xform):
+ def _make_point3_controller(self, keyframes, bezier) -> plLeafController:
ctrl = plLeafController()
- subctrls = ("X", "Y", "Z")
keyframe_type = hsKeyFrame.kBezPoint3KeyFrame if bezier else hsKeyFrame.kPoint3KeyFrame
exported_frames = []
- ctrl_fcurves = { i.array_index: i for i in fcurves }
for keyframe in keyframes:
exported = hsPoint3Key()
exported.frame = keyframe.frame_num
exported.frameTime = keyframe.frame_time
exported.type = keyframe_type
-
- in_tan = hsVector3()
- out_tan = hsVector3()
- value = hsVector3()
- for i, subctrl in enumerate(subctrls):
- fval = keyframe.values.get(i, None)
- if fval is not None:
- setattr(value, subctrl, fval)
- setattr(in_tan, subctrl, keyframe.in_tans[i])
- setattr(out_tan, subctrl, keyframe.out_tans[i])
- else:
- try:
- setattr(value, subctrl, ctrl_fcurves[i].evaluate(keyframe.frame_num_blender))
- except KeyError:
- setattr(value, subctrl, default_xform[i])
- setattr(in_tan, subctrl, 0.0)
- setattr(out_tan, subctrl, 0.0)
- exported.inTan = in_tan
- exported.outTan = out_tan
- exported.value = value
+ exported.inTan = hsVector3(*keyframe.in_tans)
+ exported.outTan = hsVector3(*keyframe.out_tans)
+ exported.value = hsVector3(*keyframe.values)
exported_frames.append(exported)
ctrl.keys = (exported_frames, keyframe_type)
return ctrl
- def _make_quat_controller(self, fcurves, keyframes, default_xform):
+ def _make_quat_controller(self, keyframes) -> plLeafController:
ctrl = plLeafController()
keyframe_type = hsKeyFrame.kQuatKeyFrame
exported_frames = []
- ctrl_fcurves = { i.array_index: i for i in fcurves }
for keyframe in keyframes:
exported = hsQuatKey()
@@ -541,58 +524,36 @@ class AnimationConverter:
exported.type = keyframe_type
# NOTE: quat keyframes don't do bezier nonsense
- value = mathutils.Euler()
- for i in range(3):
- fval = keyframe.values.get(i, None)
- if fval is not None:
- value[i] = fval
- else:
- try:
- value[i] = ctrl_fcurves[i].evaluate(keyframe.frame_num_blender)
- except KeyError:
- value[i] = default_xform[i]
- quat = value.to_quaternion()
- exported.value = utils.quaternion(quat)
+ value = mathutils.Euler(keyframe.values)
+ exported.value = utils.quaternion(value.to_quaternion())
exported_frames.append(exported)
ctrl.keys = (exported_frames, keyframe_type)
return ctrl
- def _make_scalar_compound_controller(self, fcurves, keyframes, bez_chans, default_xform):
+ def _make_scalar_compound_controller(self, keyframes, bez_chans) -> plCompoundController:
ctrl = plCompoundController()
subctrls = ("X", "Y", "Z")
for i in subctrls:
setattr(ctrl, i, plLeafController())
exported_frames = ([], [], [])
- ctrl_fcurves = { i.array_index: i for i in fcurves }
for keyframe in keyframes:
for i, subctrl in enumerate(subctrls):
- fval = keyframe.values.get(i, None)
- if fval is not None:
- keyframe_type = hsKeyFrame.kBezScalarKeyFrame if i in bez_chans else hsKeyFrame.kScalarKeyFrame
- exported = hsScalarKey()
- exported.frame = keyframe.frame_num
- exported.frameTime = keyframe.frame_time
- exported.inTan = keyframe.in_tans[i]
- exported.outTan = keyframe.out_tans[i]
- exported.type = keyframe_type
- exported.value = fval
- exported_frames[i].append(exported)
+ keyframe_type = hsKeyFrame.kBezScalarKeyFrame if i in bez_chans else hsKeyFrame.kScalarKeyFrame
+ exported = hsScalarKey()
+ exported.frame = keyframe.frame_num
+ exported.frameTime = keyframe.frame_time
+ exported.inTan = keyframe.in_tans[i]
+ exported.outTan = keyframe.out_tans[i]
+ exported.type = keyframe_type
+ exported.value = keyframe.values[i]
+ exported_frames[i].append(exported)
for i, subctrl in enumerate(subctrls):
my_keyframes = exported_frames[i]
-
- # ensure this controller has at least ONE keyframe
- if not my_keyframes:
- hack_frame = hsScalarKey()
- hack_frame.frame = 0
- hack_frame.frameTime = 0.0
- hack_frame.type = hsKeyFrame.kScalarKeyFrame
- hack_frame.value = default_xform[i]
- my_keyframes.append(hack_frame)
getattr(ctrl, subctrl).keys = (my_keyframes, my_keyframes[0].type)
return ctrl
- def _make_scalar_leaf_controller(self, keyframes, bezier):
+ def _make_scalar_leaf_controller(self, keyframes, bezier) -> plLeafController:
ctrl = plLeafController()
keyframe_type = hsKeyFrame.kBezScalarKeyFrame if bezier else hsKeyFrame.kScalarKeyFrame
exported_frames = []
@@ -601,239 +562,195 @@ class AnimationConverter:
exported = hsScalarKey()
exported.frame = keyframe.frame_num
exported.frameTime = keyframe.frame_time
- exported.inTan = keyframe.in_tan
- exported.outTan = keyframe.out_tan
+ exported.inTan = keyframe.in_tans[0]
+ exported.outTan = keyframe.out_tans[0]
exported.type = keyframe_type
- exported.value = keyframe.value
+ exported.value = keyframe.values[0]
exported_frames.append(exported)
ctrl.keys = (exported_frames, keyframe_type)
return ctrl
- def _make_scale_value_controller(self, fcurves, keyframes, bez_chans, default_xform):
- subctrls = ("X", "Y", "Z")
+ def _make_scale_value_controller(self, keyframes, bez_chans) -> plLeafController:
keyframe_type = hsKeyFrame.kBezScaleKeyFrame if bez_chans else hsKeyFrame.kScaleKeyFrame
exported_frames = []
- ctrl_fcurves = { i.array_index: i for i in fcurves }
- default_scale = default_xform.to_scale()
- unit_quat = default_xform.to_quaternion()
- unit_quat.normalize()
- unit_quat = utils.quaternion(unit_quat)
+ # Hmm... This smells... But it was basically doing this before the rewrite.
+ unit_quat = hsQuat(0.0, 0.0, 0.0, 1.0)
for keyframe in keyframes:
exported = hsScaleKey()
exported.frame = keyframe.frame_num
exported.frameTime = keyframe.frame_time
exported.type = keyframe_type
-
- in_tan = hsVector3()
- out_tan = hsVector3()
- value = hsVector3()
- for i, subctrl in enumerate(subctrls):
- fval = keyframe.values.get(i, None)
- if fval is not None:
- setattr(value, subctrl, fval)
- setattr(in_tan, subctrl, keyframe.in_tans[i])
- setattr(out_tan, subctrl, keyframe.out_tans[i])
- else:
- try:
- setattr(value, subctrl, ctrl_fcurves[i].evaluate(keyframe.frame_num_blender))
- except KeyError:
- setattr(value, subctrl, default_scale[i])
- setattr(in_tan, subctrl, 0.0)
- setattr(out_tan, subctrl, 0.0)
- exported.inTan = in_tan
- exported.outTan = out_tan
- exported.value = (value, unit_quat)
+ exported.inTan = hsVector3(*keyframe.in_tans)
+ exported.outTan = hsVector3(*keyframe.out_tans)
+ exported.value = (hsVector3(*keyframe.values), unit_quat)
exported_frames.append(exported)
ctrl = plLeafController()
ctrl.keys = (exported_frames, keyframe_type)
return ctrl
- def _process_fcurve(self, fcurve, convert=None):
+ def _sort_and_dedupe_keyframes(self, keyframes : Dict) -> Sequence:
+ """Takes in the final, unsorted keyframe sequence and sorts it. If all keyframes are
+ equivalent, eg due to a convert function, then they are discarded."""
+
+ num_keyframes = len(keyframes)
+ keyframes_sorted = [keyframes[i] for i in sorted(keyframes)]
+
+ # If any keyframe's value is equivalent to its boundary keyframes, discard it.
+ def filter_boundaries(i):
+ if i == 0 or i == num_keyframes - 1:
+ return False
+ left, me, right = keyframes_sorted[i - 1], keyframes_sorted[i], keyframes_sorted[i + 1]
+ return left.values == me.values == right.values
+
+ filtered_indices = list(itertools.filterfalse(filter_boundaries, range(num_keyframes)))
+ if len(filtered_indices) == 2:
+ if keyframes_sorted[filtered_indices[0]].values == keyframes_sorted[filtered_indices[1]].values:
+ return []
+ return [keyframes_sorted[i] for i in filtered_indices]
+
+ def _process_fcurve(self, fcurve, convert=None) -> Tuple[Sequence, AbstractSet]:
"""Like _process_keyframes, but for one fcurve"""
+
+ # Adapt from incoming single item sequence to a single argument.
+ if convert is not None:
+ single_convert = lambda x: convert(x[0])
+ else:
+ single_convert = None
+ # Can't proxy to _process_fcurves because it only supports linear interoplation.
+ return self._process_keyframes([fcurve], 1, [0.0], single_convert)
+
+ def _santize_converted_values(self, num_channels : int, raw_values : Union[Dict, Sequence], convert : Callable):
+ assert convert is not None
+ if isinstance(raw_values, Dict):
+ values = convert(**raw_values)
+ elif isinstance(raw_values, Sequence):
+ values = convert(raw_values)
+ else:
+ raise AssertionError("Unexpected type for raw_values: {}".format(raw_values.__class__))
+
+ if not isinstance(values, Sequence) and isinstance(values, Iterable):
+ values = tuple(values)
+ if not isinstance(values, Sequence):
+ assert num_channels == 1, "Converter returned 1 value but expected {}".format(num_channels)
+ values = (values,)
+ else:
+ assert len(values) == num_channels, "Converter returned {} values but expected {}".format(len(values), num_channels)
+ return values
+
+ def _process_fcurves(self, fcurves : Sequence, channels : Dict[str, int], result_channels : int,
+ convert : Callable, defaults : Dict[str, Union[float, Sequence]]) -> Sequence:
+ """This consumes a sequence of Blender FCurves that map to a single Plasma controller.
+ Like `_process_keyframes()`, except the converter function is mandatory, and each
+ Blender `data_path` must have a fixed number of channels.
+ """
+
+ # TODO: This fxn should probably issue a warning if any keyframes use bezier interpolation.
+ # But there's no indication given by any other fxn when an invalid interpolation mode is
+ # given, so what can you do?
keyframe_data = type("KeyFrameData", (), {})
- fps = self._bl_fps
- pi = math.pi
+ fps, pi = self._bl_fps, math.pi
- keyframes = {}
- bezier = False
- fcurve.update()
- for fkey in fcurve.keyframe_points:
- keyframe = keyframe_data()
- frame_num, value = fkey.co
- if fps == 30.0:
- keyframe.frame_num = int(frame_num)
- else:
- keyframe.frame_num = int(frame_num * (30.0 / fps))
- keyframe.frame_time = frame_num / fps
- if fkey.interpolation == "BEZIER":
- keyframe.in_tan = -(value - fkey.handle_left[1]) / (frame_num - fkey.handle_left[0]) / fps / (2 * pi)
- keyframe.out_tan = (value - fkey.handle_right[1]) / (frame_num - fkey.handle_right[0]) / fps / (2 * pi)
- bezier = True
- else:
- keyframe.in_tan = 0.0
- keyframe.out_tan = 0.0
- keyframe.value = value if convert is None else convert(value)
- keyframes[frame_num] = keyframe
- final_keyframes = [keyframes[i] for i in sorted(keyframes)]
- return (final_keyframes, bezier)
-
- def _process_fcurves(self, fcurves, convert, defaults=None):
- """Processes FCurves of different data sets and converts them into a single list of keyframes.
- This should be used when multiple Blender fields map to a single Plasma option."""
- class KeyFrameData:
- def __init__(self):
- self.values = {}
- fps = self._bl_fps
- pi = math.pi
-
- # It is assumed therefore that any multichannel FCurves will have all channels represented.
- # This seems fairly safe with my experiments with Lamp colors...
- grouped_fcurves = {}
- for fcurve in fcurves:
- if fcurve is None:
- continue
+ grouped_fcurves = defaultdict(dict)
+ for fcurve in (i for i in fcurves if i is not None):
fcurve.update()
- if fcurve.data_path in grouped_fcurves:
- grouped_fcurves[fcurve.data_path][fcurve.array_index] = fcurve
- else:
- grouped_fcurves[fcurve.data_path] = { fcurve.array_index: fcurve }
+ grouped_fcurves[fcurve.data_path][fcurve.array_index] = fcurve
- # Default values for channels that are not animated
- for key, value in defaults.items():
- if key not in grouped_fcurves:
- if hasattr(value, "__len__"):
- grouped_fcurves[key] = value
+ fcurve_keyframes = defaultdict(functools.partial(defaultdict, dict))
+ for fcurve in (i for i in fcurves if i is not None):
+ for fkey in fcurve.keyframe_points:
+ fcurve_keyframes[fkey.co[0]][fcurve.data_path][fcurve.array_index] = fkey
+
+ def iter_channel_values(frame_num : int, fcurves : Dict, fkeys : Dict, num_channels : int, defaults : Union[float, Sequence]):
+ for i in range(num_channels):
+ fkey = fkeys.get(i, None)
+ if fkey is None:
+ fcurve = fcurves.get(i, None)
+ if fcurve is None:
+ # We would like to test this to see if it makes sense, but Blender's mathutils
+ # types don't actually implement the sequence protocol. So, we'll have to
+ # just try to subscript it and see what happens.
+ try:
+ yield defaults[i]
+ except:
+ assert num_channels == 1, "Got a non-subscriptable default for a multi-channel keyframe."
+ yield defaults
+ else:
+ yield fcurve.evaluate(frame_num)
else:
- grouped_fcurves[key] = [value,]
+ yield fkey.co[1]
- # Assemble a dict { PlasmaFrameNum: { FCurveDataPath: KeyFrame } }
- keyframe_points = {}
- for fcurve in fcurves:
- if fcurve is None:
- continue
- for keyframe in fcurve.keyframe_points:
- frame_num_blender, value = keyframe.co
- frame_num = int(frame_num_blender * (30.0 / fps))
-
- # This is a temporary keyframe, so we're not going to worry about converting everything
- # Only the frame number to Plasma so we can go ahead and merge any rounded dupes
- entry, data = keyframe_points.get(frame_num), None
- if entry is None:
- entry = {}
- keyframe_points[frame_num] = entry
- else:
- data = entry.get(fcurve.data_path)
- if data is None:
- data = KeyFrameData()
- data.frame_num = frame_num
- data.frame_num_blender = frame_num_blender
- entry[fcurve.data_path] = data
- data.values[fcurve.array_index] = value
-
- # Now, we loop through our assembled keyframes and interpolate any missing data using the FCurves
- fcurve_chans = { key: len(value) for key, value in grouped_fcurves.items() }
- expected_values = sum(fcurve_chans.values())
- all_chans = frozenset(grouped_fcurves.keys())
-
- # We will also do the final convert here as well...
- final_keyframes = []
-
- for frame_num in sorted(keyframe_points.copy().keys()):
- keyframes = keyframe_points[frame_num]
- frame_num_blender = next(iter(keyframes.values())).frame_num_blender
-
- # If any data_paths are missing, init a dummy
- missing_channels = all_chans - frozenset(keyframes.keys())
- for chan in missing_channels:
- dummy = KeyFrameData()
- dummy.frame_num = frame_num
- dummy.frame_num_blender = frame_num_blender
- keyframes[chan] = dummy
-
- # Ensure all values are filled out.
- num_values = sum(map(len, (i.values for i in keyframes.values())))
- if num_values != expected_values:
- for chan, sorted_fcurves in grouped_fcurves.items():
- chan_keyframes = keyframes[chan]
- chan_values = fcurve_chans[chan]
- if len(chan_keyframes.values) == chan_values:
- continue
- for i in range(chan_values):
- if i not in chan_keyframes.values:
- try:
- fcurve = grouped_fcurves[chan][i]
- except:
- chan_keyframes.values[i] = defaults[chan]
- else:
- if isinstance(fcurve, bpy.types.FCurve):
- chan_keyframes.values[i] = fcurve.evaluate(chan_keyframes.frame_num_blender)
- else:
- # it's actually a default value!
- chan_keyframes.values[i] = fcurve
-
- # All values are calculated! Now we convert the disparate key data into a single keyframe.
- kwargs = { data_path: keyframe.values for data_path, keyframe in keyframes.items() }
- final_keyframe = KeyFrameData()
- final_keyframe.frame_num = frame_num
- final_keyframe.frame_num_blender = frame_num_blender
- final_keyframe.frame_time = frame_num / fps
- value = convert(**kwargs)
- if hasattr(value, "__len__"):
- final_keyframe.in_tans = [0.0] * len(value)
- final_keyframe.out_tans = [0.0] * len(value)
- final_keyframe.values = value
- else:
- final_keyframe.in_tan = 0.0
- final_keyframe.out_tan = 0.0
- final_keyframe.value = value
- final_keyframes.append(final_keyframe)
- return final_keyframes
+ keyframes = {}
+ for frame_num, fkeys in fcurve_keyframes.items():
+ keyframe = keyframe_data()
+ # hope you don't have a frame 29.9 and frame 30.0...
+ keyframe.frame_num = int(frame_num * (30.0 / fps))
+ keyframe.frame_num_blender = frame_num
+ keyframe.frame_time = frame_num / fps
+ keyframe.values_raw = { data_path: tuple(iter_channel_values(frame_num, grouped_fcurves[data_path], fkeys, num_channels, defaults[data_path]))
+ for data_path, num_channels in channels.items() }
+ keyframe.values = self._santize_converted_values(result_channels, keyframe.values_raw, convert)
+ # Very gnawty
+ keyframe.in_tans = [0.0] * result_channels
+ keyframe.out_tans = [0.0] * result_channels
+ keyframes[frame_num] = keyframe
+
+ return self._sort_and_dedupe_keyframes(keyframes)
- def _process_keyframes(self, fcurves, convert=None):
+ def _process_keyframes(self, fcurves, num_channels : int, default_values : Sequence, convert=None) -> Tuple[Sequence, AbstractSet]:
"""Groups all FCurves for the same frame together"""
keyframe_data = type("KeyFrameData", (), {})
- fps = self._bl_fps
- pi = math.pi
+ fps, pi = self._bl_fps, math.pi
- keyframes = {}
- bez_chans = set()
- for fcurve in fcurves:
+ keyframes, fcurve_keyframes = {}, defaultdict(dict)
+
+ indexed_fcurves = { fcurve.array_index: fcurve for fcurve in fcurves if fcurve is not None }
+ for i, fcurve in indexed_fcurves.items():
fcurve.update()
for fkey in fcurve.keyframe_points:
- frame_num, value = fkey.co
- keyframe = keyframes.get(frame_num, None)
- if keyframe is None:
- keyframe = keyframe_data()
- if fps == 30.0:
- # hope you don't have a frame 29.9 and frame 30.0...
- keyframe.frame_num = int(frame_num)
- else:
- keyframe.frame_num = int(frame_num * (30.0 / fps))
- keyframe.frame_num_blender = frame_num
- keyframe.frame_time = frame_num / fps
- keyframe.in_tans = {}
- keyframe.out_tans = {}
- keyframe.values = {}
- keyframes[frame_num] = keyframe
- idx = fcurve.array_index
- keyframe.values[idx] = value if convert is None else convert(value)
-
- # Calculate the bezier interpolation nonsense
- if fkey.interpolation == "BEZIER":
- keyframe.in_tans[idx] = -(value - fkey.handle_left[1]) / (frame_num - fkey.handle_left[0]) / fps / (2 * pi)
- keyframe.out_tans[idx] = (value - fkey.handle_right[1]) / (frame_num - fkey.handle_right[0]) / fps / (2 * pi)
- bez_chans.add(idx)
+ fcurve_keyframes[fkey.co[0]][i] = fkey
+
+ def iter_values(frame_num, fkeys) -> Generator[float, None, None]:
+ for i in range(num_channels):
+ fkey = fkeys.get(i, None)
+ if fkey is not None:
+ yield fkey.co[1]
else:
- keyframe.in_tans[idx] = 0.0
- keyframe.out_tans[idx] = 0.0
+ fcurve = indexed_fcurves.get(i, None)
+ if fcurve is not None:
+ yield fcurve.evaluate(frame_num)
+ else:
+ yield default_values[i]
+
+ # Does this really need to be a set?
+ bez_chans = set()
+
+ for frame_num, fkeys in fcurve_keyframes.items():
+ keyframe = keyframe_data()
+ # hope you don't have a frame 29.9 and frame 30.0...
+ keyframe.frame_num = int(frame_num * (30.0 / fps))
+ keyframe.frame_num_blender = frame_num
+ keyframe.frame_time = frame_num / fps
+ keyframe.in_tans = [0.0] * num_channels
+ keyframe.out_tans = [0.0] * num_channels
+ keyframe.values_raw = tuple(iter_values(frame_num, fkeys))
+ if convert is None:
+ keyframe.values = keyframe.values_raw
+ else:
+ keyframe.values = self._santize_converted_values(num_channels, keyframe.values_raw, convert)
+
+ for i, fkey in ((i, fkey) for i, fkey in fkeys.items() if fkey.interpolation == "BEZIER"):
+ value = keyframe.values_raw[i]
+ keyframe.in_tans[i] = -(value - fkey.handle_left[1]) / (frame_num - fkey.handle_left[0]) / fps / (2 * pi)
+ keyframe.out_tans[i] = (value - fkey.handle_right[1]) / (frame_num - fkey.handle_right[0]) / fps / (2 * pi)
+ bez_chans.add(i)
+ keyframes[frame_num] = keyframe
# Return the keyframes in a sequence sorted by frame number
- final_keyframes = [keyframes[i] for i in sorted(keyframes)]
- return (final_keyframes, bez_chans)
+ return (self._sort_and_dedupe_keyframes(keyframes), bez_chans)
@property
def _mgr(self):
diff --git a/korman/exporter/camera.py b/korman/exporter/camera.py
index 164f302..4c5b8e3 100644
--- a/korman/exporter/camera.py
+++ b/korman/exporter/camera.py
@@ -206,6 +206,9 @@ class CameraConverter:
f1, f2 = fcurve.evaluate(begin), fcurve.evaluate(end)
if abs(f1 - f2) > 0.001:
break
+ # to avoid single/duplicate keyframe client crash (per Hoikas)
+ if any((len(i.keys) == 1 for i in (pos_ctrl.X, pos_ctrl.Y, pos_ctrl.Z) if i is not None)):
+ raise ExportError("'{}': Rail Camera must have more than one keyframe", bo.name)
else:
# The animation is a loop
path.flags |= plAnimPath.kWrap
diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py
index ffcafe2..5f2555d 100644
--- a/korman/exporter/convert.py
+++ b/korman/exporter/convert.py
@@ -60,6 +60,7 @@ class Exporter:
# Step 0.8: Init the progress mgr
self.mesh.add_progress_presteps(self.report)
self.report.progress_add_step("Collecting Objects")
+ self.report.progress_add_step("Verify Competence")
self.report.progress_add_step("Harvesting Actors")
if self._op.lighting_method != "skip":
etlight.LightBaker.add_progress_steps(self.report)
@@ -81,6 +82,10 @@ class Exporter:
# us to export (both in the Age and Object Properties)... fun
self._collect_objects()
+ # Step 2.1: Run through all the objects we collected in Step 2 and make sure there
+ # is no ruddy funny business going on.
+ self._check_sanity()
+
# Step 2.5: Run through all the objects we collected in Step 2 and see if any relationships
# that the artist made requires something to have a CoordinateInterface
self._harvest_actors()
@@ -169,6 +174,20 @@ class Exporter:
inc_progress()
error.raise_if_error()
+ def _check_sanity(self):
+ self.report.progress_advance()
+ self.report.progress_range = len(self._objects)
+ inc_progress = self.report.progress_increment
+
+ self.report.msg("\nEnsuring Age is sane...")
+ for bl_obj in self._objects:
+ for mod in bl_obj.plasma_modifiers.modifiers:
+ fn = getattr(mod, "sanity_check", None)
+ if fn is not None:
+ fn()
+ inc_progress()
+ self.report.msg("... Age is grinning and holding a spatula. Must be OK, then.")
+
def _export_age_info(self):
# Make life slightly easier...
age_info = bpy.context.scene.world.plasma_age
@@ -268,10 +287,18 @@ class Exporter:
def _export_mesh_blobj(self, so, bo):
self.animation.convert_object_animations(bo, so)
if bo.data.materials:
- self.mesh.export_object(bo)
+ self.mesh.export_object(bo, so)
else:
self.report.msg("No material(s) on the ObData, so no drawables", indent=1)
+ def _export_font_blobj(self, so, bo):
+ self.animation.convert_object_animations(bo, so)
+ with utils.temporary_mesh_object(bo) as meshObj:
+ if bo.data.materials:
+ self.mesh.export_object(meshObj, so)
+ else:
+ self.report.msg("No material(s) on the ObData, so no drawables", indent=1)
+
def _export_referenced_node_trees(self):
self.report.progress_advance()
self.report.progress_range = len(self.want_node_trees)
@@ -407,7 +434,7 @@ class Exporter:
if not valid_path:
filepath = bpy.context.blend_data.filepath
if not filepath:
- filepath = self.filepath
+ filepath = self._op.filepath
filepath = str(Path(filepath).with_suffix(".ktc"))
age.texcache_path = filepath
return filepath
diff --git a/korman/exporter/etlight.py b/korman/exporter/etlight.py
index 1f6e306..a79aba5 100644
--- a/korman/exporter/etlight.py
+++ b/korman/exporter/etlight.py
@@ -375,12 +375,9 @@ class LightBaker(_MeshManager):
if not self._generate_lightgroup(bo, user_lg):
return False
- # I have heard tale of some moar "No valid image to bake to" boogs if there is a really
- # old copy of the autocolor layer on the mesh. Nuke it.
autocolor = vcols.get("autocolor")
- if autocolor is not None:
- vcols.remove(autocolor)
- autocolor = vcols.new("autocolor")
+ if autocolor is None:
+ autocolor = vcols.new("autocolor")
toggle.track(vcols, "active", autocolor)
# Mark "autocolor" as our active render layer
diff --git a/korman/exporter/manager.py b/korman/exporter/manager.py
index f62672c..ad6b7da 100644
--- a/korman/exporter/manager.py
+++ b/korman/exporter/manager.py
@@ -16,6 +16,7 @@
import bpy
from pathlib import Path
from PyHSPlasma import *
+from typing import Iterable
import weakref
from . import explosions
@@ -190,6 +191,13 @@ class ExportManager:
else:
return plEncryptedStream.kEncXtea
+ def find_interfaces(self, pClass, so : plSceneObject) -> Iterable[plObjInterface]:
+ assert issubclass(pClass, plObjInterface)
+
+ for i in (i.object for i in so.interfaces):
+ if isinstance(i, pClass):
+ yield i
+
def find_create_key(self, pClass, bl=None, name=None, so=None):
key = self.find_key(pClass, bl, name, so)
if key is None:
@@ -313,16 +321,16 @@ class ExportManager:
with output.generate_dat_file(f, enc=self._encryption) as stream:
fni = bpy.context.scene.world.plasma_fni
- stream.writeLine("Graphics.Renderer.SetClearColor {} {} {}".format(*fni.clear_color))
- stream.writeLine("Graphics.Renderer.SetYon {}".format(fni.yon))
+ stream.writeLine("Graphics.Renderer.SetClearColor {:.2f} {:.2f} {:.2f}".format(*fni.clear_color))
+ stream.writeLine("Graphics.Renderer.SetYon {:.1f}".format(fni.yon))
if fni.fog_method == "none":
stream.writeLine("Graphics.Renderer.Fog.SetDefLinear 0 0 0")
else:
- stream.writeLine("Graphics.Renderer.Fog.SetDefColor {} {} {}".format(*fni.fog_color))
+ stream.writeLine("Graphics.Renderer.Fog.SetDefColor {:.2f} {:.2f} {:.2f}".format(*fni.fog_color))
if fni.fog_method == "linear":
- stream.writeLine("Graphics.Renderer.Fog.SetDefLinear {} {} {}".format(fni.fog_start, fni.fog_end, fni.fog_density))
- elif fni.fog_method == "exp2":
- stream.writeLine("Graphics.Renderer.Fog.SetDefExp2 {} {}".format(fni.fog_end, fni.fog_density))
+ stream.writeLine("Graphics.Renderer.Fog.SetDefLinear {:.2f} {:.2f} {:.2f}".format(fni.fog_start, fni.fog_end, fni.fog_density))
+ elif fni.fog_method == "exp":
+ stream.writeLine("Graphics.Renderer.Fog.SetDefExp {:.2f} {:.2f}".format(fni.fog_end, fni.fog_density))
def _write_pages(self):
age_name = self._age_info.name
diff --git a/korman/exporter/material.py b/korman/exporter/material.py
index 4b973e4..95bb2a5 100644
--- a/korman/exporter/material.py
+++ b/korman/exporter/material.py
@@ -16,8 +16,10 @@
import bpy
import functools
import math
+import mathutils
from pathlib import Path
from PyHSPlasma import *
+from typing import Union
import weakref
from .explosions import *
@@ -143,7 +145,10 @@ class MaterialConverter:
"NONE": self._export_texture_type_none,
}
self._animation_exporters = {
+ "ambientCtl": functools.partial(self._export_layer_diffuse_animation, converter=self.get_material_ambient),
"opacityCtl": self._export_layer_opacity_animation,
+ "preshadeCtl": functools.partial(self._export_layer_diffuse_animation, converter=self.get_material_preshade),
+ "runtimeCtl": functools.partial(self._export_layer_diffuse_animation, converter=self.get_material_runtime),
"transformCtl": self._export_layer_transform_animation,
}
@@ -187,7 +192,9 @@ class MaterialConverter:
self._report.msg("Exporting Material '{}' as single user '{}'", bm.name, mat_name, indent=1)
hgmat = None
else:
- mat_name = bm.name
+ # Ensure that RT-lit objects don't infect the static-lit objects.
+ mat_prefix = "RTLit_" if bo.plasma_modifiers.lighting.rt_lights else ""
+ mat_name = "".join((mat_prefix, bm.name))
self._report.msg("Exporting Material '{}'", mat_name, indent=1)
hsgmat = self._mgr.find_key(hsGMaterial, name=mat_name, bl=bo)
if hsgmat is not None:
@@ -222,7 +229,8 @@ class MaterialConverter:
if slot.use_stencil:
stencils.append((idx, slot))
else:
- tex_layer = self.export_texture_slot(bo, bm, hsgmat, slot, idx)
+ tex_name = "{}_{}".format(mat_name, slot.name)
+ tex_layer = self.export_texture_slot(bo, bm, hsgmat, slot, idx, name=tex_name)
if restart_pass_next:
tex_layer.state.miscFlags |= hsGMatState.kMiscRestartPassHere
restart_pass_next = False
@@ -249,8 +257,9 @@ class MaterialConverter:
# Plasma makes several assumptions that every hsGMaterial has at least one layer. If this
# material had no Textures, we will need to initialize a default layer
if not hsgmat.layers:
- layer = self._mgr.find_create_object(plLayer, name="{}_AutoLayer".format(bm.name), bl=bo)
+ layer = self._mgr.find_create_object(plLayer, name="{}_AutoLayer".format(mat_name), bl=bo)
self._propagate_material_settings(bo, bm, layer)
+ layer = self._export_layer_animations(bo, bm, None, 0, layer)
hsgmat.addLayer(layer.key)
# Cache this material for later
@@ -349,7 +358,7 @@ class MaterialConverter:
return hsgmat.key
def export_bumpmap_slot(self, bo, bm, hsgmat, slot, idx):
- name = "{}_{}".format(bm.name if bm is not None else bo.name, slot.name)
+ name = "{}_{}".format(hsgmat.key.name, slot.name)
self._report.msg("Exporting Plasma Bumpmap Layers for '{}'", name, indent=2)
# Okay, now we need to make 3 layers for the Du, Dw, and Dv
@@ -486,7 +495,7 @@ class MaterialConverter:
layer = self._export_layer_animations(bo, bm, slot, idx, layer)
return layer
- def _export_layer_animations(self, bo, bm, tex_slot, idx, base_layer):
+ def _export_layer_animations(self, bo, bm, tex_slot, idx, base_layer) -> plLayer:
"""Exports animations on this texture and chains the Plasma layers as needed"""
def harvest_fcurves(bl_id, collection, data_path=None):
@@ -505,9 +514,16 @@ class MaterialConverter:
return None
fcurves = []
- texture = tex_slot.texture
- mat_action = harvest_fcurves(bm, fcurves, "texture_slots[{}]".format(idx))
- tex_action = harvest_fcurves(texture, fcurves)
+
+ # Base layers get all of the fcurves for animating things like the diffuse color
+ texture = tex_slot.texture if tex_slot is not None else None
+ if idx == 0:
+ harvest_fcurves(bm, fcurves)
+ harvest_fcurves(texture, fcurves)
+ elif tex_slot is not None:
+ harvest_fcurves(bm, fcurves, tex_slot.path_from_id())
+ harvest_fcurves(texture, fcurves)
+
if not fcurves:
return base_layer
@@ -515,10 +531,9 @@ class MaterialConverter:
# and chain this biotch up as best we can.
layer_animation = None
for attr, converter in self._animation_exporters.items():
- ctrl = converter(tex_slot, base_layer, fcurves)
+ ctrl = converter(bo, bm, tex_slot, base_layer, fcurves)
if ctrl is not None:
if layer_animation is None:
- name = "{}_LayerAnim".format(base_layer.key.name)
layer_animation = self.get_texture_animation_key(bo, bm, texture).object
setattr(layer_animation, attr, ctrl)
@@ -531,28 +546,44 @@ class MaterialConverter:
atc = layer_animation.timeConvert
# Since we are harvesting from the material action but are exporting to a layer, the
- # action's range is relatively useless. We'll figure our own.
- start, end = functools.reduce(lambda x, y: (min(x[0], y[0]), max(x[1], y[1])),
- (fcurve.range() for fcurve in fcurves))
-
- atc.begin = start / fps
- atc.end = end / fps
-
- layer_props = tex_slot.texture.plasma_layer
- if not layer_props.anim_auto_start:
- atc.flags |= plAnimTimeConvert.kStopped
- if layer_props.anim_loop:
+ # action's range is relatively useless. We'll figure our own. Reminder: the blender
+ # documentation is wrong -- FCurve.range() returns a sequence of frame numbers, not times.
+ atc.begin = min((fcurve.range()[0] for fcurve in fcurves)) * (30.0 / fps) / fps
+ atc.end = max((fcurve.range()[1] for fcurve in fcurves)) * (30.0 / fps) / fps
+
+ if tex_slot is not None:
+ layer_props = tex_slot.texture.plasma_layer
+ if not layer_props.anim_auto_start:
+ atc.flags |= plAnimTimeConvert.kStopped
+ if layer_props.anim_loop:
+ atc.flags |= plAnimTimeConvert.kLoop
+ atc.loopBegin = atc.begin
+ atc.loopEnd = atc.end
+ if layer_props.anim_sdl_var:
+ layer_animation.varName = layer_props.anim_sdl_var
+ else:
+ # Hmm... I wonder what we should do here? A reasonable default might be to just
+ # run the stupid thing in a loop.
atc.flags |= plAnimTimeConvert.kLoop
atc.loopBegin = atc.begin
atc.loopEnd = atc.end
- if layer_props.anim_sdl_var:
- layer_animation.varName = layer_props.anim_sdl_var
return layer_animation
# Well, we had some FCurves but they were garbage... Too bad.
return base_layer
- def _export_layer_opacity_animation(self, tex_slot, base_layer, fcurves):
+ def _export_layer_diffuse_animation(self, bo, bm, tex_slot, base_layer, fcurves, converter):
+ assert converter is not None
+
+ def translate_color(color_sequence):
+ # See things like get_material_preshade
+ result = converter(bo, bm, mathutils.Color(color_sequence))
+ return result.red, result.green, result.blue
+
+ ctrl = self._exporter().animation.make_pos_controller(fcurves, "diffuse_color", bm.diffuse_color, translate_color)
+ return ctrl
+
+ def _export_layer_opacity_animation(self, bo, bm, tex_slot, base_layer, fcurves):
for i in fcurves:
if i.data_path == "plasma_layer.opacity":
base_layer.state.blendFlags |= hsGMatState.kBlendAlpha
@@ -560,14 +591,16 @@ class MaterialConverter:
return ctrl
return None
- def _export_layer_transform_animation(self, tex_slot, base_layer, fcurves):
- path = tex_slot.path_from_id()
- pos_path = "{}.offset".format(path)
- scale_path = "{}.scale".format(path)
+ def _export_layer_transform_animation(self, bo, bm, tex_slot, base_layer, fcurves):
+ if tex_slot is not None:
+ path = tex_slot.path_from_id()
+ pos_path = "{}.offset".format(path)
+ scale_path = "{}.scale".format(path)
- # Plasma uses the controller to generate a matrix44... so we have to produce a leaf controller
- ctrl = self._exporter().animation.make_matrix44_controller(fcurves, pos_path, scale_path, tex_slot.offset, tex_slot.scale)
- return ctrl
+ # Plasma uses the controller to generate a matrix44... so we have to produce a leaf controller
+ ctrl = self._exporter().animation.make_matrix44_controller(fcurves, pos_path, scale_path, tex_slot.offset, tex_slot.scale)
+ return ctrl
+ return None
def _export_texture_type_environment_map(self, bo, layer, slot):
"""Exports a Blender EnvironmentMapTexture to a plLayer"""
@@ -1163,22 +1196,47 @@ class MaterialConverter:
def get_bump_layer(self, bo):
return self._bump_mats.get(bo, None)
+ def get_material_ambient(self, bo, bm, color : Union[None, mathutils.Color]=None) -> hsColorRGBA:
+ emit_scale = bm.emit * 0.5
+ if emit_scale > 0.0:
+ if color is None:
+ color = bm.diffuse_color
+ return hsColorRGBA(color.r * emit_scale,
+ color.g * emit_scale,
+ color.b * emit_scale,
+ 1.0)
+ else:
+ return utils.color(bpy.context.scene.world.ambient_color)
+
+ def get_material_preshade(self, bo, bm, color : Union[None, mathutils.Color]=None) -> hsColorRGBA:
+ if bo.plasma_modifiers.lighting.rt_lights:
+ return hsColorRGBA.kBlack
+ if color is None:
+ color = bm.diffuse_color
+ return utils.color(color)
+
+ def get_material_runtime(self, bo, bm, color : Union[None, mathutils.Color]=None) -> hsColorRGBA:
+ if not bo.plasma_modifiers.lighting.preshade:
+ return hsColorRGBA.kBlack
+ if color is None:
+ color = bm.diffuse_color
+ return utils.color(color)
+
def get_texture_animation_key(self, bo, bm, texture):
"""Finds or creates the appropriate key for sending messages to an animated Texture"""
- tex_name = texture.name
+ tex_name = texture.name if texture is not None else "AutoLayer"
if bo.type == "LAMP":
assert bm is None
bm_name = bo.name
else:
assert bm is not None
bm_name = bm.name
- if not tex_name in bm.texture_slots:
+ if texture is not None and not tex_name in bm.texture_slots:
raise ExportError("Texture '{}' not used in Material '{}'".format(bm_name, tex_name))
name = "{}_{}_LayerAnim".format(bm_name, tex_name)
- layer = texture.plasma_layer
- pClass = plLayerSDLAnimation if layer.anim_sdl_var else plLayerAnimation
+ pClass = plLayerSDLAnimation if texture is not None and texture.plasma_layer.anim_sdl_var else plLayerAnimation
return self._mgr.find_create_key(pClass, bl=bo, name=name)
@property
@@ -1204,23 +1262,17 @@ class MaterialConverter:
if bm.use_shadeless:
state.shadeFlags |= hsGMatState.kShadeWhite
+ if bm.emit:
+ state.shadeFlags |= hsGMatState.kShadeEmissive
+
# Colors
- layer.ambient = utils.color(bpy.context.scene.world.ambient_color)
- layer.preshade = utils.color(bm.diffuse_color)
- layer.runtime = utils.color(bm.diffuse_color)
+ layer.ambient = self.get_material_ambient(bo, bm)
+ layer.preshade = self.get_material_preshade(bo, bm)
+ layer.runtime = self.get_material_runtime(bo, bm)
layer.specular = utils.color(bm.specular_color)
layer.specularPower = min(100.0, float(bm.specular_hardness))
- layer.LODBias = -1.0 # Seems to be the Plasma default
-
- if bm.emit > 0.0:
- # Use the diffuse colour as the emit, scaled by the emit amount
- # (maximum 2.0, so we'll also scale that by 0.5)
- emit_scale = bm.emit * 0.5
- layer.ambient = hsColorRGBA(bm.diffuse_color.r * emit_scale,
- bm.diffuse_color.g * emit_scale,
- bm.diffuse_color.b * emit_scale,
- 1.0)
+ layer.LODBias = -1.0
def _requires_single_user(self, bo, bm):
if bo.data.show_double_sided:
diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py
index 4ef35ed..e8f1ac7 100644
--- a/korman/exporter/mesh.py
+++ b/korman/exporter/mesh.py
@@ -14,6 +14,7 @@
# along with Korman. If not, see .
import bpy
+import itertools
from PyHSPlasma import *
from math import fabs
import weakref
@@ -29,6 +30,29 @@ _WARN_VERTS_PER_SPAN = 0x8000
_VERTEX_COLOR_LAYERS = {"col", "color", "colour"}
+class _GeoSpan:
+ def __init__(self, bo, bm, geospan, pass_index=None):
+ self.geospan = geospan
+ self.pass_index = pass_index if pass_index is not None else 0
+ self.mult_color = self._determine_mult_color(bo, bm)
+
+ def _determine_mult_color(self, bo, bm):
+ """Determines the color all vertex colors should be multipled by in this span."""
+ if self.geospan.props & plGeometrySpan.kDiffuseFoldedIn:
+ color = bm.diffuse_color
+ base_layer = self._find_bottom_of_stack()
+ return (color.r, color.b, color.g, base_layer.opacity)
+ if not bo.plasma_modifiers.lighting.preshade:
+ return (0.0, 0.0, 0.0, 0.0)
+ return (1.0, 1.0, 1.0, 1.0)
+
+ def _find_bottom_of_stack(self) -> plLayerInterface:
+ base_layer = self.geospan.material.object.layers[0].object
+ while base_layer.underLay is not None:
+ base_layer = base_layer.underLay.object
+ return base_layer
+
+
class _RenderLevel:
MAJOR_OPAQUE = 0
MAJOR_FRAMEBUF = 1
@@ -39,17 +63,13 @@ class _RenderLevel:
_MAJOR_SHIFT = 28
_MINOR_MASK = ((1 << _MAJOR_SHIFT) - 1)
- def __init__(self, bo, hsgmat, pass_index, blend_span=False):
- self.level = 0
- if pass_index > 0:
- self.major = self.MAJOR_FRAMEBUF
- self.minor = pass_index * 4
+ def __init__(self, bo, pass_index, blend_span=False):
+ if blend_span:
+ self.level = self._determine_level(bo, blend_span)
else:
- self.major = self.MAJOR_BLEND if blend_span else self.MAJOR_OPAQUE
-
- # We use the blender material's pass index (which we stashed in the hsGMaterial) to increment
- # the render pass, just like it says...
- self.level += pass_index
+ self.level = 0
+ # Gulp... Hope you know what you're doing...
+ self.minor += pass_index * 4
def __eq__(self, other):
return self.level == other.level
@@ -60,19 +80,42 @@ class _RenderLevel:
def _get_major(self):
return self.level >> self._MAJOR_SHIFT
def _set_major(self, value):
- self.level = ((value << self._MAJOR_SHIFT) & 0xFFFFFFFF) | self.minor
+ self.level = self._calc_level(value, self.minor)
major = property(_get_major, _set_major)
def _get_minor(self):
return self.level & self._MINOR_MASK
def _set_minor(self, value):
- self.level = ((self.major << self._MAJOR_SHIFT) & 0xFFFFFFFF) | value
+ self.level = self._calc_level(self.major, value)
minor = property(_get_minor, _set_minor)
+ def _calc_level(self, major : int, minor : int=0) -> int:
+ return ((major << self._MAJOR_SHIFT) & 0xFFFFFFFF) | minor
+
+ def _determine_level(self, bo : bpy.types.Object, blend_span : bool) -> int:
+ mods = bo.plasma_modifiers
+ if mods.test_property("draw_framebuf"):
+ return self._calc_level(self.MAJOR_FRAMEBUF)
+ elif mods.test_property("draw_opaque"):
+ return self._calc_level(self.MAJOR_OPAQUE)
+ elif mods.test_property("draw_no_defer"):
+ blend_span = False
+
+ blend_mod = mods.blend
+ if blend_mod.enabled and blend_mod.has_dependencies:
+ level = self._calc_level(self.MAJOR_FRAMEBUF)
+ for i in blend_mod.iter_dependencies():
+ level = max(level, self._determine_level(i, blend_span))
+ return level + 4
+ elif blend_span:
+ return self._calc_level(self.MAJOR_BLEND)
+ else:
+ return self._calc_level(self.MAJOR_DEFAULT)
+
class _DrawableCriteria:
- def __init__(self, bo, hsgmat, pass_index):
- self.blend_span = bool(hsgmat.layers[0].object.state.blendFlags & hsGMatState.kBlendMask)
+ def __init__(self, bo, geospan, pass_index):
+ self.blend_span = bool(geospan.props & plGeometrySpan.kRequiresBlending)
self.criteria = 0
if self.blend_span:
@@ -80,7 +123,7 @@ class _DrawableCriteria:
self.criteria |= plDrawable.kCritSortFaces
if self._span_sort_allowed(bo):
self.criteria |= plDrawable.kCritSortSpans
- self.render_level = _RenderLevel(bo, hsgmat, pass_index, self.blend_span)
+ self.render_level = _RenderLevel(bo, pass_index, self.blend_span)
def __eq__(self, other):
if not isinstance(other, _DrawableCriteria):
@@ -96,12 +139,12 @@ class _DrawableCriteria:
def _face_sort_allowed(self, bo):
# For now, only test the modifiers
# This will need to be tweaked further for GUIs...
- return not any((i.no_face_sort for i in bo.plasma_modifiers.modifiers))
+ return not bo.plasma_modifiers.test_property("no_face_sort")
def _span_sort_allowed(self, bo):
# For now, only test the modifiers
# This will need to be tweaked further for GUIs...
- return not any((i.no_face_sort for i in bo.plasma_modifiers.modifiers))
+ return not bo.plasma_modifiers.test_property("no_face_sort")
@property
def span_type(self):
@@ -118,7 +161,6 @@ class _GeoData:
self.vertices = []
-
class _MeshManager:
def __init__(self, report=None):
if report is not None:
@@ -174,11 +216,12 @@ class _MeshManager:
trash_mesh, bo.data = bo.data, data_meshes.get(override["mesh"])
data_meshes.remove(trash_mesh)
- # If modifiers were removed, reapply them now.
+ # If modifiers were removed, reapply them now unless they're read-only.
+ readonly_attributes = {("DECIMATE", "face_count"),}
for cached_mod in override["modifiers"]:
mod = bo.modifiers.new(cached_mod["name"], cached_mod["type"])
for key, value in cached_mod.items():
- if key in {"name", "type"}:
+ if key in {"name", "type"} or (cached_mod["type"], key) in readonly_attributes:
continue
setattr(mod, key, value)
@@ -213,7 +256,49 @@ class MeshConverter(_MeshManager):
return (num_user_texs, total_texs, max_user_texs)
- def _create_geospan(self, bo, mesh, bm, hsgmatKey):
+ def _check_vtx_alpha(self, mesh, material_idx):
+ if material_idx is not None:
+ polygons = (i for i in mesh.polygons if i.material_index == material_idx)
+ else:
+ polygons = mesh.polygons
+ alpha_layer = self._find_vtx_alpha_layer(mesh.vertex_colors)
+ if alpha_layer is None:
+ return False
+ alpha_loops = (alpha_layer[i.loop_start:i.loop_start+i.loop_total] for i in polygons)
+ opaque = (sum(i.color) == len(i.color) for i in itertools.chain.from_iterable(alpha_loops))
+ has_alpha = not all(opaque)
+ return has_alpha
+
+ def _check_vtx_nonpreshaded(self, bo, mesh, material_idx, base_layer):
+ def check_layer_shading_animation(layer):
+ if isinstance(layer, plLayerAnimationBase):
+ return layer.opacityCtl is not None or layer.preshadeCtl is not None or layer.runtimeCtl is not None
+ if layer.underLay is not None:
+ return check_layer_shading_animation(layer.underLay.object)
+ return False
+
+ # TODO: if this is an avatar, we can't be non-preshaded.
+ if check_layer_shading_animation(base_layer):
+ return False
+
+ # Reject emissive and shadeless because the kLiteMaterial equation has lots of options
+ # that are taken away by VtxNonPreshaded that are useful here.
+ if material_idx is not None:
+ bm = mesh.materials[material_idx]
+ if bm.emit or bm.use_shadeless:
+ return False
+
+ mods = bo.plasma_modifiers
+ if mods.lighting.rt_lights:
+ return True
+ if mods.lightmap.bake_lightmap:
+ return True
+ if self._check_vtx_alpha(mesh, material_idx):
+ return True
+
+ return False
+
+ def _create_geospan(self, bo, mesh, material_idx, bm, hsgmatKey):
"""Initializes a plGeometrySpan from a Blender Object and an hsGMaterial"""
geospan = plGeometrySpan()
geospan.material = hsgmatKey
@@ -225,10 +310,22 @@ class MeshConverter(_MeshManager):
raise explosions.TooManyUVChannelsError(bo, bm, user_uvws, max_user_uvws)
geospan.format = total_uvws
- # Begin total guesswork WRT flags
- mods = bo.plasma_modifiers
- if mods.lightmap.enabled:
+ def is_alpha_blended(layer):
+ if layer.state.blendFlags & hsGMatState.kBlendMask:
+ return True
+ if layer.underLay is not None:
+ return is_alpha_blended(layer.underLay.object)
+ return False
+
+ base_layer = hsgmatKey.object.layers[0].object
+ if is_alpha_blended(base_layer) or self._check_vtx_alpha(mesh, material_idx):
+ geospan.props |= plGeometrySpan.kRequiresBlending
+ if self._check_vtx_nonpreshaded(bo, mesh, material_idx, base_layer):
geospan.props |= plGeometrySpan.kLiteVtxNonPreshaded
+ if (geospan.props & plGeometrySpan.kLiteMask) != plGeometrySpan.kLiteMaterial:
+ geospan.props |= plGeometrySpan.kDiffuseFoldedIn
+
+ mods = bo.plasma_modifiers
if mods.lighting.rt_lights:
geospan.props |= plGeometrySpan.kPropRunTimeLight
if not bm.use_shadows:
@@ -270,7 +367,7 @@ class MeshConverter(_MeshManager):
dspan.composeGeometry(True, True)
inc_progress()
- def _export_geometry(self, bo, mesh, materials, geospans):
+ def _export_geometry(self, bo, mesh, materials, geospans, mat2span_LUT):
# Recall that materials is a mapping of exported materials to blender material indices.
# Therefore, geodata maps blender material indices to working geometry data.
# Maybe the logic is a bit inverted, but it keeps the inner loop simple.
@@ -279,15 +376,8 @@ class MeshConverter(_MeshManager):
# Locate relevant vertex color layers now...
lm = bo.plasma_modifiers.lightmap
- color, alpha = None, None
- for vcol_layer in mesh.tessface_vertex_colors:
- name = vcol_layer.name.lower()
- if name in _VERTEX_COLOR_LAYERS:
- color = vcol_layer.data
- elif name == "autocolor" and color is None and not lm.bake_lightmap:
- color = vcol_layer.data
- elif name == "alpha":
- alpha = vcol_layer.data
+ color = None if lm.bake_lightmap else self._find_vtx_color_layer(mesh.tessface_vertex_colors)
+ alpha = self._find_vtx_alpha_layer(mesh.tessface_vertex_colors)
# Convert Blender faces into things we can stuff into libHSPlasma
for i, tessface in enumerate(mesh.tessfaces):
@@ -317,10 +407,8 @@ class MeshConverter(_MeshManager):
else:
src = alpha[i]
# average color becomes the alpha value
- tessface_alphas = (((src.color1[0] + src.color1[1] + src.color1[2]) / 3),
- ((src.color2[0] + src.color2[1] + src.color2[2]) / 3),
- ((src.color3[0] + src.color3[1] + src.color3[2]) / 3),
- ((src.color4[0] + src.color4[1] + src.color4[2]) / 3))
+ tessface_alphas = ((sum(src.color1) / 3), (sum(src.color2) / 3),
+ (sum(src.color3) / 3), (sum(src.color4) / 3))
if bumpmap is not None:
gradPass = []
@@ -350,9 +438,16 @@ class MeshConverter(_MeshManager):
for j, vertex in enumerate(tessface.vertices):
uvws = tuple([uvw[j] for uvw in tessface_uvws])
- # Grab VCols
- vertex_color = (int(tessface_colors[j][0] * 255), int(tessface_colors[j][1] * 255),
- int(tessface_colors[j][2] * 255), int(tessface_alphas[j] * 255))
+ # Calculate vertex colors.
+ if mat2span_LUT:
+ mult_color = geospans[mat2span_LUT[tessface.material_index]].mult_color
+ else:
+ mult_color = (1.0, 1.0, 1.0, 1.0)
+ tessface_color, tessface_alpha = tessface_colors[j], tessface_alphas[j]
+ vertex_color = (int(tessface_color[0] * mult_color[0] * 255),
+ int(tessface_color[1] * mult_color[1] * 255),
+ int(tessface_color[2] * mult_color[2] * 255),
+ int(tessface_alpha * mult_color[0] * 255))
# Now, we'll index into the vertex dict using the per-face elements :(
# We're using tuples because lists are not hashable. The many mathutils and PyHSPlasma
@@ -409,7 +504,7 @@ class MeshConverter(_MeshManager):
# Time to finish it up...
for i, data in enumerate(geodata.values()):
- geospan = geospans[i][0]
+ geospan = geospans[i].geospan
numVerts = len(data.vertices)
numUVs = geospan.format & plGeometrySpan.kUVCountMask
@@ -480,7 +575,7 @@ class MeshConverter(_MeshManager):
# Sequence of tuples (material_index, material)
return sorted(((i, material_source[i]) for i in valid_materials), key=lambda x: x[0])
- def export_object(self, bo):
+ def export_object(self, bo, so : plSceneObject):
# If this object has modifiers, then it's a unique mesh, and we don't need to try caching it
# Otherwise, let's *try* to share meshes as best we can...
if bo.modifiers:
@@ -492,7 +587,7 @@ class MeshConverter(_MeshManager):
# Create the DrawInterface
if drawables:
- diface = self._mgr.find_create_object(plDrawInterface, bl=bo)
+ diface = self._mgr.find_create_object(plDrawInterface, bl=bo, so=so)
for dspan_key, idx in drawables:
diface.addDrawable(dspan_key, idx)
@@ -512,18 +607,18 @@ class MeshConverter(_MeshManager):
return None
# Step 1: Export all of the doggone materials.
- geospans = self._export_material_spans(bo, mesh, materials)
+ geospans, mat2span_LUT = self._export_material_spans(bo, mesh, materials)
# Step 2: Export Blender mesh data to Plasma GeometrySpans
- self._export_geometry(bo, mesh, materials, geospans)
+ self._export_geometry(bo, mesh, materials, geospans, mat2span_LUT)
# Step 3: Add plGeometrySpans to the appropriate DSpan and create indices
_diindices = {}
- for geospan, pass_index in geospans:
- dspan = self._find_create_dspan(bo, geospan.material.object, pass_index)
+ for i in geospans:
+ dspan = self._find_create_dspan(bo, i.geospan, i.pass_index)
self._report.msg("Exported hsGMaterial '{}' geometry into '{}'",
- geospan.material.name, dspan.key.name, indent=1)
- idx = dspan.addSourceSpan(geospan)
+ i.geospan.material.name, dspan.key.name, indent=1)
+ idx = dspan.addSourceSpan(i.geospan)
diidx = _diindices.setdefault(dspan, [])
diidx.append(idx)
@@ -543,22 +638,27 @@ class MeshConverter(_MeshManager):
if len(materials) > 1:
msg = "'{}' is a WaveSet -- only one material is supported".format(bo.name)
self._exporter().report.warn(msg, indent=1)
- matKey = self.material.export_waveset_material(bo, materials[0][1])
- geospan = self._create_geospan(bo, mesh, materials[0][1], matKey)
+ blmat = materials[0][1]
+ matKey = self.material.export_waveset_material(bo, blmat)
+ geospan = self._create_geospan(bo, mesh, None, blmat, matKey)
# FIXME: Can some of this be generalized?
geospan.props |= (plGeometrySpan.kWaterHeight | plGeometrySpan.kLiteVtxNonPreshaded |
plGeometrySpan.kPropReverseSort | plGeometrySpan.kPropNoShadow)
geospan.waterHeight = bo.location[2]
- return [(geospan, 0)]
+ return [_GeoSpan(bo, blmat, geospan)], None
else:
geospans = [None] * len(materials)
- for i, (_, blmat) in enumerate(materials):
+ mat2span_LUT = {}
+ for i, (blmat_idx, blmat) in enumerate(materials):
matKey = self.material.export_material(bo, blmat)
- geospans[i] = (self._create_geospan(bo, mesh, blmat, matKey), blmat.pass_index)
- return geospans
+ geospans[i] = _GeoSpan(bo, blmat,
+ self._create_geospan(bo, mesh, blmat_idx, blmat, matKey),
+ blmat.pass_index)
+ mat2span_LUT[blmat_idx] = i
+ return geospans, mat2span_LUT
- def _find_create_dspan(self, bo, hsgmat, pass_index):
+ def _find_create_dspan(self, bo, geospan, pass_index):
location = self._mgr.get_location(bo)
if location not in self._dspans:
self._dspans[location] = {}
@@ -569,7 +669,7 @@ class MeshConverter(_MeshManager):
# SortFaces: means we should sort the faces in this span only
# We're using pass index to do just what it was designed for. Cyan has a nicer "depends on"
# draw component, but pass index is the Blender way, so that's what we're doing.
- crit = _DrawableCriteria(bo, hsgmat, pass_index)
+ crit = _DrawableCriteria(bo, geospan, pass_index)
if crit not in self._dspans[location]:
# AgeName_[District_]_Page_RenderLevel_Crit[Blend]Spans
@@ -592,6 +692,21 @@ class MeshConverter(_MeshManager):
else:
return self._dspans[location][crit]
+ def _find_vtx_alpha_layer(self, color_collection):
+ alpha_layer = next((i for i in color_collection if i.name.lower() == "alpha"), None)
+ if alpha_layer is not None:
+ return alpha_layer.data
+ return None
+
+ def _find_vtx_color_layer(self, color_collection):
+ manual_layer = next((i for i in color_collection if i.name.lower() in _VERTEX_COLOR_LAYERS), None)
+ if manual_layer is not None:
+ return manual_layer.data
+ baked_layer = color_collection.get("autocolor")
+ if baked_layer is not None:
+ return baked_layer.data
+ return None
+
@property
def _mgr(self):
return self._exporter().mgr
diff --git a/korman/exporter/outfile.py b/korman/exporter/outfile.py
index 8e71174..d3ee7e6 100644
--- a/korman/exporter/outfile.py
+++ b/korman/exporter/outfile.py
@@ -249,7 +249,8 @@ class OutputFiles:
backing_stream = stream
# No sense in wasting time encrypting data that isn't going to be used in the export
- if not bogus:
+ # Also, don't encrypt any MOUL files at all.
+ if not bogus and self._version != pvMoul:
enc = kwargs.get("enc", None)
if enc is not None:
stream = plEncryptedStream(self._version)
diff --git a/korman/exporter/physics.py b/korman/exporter/physics.py
index d13f221..4692354 100644
--- a/korman/exporter/physics.py
+++ b/korman/exporter/physics.py
@@ -60,9 +60,7 @@ class PhysicsConverter:
indices += (v[0], v[2], v[3],)
return indices
- def _convert_mesh_data(self, bo, physical, local_space, indices=True):
- mat = bo.matrix_world
-
+ def _convert_mesh_data(self, bo, physical, local_space, mat, indices=True):
mesh = bo.to_mesh(bpy.context.scene, True, "RENDER", calc_tessface=False)
with TemporaryObject(mesh, bpy.data.meshes.remove):
if local_space:
@@ -211,29 +209,41 @@ class PhysicsConverter:
if tree_xformed:
bo_xformed = bo.plasma_object.has_transform_animation
+ # Always pin these objects - otherwise they may start falling through the floor.
+ # Unless you've marked it kickable...
+ if not mod.dynamic:
+ _set_phys_prop(plSimulationInterface.kPinned, simIface, physical)
+
# MOUL: only objects that have animation data are kPhysAnim
if ver != pvMoul or bo_xformed:
_set_phys_prop(plSimulationInterface.kPhysAnim, simIface, physical)
- # PotS: objects inheriting parent animation only are not pinned
- # MOUL: animated objects in subworlds are not pinned
- if bo_xformed and (ver != pvMoul or subworld is None):
- _set_phys_prop(plSimulationInterface.kPinned, simIface, physical)
- # MOUL: child objects are kPassive
- if ver == pvMoul and bo.parent is not None:
- _set_phys_prop(plSimulationInterface.kPassive, simIface, physical)
- # FilterCoordinateInterfaces are kPassive
- if bo.plasma_object.ci_type == plFilterCoordInterface:
+
+ # Any physical that is parented by not kickable (dynamic) is passive -
+ # meaning we don't need to report back any changes from physics. Same for
+ # plFilterCoordInterface, which filters out some axes.
+ if (bo.parent is not None and not mod.dynamic) or bo.plasma_object.ci_type == plFilterCoordInterface:
_set_phys_prop(plSimulationInterface.kPassive, simIface, physical)
# If the mass is zero, then we will fail to animate. Fix that.
if physical.mass == 0.0:
physical.mass = 1.0
+ # Different Plasma versions have different ways they expect to get physical transforms.
+ # With Havok, massless objects are in absolute worldspace while massed (movable) objects
+ # are in object-local space.
+ # In PhysX, objects with a coordinate interface are in local to SUBWORLD space, otherwise
+ # they are in absolute worldspace.
if ver <= pvPots:
- local_space = physical.mass > 0.0
+ local_space, mat = physical.mass > 0.0, bo.matrix_world
+ elif ver == pvMoul:
+ if self._exporter().has_coordiface(bo):
+ local_space = True
+ mat = subworld.matrix_world.inverted() * bo.matrix_world if subworld else bo.matrix_world
+ else:
+ local_space, mat = False, bo.matrix_world
else:
- local_space = self._exporter().has_coordiface(bo)
- self._bounds_converters[bounds](bo, physical, local_space)
+ raise NotImplementedError("ODE physical transform")
+ self._bounds_converters[bounds](bo, physical, local_space, mat)
else:
simIface = so.sim.object
physical = simIface.physical.object
@@ -245,14 +255,14 @@ class PhysicsConverter:
self._apply_props(simIface, physical, kwargs)
- def _export_box(self, bo, physical, local_space):
+ def _export_box(self, bo, physical, local_space, mat):
"""Exports box bounds based on the object"""
physical.boundsType = plSimDefs.kBoxBounds
- vertices = self._convert_mesh_data(bo, physical, local_space, indices=False)
+ vertices = self._convert_mesh_data(bo, physical, local_space, mat, indices=False)
physical.calcBoxBounds(vertices)
- def _export_hull(self, bo, physical, local_space):
+ def _export_hull(self, bo, physical, local_space, mat):
"""Exports convex hull bounds based on the object"""
physical.boundsType = plSimDefs.kHullBounds
@@ -260,7 +270,6 @@ class PhysicsConverter:
# bake them to convex hulls. Specifically, Windows 32-bit w/PhysX 2.6. Everything else just
# needs to have us provide some friendlier data...
with bmesh_from_object(bo) as mesh:
- mat = bo.matrix_world
if local_space:
physical.pos = hsVector3(*mat.to_translation())
physical.rot = utils.quaternion(mat.to_quaternion())
@@ -273,18 +282,25 @@ class PhysicsConverter:
verts = itertools.takewhile(lambda x: isinstance(x, BMVert), result["geom"])
physical.verts = [hsVector3(*i.co) for i in verts]
- def _export_sphere(self, bo, physical, local_space):
+ def _export_sphere(self, bo, physical, local_space, mat):
"""Exports sphere bounds based on the object"""
physical.boundsType = plSimDefs.kSphereBounds
- vertices = self._convert_mesh_data(bo, physical, local_space, indices=False)
+ vertices = self._convert_mesh_data(bo, physical, local_space, mat, indices=False)
physical.calcSphereBounds(vertices)
- def _export_trimesh(self, bo, physical, local_space):
+ def _export_trimesh(self, bo, physical, local_space, mat):
"""Exports an object's mesh as exact physical bounds"""
- physical.boundsType = plSimDefs.kExplicitBounds
- vertices, indices = self._convert_mesh_data(bo, physical, local_space)
+ # Triangle meshes MAY optionally specify a proxy object to fetch the triangles from...
+ mod = bo.plasma_modifiers.collision
+ if mod.enabled and mod.proxy_object is not None:
+ physical.boundsType = plSimDefs.kProxyBounds
+ vertices, indices = self._convert_mesh_data(mod.proxy_object, physical, local_space, mat)
+ else:
+ physical.boundsType = plSimDefs.kExplicitBounds
+ vertices, indices = self._convert_mesh_data(bo, physical, local_space, mat)
+
physical.verts = vertices
physical.indices = indices
diff --git a/korman/exporter/utils.py b/korman/exporter/utils.py
index 9385ecf..87d894b 100644
--- a/korman/exporter/utils.py
+++ b/korman/exporter/utils.py
@@ -92,3 +92,20 @@ def bmesh_object(name : str):
bm.to_mesh(mesh)
finally:
bm.free()
+
+@contextmanager
+def temporary_mesh_object(source : bpy.types.Object) -> bpy.types.Object:
+ """Creates a temporary mesh object from a nonmesh object that will only exist for the duration
+ of the context."""
+ assert source.type != "MESH"
+
+ obj = bpy.data.objects.new(source.name, source.to_mesh(bpy.context.scene, True, "RENDER"))
+ obj.draw_type = "WIRE"
+ obj.matrix_basis, obj.matrix_world = source.matrix_basis, source.matrix_world
+ obj.parent = source.parent
+
+ bpy.context.scene.objects.link(obj)
+ try:
+ yield obj
+ finally:
+ bpy.data.objects.remove(obj)
diff --git a/korman/idprops.py b/korman/idprops.py
index 66328d2..b7c55a6 100644
--- a/korman/idprops.py
+++ b/korman/idprops.py
@@ -127,6 +127,9 @@ def poll_animated_objects(self, value):
def poll_camera_objects(self, value):
return value.type == "CAMERA"
+def poll_drawable_objects(self, value):
+ return value.type == "MESH" and any(value.data.materials)
+
def poll_empty_objects(self, value):
return value.type == "EMPTY"
diff --git a/korman/nodes/node_core.py b/korman/nodes/node_core.py
index 597b2f5..86a2d3f 100644
--- a/korman/nodes/node_core.py
+++ b/korman/nodes/node_core.py
@@ -181,6 +181,12 @@ class PlasmaNodeBase:
"""Generates valid node sockets that can be linked to a specific socket on this node."""
from .node_deprecated import PlasmaDeprecatedNode
+ source_socket_props = getattr(self.__class__, "output_sockets", {}) if is_output else \
+ getattr(self.__class__, "input_sockets", {})
+ source_socket_def = source_socket_props.get(socket.alias, {})
+ valid_dest_sockets = source_socket_def.get("valid_link_sockets")
+ valid_dest_nodes = source_socket_def.get("valid_link_nodes")
+
for dest_node_cls in bpy.types.Node.__subclasses__():
if not issubclass(dest_node_cls, PlasmaNodeBase) or issubclass(dest_node_cls, PlasmaDeprecatedNode):
continue
@@ -193,7 +199,14 @@ class PlasmaNodeBase:
continue
if socket_def.get("hidden") is True:
continue
-
+
+ # Can this socket link to the socket_def on the destination node?
+ if valid_dest_nodes is not None and dest_node_cls.bl_idname not in valid_dest_nodes:
+ continue
+ if valid_dest_sockets is not None and socket_def["type"] not in valid_dest_sockets:
+ continue
+
+ # Can the socket_def on the destination node link to this socket?
valid_source_nodes = socket_def.get("valid_link_nodes")
valid_source_sockets = socket_def.get("valid_link_sockets")
if valid_source_nodes is not None and self.bl_idname not in valid_source_nodes:
diff --git a/korman/nodes/node_python.py b/korman/nodes/node_python.py
index 9b9dcbc..c758b3e 100644
--- a/korman/nodes/node_python.py
+++ b/korman/nodes/node_python.py
@@ -256,6 +256,10 @@ class PlasmaPythonFileNode(PlasmaVersionedNode, bpy.types.Node):
def export(self, exporter, bo, so):
pfm = self.get_key(exporter, so).object
+ # Special PFM-SO handling ahoy - be sure to do it for all objects this PFM is attached to.
+ # Otherwise, you get non-determinant behavior.
+ self._export_ancillary_sceneobject(exporter, so)
+
# No need to continue if the PFM was already generated.
if pfm.filename:
return
@@ -276,7 +280,6 @@ class PlasmaPythonFileNode(PlasmaVersionedNode, bpy.types.Node):
# Handle exporting the Python Parameters
attrib_sockets = (i for i in self.inputs if i.is_linked)
for socket in attrib_sockets:
- attrib = socket.attribute_type
from_node = socket.links[0].from_node
value = from_node.value if socket.is_simple_value else from_node.get_key(exporter, so)
@@ -285,27 +288,42 @@ class PlasmaPythonFileNode(PlasmaVersionedNode, bpy.types.Node):
for i in value:
param = plPythonParameter()
param.id = socket.attribute_id
- param.valueType = _attrib2param[attrib]
+ param.valueType = _attrib2param[socket.attribute_type]
param.value = i
- # Key type sanity checking... Because I trust no user.
if not socket.is_simple_value:
- if i is None:
- msg = "'{}' Node '{}' didn't return a key and therefore will be unavailable to Python".format(
- self.id_data.name, from_node.name)
- exporter.report.warn(msg, indent=3)
- else:
- key_type = _attrib_key_types[attrib]
- if isinstance(key_type, tuple):
- good_key = i.type in key_type
- else:
- good_key = i.type == key_type
- if not good_key:
- msg = "'{}' Node '{}' returned an unexpected key type '{}'".format(
- self.id_data.name, from_node.name, plFactory.ClassName(i.type))
- exporter.report.warn(msg, indent=3)
+ self._export_key_attrib(exporter, bo, so, i, socket)
pfm.addParameter(param)
+ def _export_ancillary_sceneobject(self, exporter, so : plSceneObject) -> None:
+ # Danger: Special case evil ahoy...
+ # If the key is an object that represents a lamp, we have to assume that the reason it's
+ # being passed to Python is so it can be turned on/off at will. That means it's technically
+ # an animated lamp.
+ for light in exporter.mgr.find_interfaces(plLightInfo, so):
+ exporter.report.msg("Marking RT light '{}' as animated due to usage in a Python File node",
+ so.key.name, indent=3)
+ light.setProperty(plLightInfo.kLPMovable, True)
+
+ def _export_key_attrib(self, exporter, bo, so : plSceneObject, key : plKey, socket) -> None:
+ if key is None:
+ exporter.report.warn("Attribute '{}' didn't return a key and therefore will be unavailable to Python",
+ self.id_data.name, socket.links[0].name, indent=3)
+ return
+
+ key_type = _attrib_key_types[socket.attribute_type]
+ if isinstance(key_type, tuple):
+ good_key = key.type in key_type
+ else:
+ good_key = key.type == key_type
+ if not good_key:
+ exporter.report.warn("'{}' Node '{}' returned an unexpected key type '{}'",
+ self.id_data.name, socket.links[0].from_node.name,
+ plFactory.ClassName(key.type), indent=3)
+
+ if isinstance(key.object, plSceneObject):
+ self._export_ancillary_sceneobject(exporter, key.object)
+
def _get_attrib_sockets(self, idx):
for i in self.inputs:
if i.attribute_id == idx:
diff --git a/korman/nodes/node_responder.py b/korman/nodes/node_responder.py
index a400182..10ccf69 100644
--- a/korman/nodes/node_responder.py
+++ b/korman/nodes/node_responder.py
@@ -291,7 +291,7 @@ class PlasmaResponderStateNode(PlasmaNodeBase, bpy.types.Node):
# Convert the commands
commands = CommandMgr(stateMgr.responder)
- for i in self.find_outputs("msgs"):
+ for i in self._get_child_messages():
# 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...
self._generate_command(exporter, so, stateMgr.responder, commands, i)
@@ -340,16 +340,24 @@ class PlasmaResponderStateNode(PlasmaNodeBase, bpy.types.Node):
if msgNode.has_callbacks:
commandMgr.add_waitable_node(msgNode)
- if msgNode.find_output("msgs"):
+ if msgNode.has_linked_callbacks:
childWaitOn = commandMgr.add_wait(idx)
msgNode.convert_callback_message(exporter, so, msg, responder.key, childWaitOn)
else:
childWaitOn = waitOn
# Export any linked callback messages
- for i in msgNode.find_outputs("msgs"):
+ for i in self._get_child_messages(msgNode):
self._generate_command(exporter, so, responder, commandMgr, i, childWaitOn)
+ def _get_child_messages(self, node=None):
+ """Returns a list of the message nodes sent by `node`. The list is sorted such that any
+ messages with callbacks are last in the list, allowing proper wait generation.
+ """
+ if node is None:
+ node = self
+ return sorted(node.find_outputs("msgs"), key=lambda x: x.has_callbacks and x.has_linked_callbacks)
+
class PlasmaRespStateSocket(PlasmaNodeSocketBase, bpy.types.NodeSocket):
bl_color = (0.388, 0.78, 0.388, 1.0)
diff --git a/korman/operators/op_mesh.py b/korman/operators/op_mesh.py
index 85958bb..5a5a98e 100644
--- a/korman/operators/op_mesh.py
+++ b/korman/operators/op_mesh.py
@@ -28,10 +28,6 @@ class PlasmaMeshOperator:
FLARE_MATERIAL_BASE_NAME = "FLAREGEN"
-def store_material_selection(self, value):
- if bpy.data.materials.get(value, None):
- bpy.context.scene.plasma_scene.last_flare_material = value
-
class PlasmaAddFlareOperator(PlasmaMeshOperator, bpy.types.Operator):
bl_idname = "mesh.plasma_flare_add"
@@ -52,7 +48,6 @@ class PlasmaAddFlareOperator(PlasmaMeshOperator, bpy.types.Operator):
flare_material_name = bpy.props.StringProperty(name="Material",
description="A specially-crafted material to use for this flare",
default=FLARE_MATERIAL_BASE_NAME,
- update=store_material_selection,
options=set())
@classmethod
diff --git a/korman/operators/op_toolbox.py b/korman/operators/op_toolbox.py
index bb07463..a5ffb64 100644
--- a/korman/operators/op_toolbox.py
+++ b/korman/operators/op_toolbox.py
@@ -16,6 +16,7 @@
import bpy
from bpy.props import *
import pickle
+import itertools
class ToolboxOperator:
@classmethod
@@ -171,6 +172,37 @@ class PlasmaToggleAllPlasmaObjectsOperator(ToolboxOperator, bpy.types.Operator):
i.plasma_object.enabled = self.enable
return {"FINISHED"}
+
+class PlasmaToggleDoubleSidedOperator(ToolboxOperator, bpy.types.Operator):
+ bl_idname = "mesh.plasma_toggle_double_sided"
+ bl_label = "Toggle All Double Sided"
+ bl_description = "Toggles all meshes to be double sided"
+
+ enable = BoolProperty(name="Enable", description="Enable Double Sided")
+
+ def execute(self, context):
+ enable = self.enable
+ for mesh in bpy.data.meshes:
+ mesh.show_double_sided = enable
+ return {"FINISHED"}
+
+
+class PlasmaToggleDoubleSidedSelectOperator(ToolboxOperator, bpy.types.Operator):
+ bl_idname = "mesh.plasma_toggle_double_sided_selected"
+ bl_label = "Toggle Selected Double Sided"
+ bl_description = "Toggles selected meshes double sided value"
+
+ @classmethod
+ def poll(cls, context):
+ return super().poll(context) and hasattr(bpy.context, "selected_objects")
+
+ def execute(self, context):
+ mesh_list = [i.data for i in context.selected_objects if i.type == "MESH"]
+ enable = not all((mesh.show_double_sided for mesh in mesh_list))
+ for mesh in mesh_list:
+ mesh.show_double_sided = enable
+ return {"FINISHED"}
+
class PlasmaToggleEnvironmentMapsOperator(ToolboxOperator, bpy.types.Operator):
bl_idname = "texture.plasma_toggle_environment_maps"
@@ -204,3 +236,39 @@ class PlasmaTogglePlasmaObjectsOperator(ToolboxOperator, bpy.types.Operator):
for i in context.selected_objects:
i.plasma_object.enabled = enable
return {"FINISHED"}
+
+
+class PlasmaToggleSoundExportOperator(ToolboxOperator, bpy.types.Operator):
+ bl_idname = "object.plasma_toggle_sound_export"
+ bl_label = "Toggle Sound Export"
+ bl_description = "Toggles the Export function of all sound emitters' files"
+
+ enable = BoolProperty(name="Enable", description="Sound Export Enable")
+
+ def execute(self, context):
+ enable = self.enable
+ for i in bpy.data.objects:
+ if i.plasma_modifiers.soundemit is None:
+ continue
+ for sound in i.plasma_modifiers.soundemit.sounds:
+ sound.package = enable
+ return {"FINISHED"}
+
+
+class PlasmaToggleSoundExportSelectedOperator(ToolboxOperator, bpy.types.Operator):
+ bl_idname = "object.plasma_toggle_sound_export_selected"
+ bl_label = "Toggle Selected Sound Export"
+ bl_description = "Toggles the Export function of selected sound emitters' files."
+
+ @classmethod
+ def poll(cls, context):
+ return super().poll(context) and hasattr(bpy.context, "selected_objects")
+
+ def execute(self, context):
+ enable = not all((i.package for i in itertools.chain.from_iterable(i.plasma_modifiers.soundemit.sounds for i in bpy.context.selected_objects)))
+ for i in context.selected_objects:
+ if i.plasma_modifiers.soundemit is None:
+ continue
+ for sound in i.plasma_modifiers.soundemit.sounds:
+ sound.package = enable
+ return {"FINISHED"}
diff --git a/korman/properties/modifiers/__init__.py b/korman/properties/modifiers/__init__.py
index f4905d3..ba179df 100644
--- a/korman/properties/modifiers/__init__.py
+++ b/korman/properties/modifiers/__init__.py
@@ -66,6 +66,10 @@ class PlasmaModifiers(bpy.types.PropertyGroup):
setattr(cls, i.pl_id, bpy.props.PointerProperty(type=i))
bpy.types.Object.plasma_modifiers = bpy.props.PointerProperty(type=cls)
+ def test_property(self, property : str) -> bool:
+ """Tests a property on all enabled Plasma modifiers"""
+ return any((getattr(i, property) for i in self.modifiers))
+
class PlasmaModifierSpec(bpy.types.PropertyGroup):
pass
diff --git a/korman/properties/modifiers/anim.py b/korman/properties/modifiers/anim.py
index 78047b5..c836df4 100644
--- a/korman/properties/modifiers/anim.py
+++ b/korman/properties/modifiers/anim.py
@@ -60,37 +60,52 @@ class PlasmaAnimationModifier(ActionModifier, PlasmaModifierProperties):
description="Marker indicating where the default loop begins")
loop_end = StringProperty(name="Loop End",
description="Marker indicating where the default loop ends")
+ obj_sdl_anim = StringProperty(name="SDL Animation",
+ description="Name of the SDL variable to use for this animation",
+ options=set())
+
+ @property
+ def anim_type(self):
+ return plAgeGlobalAnim if self.enabled and self.obj_sdl_anim else plATCAnim
def export(self, exporter, bo, so):
action = self.blender_action
-
- atcanim = exporter.mgr.find_create_object(plATCAnim, so=so)
- atcanim.autoStart = self.auto_start
- atcanim.loop = self.loop
-
- # Simple start and loop info
- if action is not None:
- markers = action.pose_markers
- initial_marker = markers.get(self.initial_marker)
- if initial_marker is not None:
- atcanim.initial = _convert_frame_time(initial_marker.frame)
- else:
- atcanim.initial = -1.0
- if self.loop:
- loop_start = markers.get(self.loop_start)
- if loop_start is not None:
- atcanim.loopStart = _convert_frame_time(loop_start.frame)
+ anim_mod = bo.plasma_modifiers.animation
+
+ # Do not create the private animation here. The animation converter itself does this
+ # before we reach this point. If it does not create an animation, then we might create an
+ # empty animation that crashes Uru.
+ atcanim = exporter.mgr.find_object(anim_mod.anim_type, so=so)
+ if atcanim is None:
+ return
+
+ if not isinstance(atcanim, plAgeGlobalAnim):
+ atcanim.autoStart = self.auto_start
+ atcanim.loop = self.loop
+
+ # Simple start and loop info for ATC
+ if action is not None:
+ markers = action.pose_markers
+ initial_marker = markers.get(self.initial_marker)
+ if initial_marker is not None:
+ atcanim.initial = _convert_frame_time(initial_marker.frame)
else:
+ atcanim.initial = -1.0
+ if self.loop:
+ loop_start = markers.get(self.loop_start)
+ if loop_start is not None:
+ atcanim.loopStart = _convert_frame_time(loop_start.frame)
+ else:
+ atcanim.loopStart = atcanim.start
+ loop_end = markers.get(self.loop_end)
+ if loop_end is not None:
+ atcanim.loopEnd = _convert_frame_time(loop_end.frame)
+ else:
+ atcanim.loopEnd = atcanim.end
+ else:
+ if self.loop:
atcanim.loopStart = atcanim.start
- loop_end = markers.get(self.loop_end)
- if loop_end is not None:
- atcanim.loopEnd = _convert_frame_time(loop_end.frame)
- else:
atcanim.loopEnd = atcanim.end
- else:
- if self.loop:
- atcanim.loopStart = atcanim.start
- atcanim.loopEnd = atcanim.end
class AnimGroupObject(idprops.IDPropObjectMixin, bpy.types.PropertyGroup):
diff --git a/korman/properties/modifiers/base.py b/korman/properties/modifiers/base.py
index 5f4ea34..0f52486 100644
--- a/korman/properties/modifiers/base.py
+++ b/korman/properties/modifiers/base.py
@@ -30,10 +30,30 @@ class PlasmaModifierProperties(bpy.types.PropertyGroup):
def destroyed(self):
pass
+ @property
+ def draw_opaque(self):
+ """Render geometry before the avatar"""
+ return False
+
+ @property
+ def draw_framebuf(self):
+ """Render geometry after the avatar but before other blended geometry"""
+ return False
+
+ @property
+ def draw_no_defer(self):
+ """Disallow geometry being sorted into a blending span"""
+ return False
+
@property
def enabled(self):
return self.display_order >= 0
+ @property
+ def face_sort(self):
+ """Indicates that the geometry's faces should be sorted by the engine"""
+ return False
+
def harvest_actors(self):
return ()
diff --git a/korman/properties/modifiers/gui.py b/korman/properties/modifiers/gui.py
index 069aa16..a9a94da 100644
--- a/korman/properties/modifiers/gui.py
+++ b/korman/properties/modifiers/gui.py
@@ -578,6 +578,8 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz
anim_stage = nodes.new("PlasmaAnimStageNode")
anim_stage.anim_name = "LinkOut"
anim_settings = nodes.new("PlasmaAnimStageSettingsNode")
+ anim_settings.forward = "kPlayAuto"
+ anim_settings.stage_advance = "kAdvanceAuto"
anim_stage.link_input(anim_settings, "stage", "stage_settings")
msb = nodes.new("PlasmaMultiStageBehaviorNode")
@@ -616,6 +618,8 @@ class PlasmaLinkingBookModifier(PlasmaModifierProperties, PlasmaModifierLogicWiz
anim_stage = nodes.new("PlasmaAnimStageNode")
anim_stage.anim_name = "LinkOut"
anim_settings = nodes.new("PlasmaAnimStageSettingsNode")
+ anim_settings.forward = "kPlayAuto"
+ anim_settings.stage_advance = "kAdvanceAuto"
anim_stage.link_input(anim_settings, "stage", "stage_settings")
msb = nodes.new("PlasmaMultiStageBehaviorNode")
diff --git a/korman/properties/modifiers/physics.py b/korman/properties/modifiers/physics.py
index f561144..907875d 100644
--- a/korman/properties/modifiers/physics.py
+++ b/korman/properties/modifiers/physics.py
@@ -19,6 +19,7 @@ from PyHSPlasma import *
from .base import PlasmaModifierProperties
from ...exporter import ExportError
+from ... import idprops
# These are the kinds of physical bounds Plasma can work with.
# This sequence is acceptable in any EnumProperty
@@ -62,6 +63,11 @@ class PlasmaCollider(PlasmaModifierProperties):
mass = FloatProperty(name="Mass", description="Mass of object in pounds", min=0.0, default=1.0)
start_asleep = BoolProperty(name="Start Asleep", description="Object is not active until influenced by another object", default=False)
+ proxy_object = PointerProperty(name="Proxy",
+ description="Object used as the collision geometry",
+ type=bpy.types.Object,
+ poll=idprops.poll_mesh_objects)
+
def export(self, exporter, bo, so):
# All modifier properties are examined by this little stinker...
exporter.physics.generate_physical(bo, so)
diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py
index 856840c..3683039 100644
--- a/korman/properties/modifiers/render.py
+++ b/korman/properties/modifiers/render.py
@@ -25,6 +25,98 @@ from ...exporter import utils
from ...exporter.explosions import ExportError
from ... import idprops
+class PlasmaBlendOntoObject(bpy.types.PropertyGroup):
+ blend_onto = PointerProperty(name="Blend Onto",
+ description="Object to render first",
+ options=set(),
+ type=bpy.types.Object,
+ poll=idprops.poll_drawable_objects)
+ enabled = BoolProperty(name="Enabled",
+ default=True,
+ options=set())
+
+
+class PlasmaBlendMod(PlasmaModifierProperties):
+ pl_id = "blend"
+
+ bl_category = "Render"
+ bl_label = "Blending"
+ bl_description = "Advanced Blending Options"
+
+ render_level = EnumProperty(name="Render Pass",
+ description="Suggested render pass for this object.",
+ items=[("AUTO", "(Auto)", "Let Korman decide when to render this object."),
+ ("OPAQUE", "Before Avatar", "Prefer for the object to draw before the avatar."),
+ ("FRAMEBUF", "Frame Buffer", "Prefer for the object to draw after the avatar but before other blended objects."),
+ ("BLEND", "Blended", "Prefer for the object to draw after most other geometry in the blended pass.")],
+ options=set())
+ sort_faces = EnumProperty(name="Sort Faces",
+ description="",
+ items=[("AUTO", "(Auto)", "Let Korman decide if faces should be sorted."),
+ ("ALWAYS", "Always", "Force the object's faces to be sorted."),
+ ("NEVER", "Never", "Force the object's faces to never be sorted.")],
+ options=set())
+
+ dependencies = CollectionProperty(type=PlasmaBlendOntoObject)
+ active_dependency_index = IntProperty(options={"HIDDEN"})
+
+ def export(self, exporter, bo, so):
+ # What'er you lookin at?
+ pass
+
+ @property
+ def draw_opaque(self):
+ return self.render_level == "OPAQUE"
+
+ @property
+ def draw_framebuf(self):
+ return self.render_level == "FRAMEBUF"
+
+ @property
+ def draw_no_defer(self):
+ return self.render_level != "BLEND"
+
+ @property
+ def face_sort(self):
+ return self.sort_faces == "ALWAYS"
+
+ @property
+ def no_face_sort(self):
+ return self.sort_faces == "NEVER"
+
+ @property
+ def has_dependencies(self):
+ return bool(self.dependencies)
+
+ @property
+ def has_circular_dependency(self):
+ return self._check_circular_dependency()
+
+ def _check_circular_dependency(self, objects=None):
+ if objects is None:
+ objects = set()
+ elif self.name in objects:
+ return True
+ objects.add(self.name)
+
+ for i in self.iter_dependencies():
+ # New deep copy of the set for each dependency, so an object can be reused as a
+ # dependant's dependant.
+ this_branch = set(objects)
+ sub_mod = i.plasma_modifiers.blend
+ if sub_mod.enabled and sub_mod._check_circular_dependency(this_branch):
+ return True
+ return False
+
+ def iter_dependencies(self):
+ for i in (j.blend_onto for j in self.dependencies if j.blend_onto is not None and j.enabled):
+ yield i
+
+ def sanity_check(self):
+ if self.has_circular_dependency:
+ raise ExportError("'{}': Circular Render Dependency detected!".format(self.name))
+
+
class PlasmaDecalManagerRef(bpy.types.PropertyGroup):
enabled = BoolProperty(name="Enabled",
default=True,
@@ -439,6 +531,8 @@ class PlasmaLightingMod(PlasmaModifierProperties):
return True
if self.id_data.plasma_object.has_transform_animation:
return True
+ if mods.collision.enabled and mods.collision.dynamic:
+ return True
return False
diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py
index 00f289a..71b37fd 100644
--- a/korman/properties/modifiers/sound.py
+++ b/korman/properties/modifiers/sound.py
@@ -189,9 +189,13 @@ class PlasmaSound(idprops.IDPropMixin, bpy.types.PropertyGroup):
exporter.output.add_sfx(self._sound)
# There is some bug in the MOUL code that causes a crash if this does not match the expected
- # result. There's no sense in debugging that though--the user should never specify
- # streaming vs static. That's an implementation detail.
- pClass = plWin32StreamingSound if length > 4.0 else plWin32StaticSound
+ # result. Worse, PotS seems to not like static sounds that are brand-new to it. Possibly because
+ # it needs to be decompressed outside of game. There's no sense in debugging any of that
+ # though--the user should never specify streaming vs static. That's an implementation detail.
+ if exporter.mgr.getVer() != pvMoul and self._sound.plasma_sound.package:
+ pClass = plWin32StreamingSound
+ else:
+ pClass = plWin32StreamingSound if length > 4.0 else plWin32StaticSound
# OK. Any Plasma engine that uses OpenAL (MOUL) is subject to this restriction.
# 3D Positional audio MUST... and I mean MUST... have mono emitters.
diff --git a/korman/properties/modifiers/water.py b/korman/properties/modifiers/water.py
index eafa22f..806883b 100644
--- a/korman/properties/modifiers/water.py
+++ b/korman/properties/modifiers/water.py
@@ -249,7 +249,7 @@ class PlasmaWaterModifier(idprops.IDPropMixin, PlasmaModifierProperties, bpy.typ
# This is much like what happened in PyPRP
speed = self.wind_speed
- matrix = wind_obj.matrix_world
+ matrix = self.wind_object.matrix_world
wind_dir = hsVector3(matrix[1][0] * speed, matrix[1][1] * speed, matrix[1][2] * speed)
else:
# Stolen shamelessly from PyPRP
diff --git a/korman/properties/prop_object.py b/korman/properties/prop_object.py
index e9de5e9..4413160 100644
--- a/korman/properties/prop_object.py
+++ b/korman/properties/prop_object.py
@@ -19,13 +19,11 @@ from PyHSPlasma import *
class PlasmaObject(bpy.types.PropertyGroup):
def _enabled(self, context):
- # This makes me sad
- if not self.is_inited:
+ if not self.is_property_set("page"):
self._init(context)
- self.is_inited = True
def _init(self, context):
- o = context.object
+ o = self.id_data
age = context.scene.world.plasma_age
# We want to encourage the pages = layers paradigm.
@@ -47,8 +45,8 @@ class PlasmaObject(bpy.types.PropertyGroup):
page = StringProperty(name="Page",
description="Page this object will be exported to")
- # Implementation Details
- is_inited = BoolProperty(description="INTERNAL: Init proc complete",
+ # DEAD - leaving in just in case external code uses it
+ is_inited = BoolProperty(description="DEAD",
default=False,
options={"HIDDEN"})
diff --git a/korman/properties/prop_world.py b/korman/properties/prop_world.py
index 1e159a5..5e223af 100644
--- a/korman/properties/prop_world.py
+++ b/korman/properties/prop_world.py
@@ -31,7 +31,7 @@ class PlasmaFni(bpy.types.PropertyGroup):
fog_method = EnumProperty(name="Fog Type",
items=[
("linear", "Linear", "Linear Fog"),
- ("exp2", "Exponential", "Exponential Fog"),
+ ("exp", "Exponential", "Exponential Fog"),
("none", "None", "Use fog from the previous age")
])
fog_start = FloatProperty(name="Start",
@@ -148,6 +148,14 @@ class PlasmaPage(bpy.types.PropertyGroup):
class PlasmaAge(bpy.types.PropertyGroup):
def export(self, exporter):
+ if exporter.mgr.getVer() == pvMoul:
+ log_func = exporter.report.warn
+ else:
+ log_func = exporter.report.port
+ if self.seq_prefix <= self.MOUL_PREFIX_RANGE[0] or self.seq_prefix >= self.MOUL_PREFIX_RANGE[1]:
+ log_func("Age Sequence Prefix {} is potentially out of range (should be between {} and {})",
+ self.seq_prefix, *self.MOUL_PREFIX_RANGE)
+
_age_info = plAgeInfo()
_age_info.dayLength = self.day_length
_age_info.lingerTime = 180 # this is fairly standard
@@ -157,6 +165,10 @@ class PlasmaAge(bpy.types.PropertyGroup):
_age_info.startDateTime = self.start_time
return _age_info
+ # Sequence prefix helpers
+ MOUL_PREFIX_RANGE = ((pow(2, 16) - pow(2, 15)) * -1, pow(2, 15) - 1)
+ SP_PRFIX_RANGE = ((pow(2, 24) - pow(2, 23)) * -1, pow(2, 23) - 1)
+
day_length = FloatProperty(name="Day Length",
description="Length of a day (in hours) on this age",
default=30.230000,
@@ -169,7 +181,10 @@ class PlasmaAge(bpy.types.PropertyGroup):
min=0)
seq_prefix = IntProperty(name="Sequence Prefix",
description="A unique numerical ID for this age",
+ min=SP_PRFIX_RANGE[0],
soft_min=0, # Negative indicates global--advanced users only
+ soft_max=MOUL_PREFIX_RANGE[1],
+ max=SP_PRFIX_RANGE[1],
default=100)
pages = CollectionProperty(name="Pages",
description="Registry pages for this age",
diff --git a/korman/ui/modifiers/anim.py b/korman/ui/modifiers/anim.py
index 5216dda..614ff07 100644
--- a/korman/ui/modifiers/anim.py
+++ b/korman/ui/modifiers/anim.py
@@ -40,10 +40,12 @@ def animation(modifier, layout, context):
if action:
layout.prop_search(modifier, "initial_marker", action, "pose_markers", icon="PMARKER")
col = layout.column()
- col.enabled = modifier.loop
+ col.enabled = modifier.loop and not modifier.obj_sdl_anim
col.prop_search(modifier, "loop_start", action, "pose_markers", icon="PMARKER")
col.prop_search(modifier, "loop_end", action, "pose_markers", icon="PMARKER")
-
+ layout.separator()
+ layout.prop(modifier, "obj_sdl_anim")
+
def animation_filter(modifier, layout, context):
split = layout.split()
diff --git a/korman/ui/modifiers/physics.py b/korman/ui/modifiers/physics.py
index 615e476..fa9d8e9 100644
--- a/korman/ui/modifiers/physics.py
+++ b/korman/ui/modifiers/physics.py
@@ -39,6 +39,11 @@ def collision(modifier, layout, context):
col.active = modifier.dynamic
col.prop(modifier, "mass")
+ layout.separator()
+ row = layout.row()
+ row.active = modifier.bounds == "trimesh"
+ row.prop(modifier, "proxy_object")
+
def subworld_def(modifier, layout, context):
layout.prop(modifier, "sub_type")
if modifier.sub_type != "dynamicav":
diff --git a/korman/ui/modifiers/render.py b/korman/ui/modifiers/render.py
index 64d3c9b..7c6dfa6 100644
--- a/korman/ui/modifiers/render.py
+++ b/korman/ui/modifiers/render.py
@@ -18,6 +18,36 @@ import bpy
from .. import ui_list
from ...exporter.mesh import _VERTEX_COLOR_LAYERS
+class BlendOntoListUI(bpy.types.UIList):
+ def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
+ if item.blend_onto is None:
+ layout.label("[No Object Specified]", icon="ERROR")
+ else:
+ layout.label(item.blend_onto.name, icon="OBJECT_DATA")
+ layout.prop(item, "enabled", text="")
+
+
+def blend(modifier, layout, context):
+ # Warn if there are render dependencies and a manual render level specification -- this
+ # could lead to unpredictable results.
+ layout.alert = modifier.render_level != "AUTO" and bool(modifier.dependencies)
+ layout.prop(modifier, "render_level")
+ layout.alert = False
+ layout.prop(modifier, "sort_faces")
+
+ layout.separator()
+ layout.label("Render Dependencies:")
+ ui_list.draw_modifier_list(layout, "BlendOntoListUI", modifier, "dependencies",
+ "active_dependency_index", rows=2, maxrows=4)
+ try:
+ dependency_ref = modifier.dependencies[modifier.active_dependency_index]
+ except:
+ pass
+ else:
+ layout.alert = dependency_ref.blend_onto is None
+ layout.prop(dependency_ref, "blend_onto")
+
+
class DecalMgrListUI(bpy.types.UIList):
def draw_item(self, context, layout, data, item, icon, active_data, active_property, index=0, flt_flag=0):
if item.name:
diff --git a/korman/ui/ui_menus.py b/korman/ui/ui_menus.py
index 1073a98..89f6fe0 100644
--- a/korman/ui/ui_menus.py
+++ b/korman/ui/ui_menus.py
@@ -40,8 +40,25 @@ def build_plasma_menu(self, context):
self.layout.separator()
self.layout.menu("menu.plasma_add", icon="URL")
+
+class PlasmaHelpMenu(PlasmaMenu, bpy.types.Menu):
+ bl_idname = "menu.plasma_help"
+ bl_label = "Korman..."
+
+ def draw(self, context):
+ layout = self.layout
+ layout.operator("wm.url_open", text="About Korman", icon="URL").url = "https://guildofwriters.org/wiki/Korman"
+ layout.operator("wm.url_open", text="Getting Started", icon="URL").url = "https://guildofwriters.org/wiki/Korman:Getting_Started"
+ layout.operator("wm.url_open", text="Tutorials", icon="URL").url = "https://guildofwriters.org/wiki/Category:Korman_Tutorials"
+
+
+def build_plasma_help_menu(self, context):
+ self.layout.menu("menu.plasma_help", text="Korman", icon="URL")
+
def register():
bpy.types.INFO_MT_add.append(build_plasma_menu)
+ bpy.types.INFO_MT_help.prepend(build_plasma_help_menu)
def unregister():
bpy.types.INFO_MT_add.remove(build_plasma_menu)
+ bpy.types.INFO_MT_help.remove(build_plasma_help_menu)
diff --git a/korman/ui/ui_toolbox.py b/korman/ui/ui_toolbox.py
index 56a86da..b8c1539 100644
--- a/korman/ui/ui_toolbox.py
+++ b/korman/ui/ui_toolbox.py
@@ -14,6 +14,7 @@
# along with Korman. If not, see .
import bpy
+import itertools
class ToolboxPanel:
bl_category = "Tools"
@@ -44,12 +45,24 @@ class PlasmaToolboxPanel(ToolboxPanel, bpy.types.Panel):
col.label("Plasma Pages:")
col.operator("object.plasma_move_selection_to_page", icon="BOOKMARKS", text="Move to Page")
col.operator("object.plasma_select_page_objects", icon="RESTRICT_SELECT_OFF", text="Select Objects")
+
+ col.label("Package Sounds:")
+ col.operator("object.plasma_toggle_sound_export", icon="MUTE_IPO_OFF", text="Enable All").enable = True
+ all_sounds_export = all((i.package for i in itertools.chain.from_iterable(i.plasma_modifiers.soundemit.sounds for i in bpy.context.selected_objects if i.plasma_modifiers.soundemit.enabled)))
+ col.operator("object.plasma_toggle_sound_export_selected", icon="OUTLINER_OB_SPEAKER", text="Disable Selection" if all_sounds_export else "Enable Selection")
+ col.operator("object.plasma_toggle_sound_export", icon="MUTE_IPO_ON", text="Disable All").enable = False
col.label("Textures:")
col.operator("texture.plasma_enable_all_textures", icon="TEXTURE", text="Enable All")
col.operator("texture.plasma_toggle_environment_maps", icon="IMAGE_RGB", text="Enable All EnvMaps").enable = True
col.operator("texture.plasma_toggle_environment_maps", icon="IMAGE_RGB_ALPHA", text="Disable All EnvMaps").enable = False
+ # Double Sided Operators
+ col.label("Double Sided:")
+ col.operator("mesh.plasma_toggle_double_sided", icon="MESH_DATA", text="Disable All").enable = False
+ all_double_sided = all((i.data.show_double_sided for i in bpy.context.selected_objects if i.type == "MESH"))
+ col.operator("mesh.plasma_toggle_double_sided_selected", icon="BORDER_RECT", text="Disable Selection" if all_double_sided else "Enable Selection")
+
col.label("Convert:")
col.operator("object.plasma_convert_plasma_objects", icon="OBJECT_DATA", text="Plasma Objects")
col.operator("texture.plasma_convert_layer_opacities", icon="IMAGE_RGB_ALPHA", text="Layer Opacities")
diff --git a/korman/ui/ui_world.py b/korman/ui/ui_world.py
index 5443248..3ed422e 100644
--- a/korman/ui/ui_world.py
+++ b/korman/ui/ui_world.py
@@ -210,6 +210,8 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
# Age Names should really be legal Python 2.x identifiers for AgeSDLHooks
legal_identifier = korlib.is_legal_python2_identifier(age.age_name)
+ illegal_age_name = not legal_identifier or '_' in age.age_name
+ bad_prefix = age.seq_prefix >= age.MOUL_PREFIX_RANGE[1] or age.seq_prefix <= age.MOUL_PREFIX_RANGE[0]
# Core settings
layout.separator()
@@ -222,17 +224,28 @@ class PlasmaAgePanel(AgeButtonsPanel, bpy.types.Panel):
col = split.column()
col.label("Age Settings:")
+ col.alert = bad_prefix
col.prop(age, "seq_prefix", text="ID")
- col.alert = not legal_identifier or '_' in age.age_name
+ col.alert = illegal_age_name
col.prop(age, "age_name", text="")
+ if age.seq_prefix >= age.MOUL_PREFIX_RANGE[1]:
+ layout.label(text="Your sequence prefix is too high for Myst Online: Uru Live", icon="ERROR")
+ elif age.seq_prefix <= age.MOUL_PREFIX_RANGE[0]:
+ # Unlikely.
+ layout.label(text="Your sequence prefix is too low for Myst Online: Uru Live", icon="ERROR")
+
# Display a hint if the identifier is illegal
- if not legal_identifier:
- if korlib.is_python_keyword(age.age_name):
+ if illegal_age_name:
+ if not age.age_name:
+ layout.label(text="Age names cannot be empty", icon="ERROR")
+ elif korlib.is_python_keyword(age.age_name):
layout.label(text="Ages should not be named the same as a Python keyword", icon="ERROR")
elif age.age_sdl:
fixed_identifier = korlib.replace_python2_identifier(age.age_name)
layout.label(text="Age's SDL will use the name '{}'".format(fixed_identifier), icon="ERROR")
+ if '_' in age.age_name:
+ layout.label(text="Age names should not contain underscores", icon="ERROR")
layout.separator()
split = layout.split()
@@ -262,6 +275,10 @@ class PlasmaEnvironmentPanel(AgeButtonsPanel, bpy.types.Panel):
layout = self.layout
fni = context.world.plasma_fni
+ # warn about reversed linear fog values
+ if fni.fog_method == "linear" and fni.fog_start >= fni.fog_end and (fni.fog_start + fni.fog_end) != 0:
+ layout.label(text="Fog Start Value should be less than the End Value", icon="ERROR")
+
# basic colors
split = layout.split()
col = split.column()