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()