From 6a3b09b7473a6573193bd29d24888527395ca61d Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sat, 10 Jun 2017 20:45:34 -0400 Subject: [PATCH 1/7] Refactor export logging The export logger and export reporter have been merged together to form an eventually much more powerful export analysis feature. For now, the benefit is that general log messages don't have to be so fiddly with print statements and string formatting. You're welcome. --- korlib/texture.cpp | 23 ++++--- korman/exporter/convert.py | 31 ++++----- korman/exporter/etlight.py | 20 +++--- korman/exporter/logger.py | 95 ++++++++++++++------------- korman/exporter/material.py | 45 +++++++------ korman/exporter/mesh.py | 13 ++-- korman/exporter/rtlight.py | 37 ++++++----- korman/korlib/texture.py | 8 +-- korman/nodes/node_conditions.py | 6 +- korman/properties/modifiers/render.py | 4 +- korman/properties/modifiers/sound.py | 2 +- 11 files changed, 154 insertions(+), 130 deletions(-) diff --git a/korlib/texture.cpp b/korlib/texture.cpp index af4f0f6..63d3975 100644 --- a/korlib/texture.cpp +++ b/korlib/texture.cpp @@ -266,14 +266,19 @@ static int _generate_detail_map(pyGLTexture* self, uint8_t* buf, size_t bufsz, G return 0; } -static _LevelData _get_level_data(pyGLTexture* self, GLint level, bool bgra, bool quiet) { +static _LevelData _get_level_data(pyGLTexture* self, GLint level, bool bgra, PyObject* report) { GLint width, height; glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_WIDTH, &width); glGetTexLevelParameteriv(GL_TEXTURE_2D, level, GL_TEXTURE_HEIGHT, &height); GLenum fmt = bgra ? GL_BGRA_EXT : GL_RGBA; - if (!quiet) - PySys_WriteStdout(" Level #%i: %ix%i\n", level, width, height); + // Print out the debug message + if (report && report != Py_None) { + PyObjectRef msg_func = PyObject_GetAttrString(report, "msg"); + PyObjectRef args = Py_BuildValue("siii", "Level #{}: {}x{}", level, width, height); + PyObjectRef kwargs = Py_BuildValue("{s:i}", "indent", 2); + PyObjectRef result = PyObject_Call(msg_func, args, kwargs); + } size_t bufsz; bufsz = (width * height * 4); @@ -284,18 +289,18 @@ static _LevelData _get_level_data(pyGLTexture* self, GLint level, bool bgra, boo static PyObject* pyGLTexture_get_level_data(pyGLTexture* self, PyObject* args, PyObject* kwargs) { static char* kwlist[] = { _pycs("level"), _pycs("calc_alpha"), _pycs("bgra"), - _pycs("quiet"), _pycs("fast"), NULL }; + _pycs("report"), _pycs("fast"), NULL }; GLint level = 0; bool calc_alpha = false; bool bgra = false; - bool quiet = false; + PyObject* report = nullptr; bool fast = false; - if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ibbbb", kwlist, &level, &calc_alpha, &bgra, &quiet, &fast)) { - PyErr_SetString(PyExc_TypeError, "get_level_data expects an optional int, bool, bool, bool, bool"); + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "|ibbOb", kwlist, &level, &calc_alpha, &bgra, &report, &fast)) { + PyErr_SetString(PyExc_TypeError, "get_level_data expects an optional int, bool, bool, obejct, bool"); return NULL; } - _LevelData data = _get_level_data(self, level, bgra, quiet); + _LevelData data = _get_level_data(self, level, bgra, report); if (fast) return pyBuffer_Steal(data.m_data, data.m_dataSize); @@ -372,7 +377,7 @@ static PyMethodDef pyGLTexture_Methods[] = { }; static PyObject* pyGLTexture_get_has_alpha(pyGLTexture* self, void*) { - _LevelData data = _get_level_data(self, 0, false, true); + _LevelData data = _get_level_data(self, 0, false, nullptr); for (size_t i = 3; i < data.m_dataSize; i += 4) { if (data.m_data[i] != 255) { delete[] data.m_data; diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 4e26fe0..9416588 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -42,14 +42,13 @@ class Exporter: return Path(self._op.filepath).stem def run(self): - with logger.ExportLogger(self._op.filepath) as _log: - print("Exporting '{}.age'".format(self.age_name)) + with logger.ExportLogger(self._op.filepath) as self.report: + self.report.msg("Exporting '{}.age'", self.age_name) start = time.perf_counter() # Step 0: Init export resmgr and stuff self.mgr = manager.ExportManager(self) self.mesh = mesh.MeshConverter(self) - self.report = logger.ExportAnalysis() self.physics = physics.PhysicsConverter(self) self.light = rtlight.LightConverter(self) self.animation = animation.AnimationConverter(self) @@ -95,10 +94,10 @@ class Exporter: # And finally we crow about how awesomely fast we are... end = time.perf_counter() - print("\nExported {}.age in {:.2f} seconds".format(self.age_name, end-start)) + self.report.msg("\nExported {}.age in {:.2f} seconds", self.age_name, end-start) def _bake_static_lighting(self): - oven = etlight.LightBaker() + oven = etlight.LightBaker(self.report) oven.bake_static_lighting(self._objects) def _collect_objects(self): @@ -163,7 +162,7 @@ class Exporter: parent = bo.parent if parent is not None: if parent.plasma_object.enabled: - print(" Attaching to parent SceneObject '{}'".format(parent.name)) + self.report.msg("Attaching to parent SceneObject '{}'", parent.name, indent=1) parent_ci = self._export_coordinate_interface(None, parent) parent_ci.addChild(so.key) else: @@ -187,8 +186,9 @@ class Exporter: return so.coord.object def _export_scene_objects(self): + log_msg = self.report.msg for bl_obj in self._objects: - print("\n[SceneObject '{}']".format(bl_obj.name)) + log_msg("\n[SceneObject '{}']".format(bl_obj.name)) # First pass: do things specific to this object type. # note the function calls: to export a MESH, it's _export_mesh_blobj @@ -196,10 +196,10 @@ class Exporter: try: export_fn = getattr(self, export_fn) except AttributeError: - print("WARNING: '{}' is a Plasma Object of Blender type '{}'".format(bl_obj.name, bl_obj.type)) - print("... And I have NO IDEA what to do with that! Tossing.") + self.report.warn("""'{}' is a Plasma Object of Blender type '{}' + ... And I have NO IDEA what to do with that! Tossing.""".format(bl_obj.name, bl_obj.type)) continue - print(" Blender Object '{}' of type '{}'".format(bl_obj.name, bl_obj.type)) + log_msg("Blender Object '{}' of type '{}'".format(bl_obj.name, bl_obj.type), indent=1) # Create a sceneobject if one does not exist. # Before we call the export_fn, we need to determine if this object is an actor of any @@ -211,7 +211,7 @@ class Exporter: # And now we puke out the modifiers... for mod in bl_obj.plasma_modifiers.modifiers: - print(" Exporting '{}' modifier as '{}'".format(mod.bl_label, mod.key_name)) + log_msg("Exporting '{}' modifier".format(mod.bl_label), indent=1) mod.export(self, bl_obj, sceneobject) def _export_empty_blobj(self, so, bo): @@ -227,14 +227,14 @@ class Exporter: if bo.data.materials: self.mesh.export_object(bo) else: - print(" No material(s) on the ObData, so no drawables") + self.report.msg("No material(s) on the ObData, so no drawables", indent=1) def _export_referenced_node_trees(self): - print("\nChecking Logic Trees...") + self.report.msg("\nChecking Logic Trees...") need_to_export = ((name, bo, so) for name, (bo, so) in self.want_node_trees.items() if name not in self.node_trees_exported) for tree, bo, so in need_to_export: - print(" NodeTree '{}'".format(tree)) + self.report.msg("NodeTree '{}'", tree, indent=1) bpy.data.node_groups[tree].export(self, bo, so) def _harvest_actors(self): @@ -269,8 +269,6 @@ class Exporter: return False def _post_process_scene_objects(self): - print("\nPostprocessing SceneObjects...") - mat_mgr = self.mesh.material for bl_obj in self._objects: sceneobject = self.mgr.find_object(plSceneObject, bl=bl_obj) @@ -292,5 +290,4 @@ class Exporter: for mod in bl_obj.plasma_modifiers.modifiers: proc = getattr(mod, "post_export", None) if proc is not None: - print(" '{}' modifier '{}'".format(bl_obj.name, mod.key_name)) proc(self, bl_obj, sceneobject) diff --git a/korman/exporter/etlight.py b/korman/exporter/etlight.py index ab93089..c938520 100644 --- a/korman/exporter/etlight.py +++ b/korman/exporter/etlight.py @@ -16,6 +16,7 @@ import bpy from bpy.app.handlers import persistent +from .logger import ExportLogger from .mesh import _VERTEX_COLOR_LAYERS from ..helpers import * @@ -24,8 +25,9 @@ _NUM_RENDER_LAYERS = 20 class LightBaker: """ExportTime Lighting""" - def __init__(self): + def __init__(self, report=None): self._lightgroups = {} + self._report = report if report is not None else ExportLogger() self._uvtexs = {} def _apply_render_settings(self, toggle, vcols): @@ -63,7 +65,7 @@ class LightBaker: def bake_static_lighting(self, objs): """Bakes all static lighting for Plasma geometry""" - print("\nBaking Static Lighting...") + self._report.msg("\nBaking Static Lighting...") bake = self._harvest_bakable_objects(objs) with GoodNeighbor() as toggle: @@ -82,24 +84,26 @@ class LightBaker: bpy.context.scene.layers = (True,) * _NUM_RENDER_LAYERS # Step 1: Prepare... Apply UVs, etc, etc, etc - print(" Preparing to bake...") + self._report.msg("Preparing to bake...", indent=1) for key in bake.keys(): if key[0] == "lightmap": for i in range(len(bake[key])-1, -1, -1): obj = bake[key][i] if not self._prep_for_lightmap(obj, toggle): - print(" Lightmap '{}' will not be baked -- no applicable lights".format(obj.name)) + self._report.msg("Lightmap '{}' will not be baked -- no applicable lights", + obj.name, indent=2) bake[key].pop(i) elif key[0] == "vcol": for i in range(len(bake[key])-1, -1, -1): obj = bake[key][i] if not self._prep_for_vcols(obj, toggle): if self._has_valid_material(obj): - print(" VCols '{}' will not be baked -- no applicable lights".format(obj.name)) + self._report.msg("VCols '{}' will not be baked -- no applicable lights", + obj.name, indent=2) bake[key].pop(i) else: raise RuntimeError(key[0]) - print(" ...") + self._report.msg(" ...") # Step 2: BAKE! for key, value in bake.items(): @@ -107,10 +111,10 @@ class LightBaker: continue if key[0] == "lightmap": - print(" {} Lightmap(s) [H:{:X}]".format(len(value), hash(key))) + self._report.msg("{} Lightmap(s) [H:{:X}]", len(value), hash(key), indent=1) self._bake_lightmaps(value, key[1:]) elif key[0] == "vcol": - print(" {} Crap Light(s)".format(len(value))) + self._report.msg("{} Crap Light(s)", len(value), indent=1) self._bake_vcols(value) else: raise RuntimeError(key[0]) diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index e8dc566..b12d8d2 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -16,61 +16,62 @@ from pathlib import Path import sys -class ExportAnalysis: - """This is used to collect artist action items from the export process. You can warn about - portability issues, possible oversights, etc. The benefit here is that the user doesn't have - to look through all of the gobbledygook in the export log. - """ - - _porting = [] - _warnings = [] - - def save(self): - # TODO - pass - - def port(self, message, indent=0): - self._porting.append(message) - print(" " * indent, end="") - print("PORTING: {}".format(message)) - - def warn(self, message, indent=0): - self._warnings.append(message) - print(" " * indent, end="") - print("WARNING: {}".format(message)) - - class ExportLogger: - """Yet Another Logger(TM)""" + def __init__(self, age_path=None): + self._porting = [] + self._warnings = [] + self._age_path = age_path + self._file = None + + def __enter__(self): + assert self._age_path is not None - def __init__(self, ageFile): # Make the log file name from the age file path -- this ensures we're not trying to write # the log file to the same directory Blender.exe is in, which might be a permission error - my_path = Path(ageFile) + my_path = Path(self._age_path) my_path = my_path.with_name("{}_export".format(my_path.stem)).with_suffix(".log") self._file = open(str(my_path), "w") - - for i in dir(self._file): - if not hasattr(self, i): - setattr(self, i, getattr(self._file, i)) - - def __enter__(self): - self._stdout, sys.stdout = sys.stdout, self._file - self._stderr, sys.stderr = sys.stderr, self._file + return self def __exit__(self, type, value, traceback): - sys.stdout = self._stdout - sys.stderr = self._stderr + self._file.close() + return False - def flush(self): - self._file.flush() - self._stdout.flush() - self._stderr.flush() + def msg(self, *args, **kwargs): + assert args + indent = kwargs.get("indent", 0) + msg = "{}{}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) + if self._file is None: + print(msg) + else: + self._file.writelines((msg, "\n")) - def write(self, str): - self._file.write(str) - self._stdout.write(str) + def port(self, *args, **kwargs): + assert args + indent = kwargs.get("indent", 0) + msg = "{}PORTING: {}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) + if self._file is None: + print(msg) + else: + self._file.writelines((msg, "\n")) + self._porting.append(args[0]) + + def save(self): + # TODO + pass - def writelines(self, seq): - self._file.writelines(seq) - self._stdout.writelines(seq) + def warn(self, *args, **kwargs): + assert args + indent = kwargs.get("indent", 0) + msg = "{}WARNING: {}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) + if self._file is None: + print(msg) + else: + self._file.writelines((msg, "\n")) + self._warnings.append(args[0]) diff --git a/korman/exporter/material.py b/korman/exporter/material.py index edeb1b6..43d769b 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -125,7 +125,7 @@ class MaterialConverter: def export_material(self, bo, bm): """Exports a Blender Material as an hsGMaterial""" - print(" Exporting Material '{}'".format(bm.name)) + self._report.msg("Exporting Material '{}'", bm.name, indent=1) hsgmat = self._mgr.add_object(hsGMaterial, name=bm.name, bl=bo) slots = [(idx, slot) for idx, slot in enumerate(bm.texture_slots) if slot is not None and slot.use \ @@ -198,7 +198,7 @@ class MaterialConverter: return hsgmat.key def export_waveset_material(self, bo, bm): - print(" Exporting WaveSet Material '{}'".format(bm.name)) + self._report.msg("Exporting WaveSet Material '{}'", bm.name, indent=1) # WaveSets MUST have their own material unique_name = "{}_WaveSet7".format(bm.name) @@ -215,7 +215,7 @@ class MaterialConverter: def export_bumpmap_slot(self, bo, bm, hsgmat, slot, idx): name = "{}_{}".format(bm.name if bm is not None else bo.name, slot.name) - print(" Exporting Plasma Bumpmap Layers for '{}'".format(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 du_layer = self._mgr.add_object(plLayer, name="{}_DU_BumpLut".format(name), bl=bo) @@ -264,7 +264,7 @@ class MaterialConverter: def export_texture_slot(self, bo, bm, hsgmat, slot, idx, name=None, blend_flags=True): if name is None: name = "{}_{}".format(bm.name if bm is not None else bo.name, slot.name) - print(" Exporting Plasma Layer '{}'".format(name)) + self._report.msg("Exporting Plasma Layer '{}'", name, indent=2) layer = self._mgr.add_object(plLayer, name=name, bl=bo) if bm is not None and not slot.use_map_normal: self._propagate_material_settings(bm, layer) @@ -274,10 +274,10 @@ class MaterialConverter: for i, uvchan in enumerate(bo.data.uv_layers): if uvchan.name == slot.uv_layer: layer.UVWSrc = i - print(" Using UV Map #{} '{}'".format(i, name)) + self._report.msg("Using UV Map #{} '{}'", i, name, indent=3) break else: - print(" No UVMap specified... Blindly using the first one, maybe it exists :|") + self._report.msg("No UVMap specified... Blindly using the first one, maybe it exists :|", indent=3) # Transform xform = hsMatrix44() @@ -447,7 +447,8 @@ class MaterialConverter: name = "{}_DynEnvMap".format(viewpt.name) pl_env = self._mgr.find_object(pl_class, bl=bo, name=name) if pl_env is not None: - print(" EnvMap for viewpoint {} already exported... NOTE: Your settings here will be overridden by the previous object!".format(viewpt.name)) + self._report.msg("EnvMap for viewpoint {} already exported... NOTE: Your settings here will be overridden by the previous object!", + viewpt.name, indent=3) if isinstance(pl_env, plDynamicCamMap): pl_env.addTargetNode(self._mgr.find_key(plSceneObject, bl=bo)) pl_env.addMatLayer(layer.key) @@ -457,7 +458,7 @@ class MaterialConverter: oRes = bl_env.resolution eRes = helpers.ensure_power_of_two(oRes) if oRes != eRes: - print(" Overriding EnvMap size to ({}x{}) -- POT".format(eRes, eRes)) + self._report.msg("Overriding EnvMap size to ({}x{}) -- POT", eRes, eRes, indent=3) # And now for the general ho'hum-ness pl_env = self._mgr.add_object(pl_class, bl=bo, name=name) @@ -595,10 +596,11 @@ class MaterialConverter: detail_fade_start=layer_props.detail_fade_start, detail_fade_stop=layer_props.detail_fade_stop, detail_opacity_start=layer_props.detail_opacity_start, detail_opacity_stop=layer_props.detail_opacity_stop) if key not in self._pending: - print(" Stashing '{}' for conversion as '{}'".format(texture.image.name, str(key))) + self._report.msg("Stashing '{}' for conversion as '{}'", + texture.image.name, str(key), indent=3) self._pending[key] = [layer.key,] else: - print(" Found another user of '{}'".format(texture.image.name)) + self._report.msg("Found another user of '{}'", texture.image.name, indent=3) self._pending[key].append(layer.key) def _export_texture_type_none(self, bo, layer, texture): @@ -609,16 +611,16 @@ class MaterialConverter: """This exports an externally prepared layer and image""" key = _Texture(image=image) if key not in self._pending: - print(" Stashing '{}' for conversion as '{}'".format(image.name, str(key))) + self._report.msg("Stashing '{}' for conversion as '{}'", image.name, key, indent=2) self._pending[key] = [layer.key,] else: - print(" Found another user of '{}'".format(key)) + self._report.msg("Found another user of '{}'", key, indent=2) self._pending[key].append(layer.key) def finalize(self): for key, layers in self._pending.items(): name = str(key) - print("\n[Mipmap '{}']".format(name)) + self._report.msg("\n[Mipmap '{}']", name) image = key.image oWidth, oHeight = image.size @@ -628,7 +630,8 @@ class MaterialConverter: eWidth = helpers.ensure_power_of_two(oWidth) eHeight = helpers.ensure_power_of_two(oHeight) if (eWidth != oWidth) or (eHeight != oHeight): - print(" Image is not a POT ({}x{}) resizing to {}x{}".format(oWidth, oHeight, eWidth, eHeight)) + self._report.msg("Image is not a POT ({}x{}) resizing to {}x{}", + oWidth, oHeight, eWidth, eHeight, indent=1) self._resize_image(image, eWidth, eHeight) # Some basic mipmap settings. @@ -640,11 +643,11 @@ class MaterialConverter: with helper as glimage: if key.mipmap: numLevels = glimage.num_levels - print(" Generating mip levels") + self._report.msg("Generating mip levels", indent=1) glimage.generate_mipmap() else: numLevels = 1 - print(" Stuffing image data") + self._report.msg("Stuffing image data", indent=1) # Uncompressed bitmaps are BGRA fmt = compression == plBitmap.kUncompressed @@ -653,7 +656,7 @@ class MaterialConverter: # this mipmap for per-page textures :( data = [] for i in range(numLevels): - data.append(glimage.get_level_data(i, key.calc_alpha, fmt)) + data.append(glimage.get_level_data(i, key.calc_alpha, fmt, report=self._report)) # Be a good citizen and reset the Blender Image to pre-futzing state image.reload() @@ -663,9 +666,9 @@ class MaterialConverter: mgr = self._mgr pages = {} - print(" Adding to Layer(s)") + self._report.msg("Adding to Layer(s)", indent=1) for layer in layers: - print(" {}".format(layer.name)) + self._report.msg(layer.name, indent=2) page = mgr.get_textures_page(layer) # Layer's page or Textures.prp # If we haven't created this plMipmap in the page (either layer's page or Textures.prp), @@ -722,6 +725,10 @@ class MaterialConverter: layer.runtime = utils.color(bm.diffuse_color) layer.specular = utils.color(bm.specular_color) + @property + def _report(self): + return self._exporter().report + def _resize_image(self, image, width, height): image.scale(width, height) image.update() diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py index 98fb63c..2aa2692 100644 --- a/korman/exporter/mesh.py +++ b/korman/exporter/mesh.py @@ -177,8 +177,8 @@ class MeshConverter: for loc in self._dspans.values(): for dspan in loc.values(): - print("\n[DrawableSpans '{}']".format(dspan.key.name)) - print(" Composing geometry data") + self._report.msg("\n[DrawableSpans '{}']", dspan.key.name) + self._report.msg("Composing geometry data", indent=1) # This mega-function does a lot: # 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers @@ -189,7 +189,7 @@ class MeshConverter: # Might as well say something else just to fascinate anyone who is playing along # at home (and actually enjoys reading these lawgs) - print(" Bounds and SpaceTree in the saddle") + self._report.msg("Bounds and SpaceTree in the saddle", indent=1) def _export_geometry(self, bo, mesh, materials, geospans): geodata = [_GeoData(len(mesh.vertices)) for i in materials] @@ -422,7 +422,8 @@ class MeshConverter: _diindices = {} for geospan, pass_index in geospans: dspan = self._find_create_dspan(bo, geospan.material.object, pass_index) - print(" Exported hsGMaterial '{}' geometry into '{}'".format(geospan.material.name, dspan.key.name)) + self._report.msg("Exported hsGMaterial '{}' geometry into '{}'", + geospan.material.name, dspan.key.name, indent=1) idx = dspan.addSourceSpan(geospan) if dspan not in _diindices: _diindices[dspan] = [idx,] @@ -497,3 +498,7 @@ class MeshConverter: @property def _mgr(self): return self._exporter().mgr + + @property + def _report(self): + return self._exporter().report diff --git a/korman/exporter/rtlight.py b/korman/exporter/rtlight.py index d1790ce..c249218 100644 --- a/korman/exporter/rtlight.py +++ b/korman/exporter/rtlight.py @@ -43,19 +43,19 @@ class LightConverter: # If you change these calculations, be sure to update the AnimationConverter! intens, attenEnd = self.convert_attenuation(bl) if bl.falloff_type == "CONSTANT": - print(" Attenuation: No Falloff") + self._report.msg("Attenuation: No Falloff", indent=2) pl.attenConst = intens pl.attenLinear = 0.0 pl.attenQuadratic = 0.0 pl.attenCutoff = attenEnd elif bl.falloff_type == "INVERSE_LINEAR": - print(" Attenuation: Inverse Linear") + self._report.msg("Attenuation: Inverse Linear", indent=2) pl.attenConst = 1.0 pl.attenLinear = self.convert_attenuation_linear(intens, attenEnd) pl.attenQuadratic = 0.0 pl.attenCutoff = attenEnd elif bl.falloff_type == "INVERSE_SQUARE": - print(" Attenuation: Inverse Square") + self._report.msg("Attenuation: Inverse Square", indent=2) pl.attenConst = 1.0 pl.attenLinear = 0.0 pl.attenQuadratic = self.convert_attenuation_quadratic(intens, attenEnd) @@ -75,18 +75,18 @@ class LightConverter: return max(0.0, (intensity * _FAR_POWER - 1.0) / pow(end, 2)) def _convert_area_lamp(self, bl, pl): - print(" [LimitedDirLightInfo '{}']".format(bl.name)) + self._report.msg("[LimitedDirLightInfo '{}']", bl.name, indent=1) pl.width = bl.size pl.depth = bl.size if bl.shape == "SQUARE" else bl.size_y pl.height = bl.plasma_lamp.size_height def _convert_point_lamp(self, bl, pl): - print(" [OmniLightInfo '{}']".format(bl.name)) + self._report.msg("[OmniLightInfo '{}']", bl.name, indent=1) self._convert_attenuation(bl, pl) def _convert_spot_lamp(self, bl, pl): - print(" [SpotLightInfo '{}']".format(bl.name)) + self._report.msg("[SpotLightInfo '{}']", bl.name, indent=1) self._convert_attenuation(bl, pl) # Spot lights have a few more things... @@ -102,7 +102,7 @@ class LightConverter: pl.falloff = 1.0 def _convert_sun_lamp(self, bl, pl): - print(" [DirectionalLightInfo '{}']".format(bl.name)) + self._report.msg("[DirectionalLightInfo '{}']", bl.name, indent=1) def export_rtlight(self, so, bo): bl_light = bo.data @@ -132,18 +132,18 @@ class LightConverter: # Apply the colors if bl_light.use_diffuse and not shadow_only: - print(" Diffuse: {}".format(diff_str)) + self._report.msg("Diffuse: {}", diff_str, indent=2) pl_light.diffuse = hsColorRGBA(*diff_color) else: - print(" Diffuse: OFF") + self._report.msg("Diffuse: OFF", indent=2) pl_light.diffuse = hsColorRGBA(0.0, 0.0, 0.0, energy) if bl_light.use_specular and not shadow_only: - print(" Specular: {}".format(spec_str)) + self._report.msg("Specular: {}", spec_str, indent=2) pl_light.setProperty(plLightInfo.kLPHasSpecular, True) pl_light.specular = hsColorRGBA(*spec_color) else: - print(" Specular: OFF") + self._report.msg("Specular: OFF", indent=2) pl_light.specular = hsColorRGBA(0.0, 0.0, 0.0, energy) rtlamp = bl_light.plasma_lamp @@ -202,7 +202,7 @@ class LightConverter: # projection Lamp with our own faux Material. Unfortunately, Plasma only supports projecting # one layer. We could exploit the fUnderLay and fOverLay system to export everything, but meh. if len(tex_slots) > 1: - self._exporter().warn("Only one texture slot can be exported per Lamp. Picking the first one: '{}'".format(slot.name), indent=3) + self._report.warn("Only one texture slot can be exported per Lamp. Picking the first one: '{}'".format(slot.name), indent=3) layer = mat.export_texture_slot(bo, None, None, slot, 0, blend_flags=False) state = layer.state @@ -243,7 +243,7 @@ class LightConverter: def find_material_light_keys(self, bo, bm): """Given a blender material, we find the keys of all matching Plasma RT Lights. NOTE: We return a tuple of lists: ([permaLights], [permaProjs])""" - print(" Searching for runtime lights...") + self._report.msg("Searching for runtime lights...", indent=1) permaLights = [] permaProjs = [] @@ -272,16 +272,17 @@ class LightConverter: break else: # didn't find a layer where both lamp and object were, skip it. - print(" [{}] '{}': not in same layer, skipping...".format(lamp.type, obj.name)) + self._report.msg("[{}] '{}': not in same layer, skipping...", + lamp.type, obj.name, indent=2) continue # This is probably where PermaLight vs PermaProj should be sorted out... pl_light = self.get_light_key(obj, lamp, None) if self._is_projection_lamp(lamp): - print(" [{}] PermaProj '{}'".format(lamp.type, obj.name)) + self._report.msg("[{}] PermaProj '{}'", lamp.type, obj.name, indent=2) permaProjs.append(pl_light) else: - print(" [{}] PermaLight '{}'".format(lamp.type, obj.name)) + self._report.msg("[{}] PermaLight '{}'", lamp.type, obj.name, indent=2) permaLights.append(pl_light) return (permaLights, permaProjs) @@ -308,3 +309,7 @@ class LightConverter: @property def mgr(self): return self._exporter().mgr + + @property + def _report(self): + return self._exporter().report diff --git a/korman/korlib/texture.py b/korman/korlib/texture.py index 11a289e..194add9 100644 --- a/korman/korlib/texture.py +++ b/korman/korlib/texture.py @@ -76,14 +76,14 @@ class GLTexture: # It will simplify our state tracking a bit. bgl.glTexParameteri(bgl.GL_TEXTURE_2D, bgl.GL_GENERATE_MIPMAP, 1) - def get_level_data(self, level=0, calc_alpha=False, bgra=False, quiet=False, fast=False): + def get_level_data(self, level=0, calc_alpha=False, bgra=False, report=None, fast=False): """Gets the uncompressed pixel data for a requested mip level, optionally calculating the alpha channel from the image color data """ width = self._get_tex_param(bgl.GL_TEXTURE_WIDTH, level) height = self._get_tex_param(bgl.GL_TEXTURE_HEIGHT, level) - if not quiet: - print(" Level #{}: {}x{}".format(level, width, height)) + if report is not None: + report.msg("Level #{}: {}x{}", level, width, height, indent=2) # Grab the image data size = width * height * 4 @@ -138,7 +138,7 @@ class GLTexture: @property def has_alpha(self): - data = self.get_level_data(quiet=True, fast=True) + data = self.get_level_data(report=None, fast=True) for i in range(3, len(data), 4): if data[i] != 255: return True diff --git a/korman/nodes/node_conditions.py b/korman/nodes/node_conditions.py index 43c078e..d4fec71 100644 --- a/korman/nodes/node_conditions.py +++ b/korman/nodes/node_conditions.py @@ -427,18 +427,18 @@ class PlasmaVolumeSensorNode(PlasmaNodeBase, bpy.types.Node): suffix = "Exit" theName = "{}_{}_{}".format(self.id_data.name, self.name, suffix) - print(" [LogicModifier '{}']".format(theName)) + exporter.report.msg("[LogicModifier '{}']", theName, indent=2) logicKey = exporter.mgr.find_create_key(plLogicModifier, name=theName, so=so) logicmod = logicKey.object logicmod.setLogicFlag(plLogicModifier.kMultiTrigger, True) logicmod.notify = self.generate_notify_msg(exporter, so, "satisfies") # Now, the detector objects - print(" [ObjectInVolumeDetector '{}']".format(theName)) + exporter.report.msg("[ObjectInVolumeDetector '{}']", theName, indent=2) detKey = exporter.mgr.find_create_key(plObjectInVolumeDetector, name=theName, so=so) det = detKey.object - print(" [VolumeSensorConditionalObject '{}']".format(theName)) + exporter.report.msg("[VolumeSensorConditionalObject '{}']", theName, indent=2) volKey = exporter.mgr.find_create_key(plVolumeSensorConditionalObject, name=theName, so=so) volsens = volKey.object diff --git a/korman/properties/modifiers/render.py b/korman/properties/modifiers/render.py index 97a7715..dcad670 100644 --- a/korman/properties/modifiers/render.py +++ b/korman/properties/modifiers/render.py @@ -427,10 +427,10 @@ class PlasmaVisControl(PlasmaModifierProperties): else: this_sv = bo.plasma_modifiers.softvolume if this_sv.enabled: - print(" [VisRegion] I'm a SoftVolume myself :)") + exporter.report.msg("[VisRegion] I'm a SoftVolume myself :)", indent=1) rgn.region = this_sv.get_key(exporter, so) else: - print(" [VisRegion] SoftVolume '{}'".format(self.softvolume)) + exporter.report.msg("[VisRegion] SoftVolume '{}'", self.softvolume, indent=1) sv_bo = bpy.data.objects.get(self.softvolume, None) if sv_bo is None: raise ExportError("'{}': Invalid object '{}' for VisControl soft volume".format(bo.name, self.softvolume)) diff --git a/korman/properties/modifiers/sound.py b/korman/properties/modifiers/sound.py index 4ed90cf..cce45b3 100644 --- a/korman/properties/modifiers/sound.py +++ b/korman/properties/modifiers/sound.py @@ -173,7 +173,7 @@ class PlasmaSound(bpy.types.PropertyGroup): name = "Sfx-{}_{}".format(so.key.name, self.sound_data) else: name = "Sfx-{}_{}:{}".format(so.key.name, self.sound_data, channel) - print(" [{}] {}".format(pClass.__name__[2:], name)) + exporter.report.msg("[{}] {}", pClass.__name__[2:], name, indent=1) sound = exporter.mgr.find_create_object(pClass, so=so, name=name) # If this object is a soft volume itself, we will use our own soft region. From 98d54800241a1536ec452073f5a8402cdcfe68d0 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 11 Jun 2017 20:35:40 -0400 Subject: [PATCH 2/7] Implement rudimentary progress reporter A common complaint that I have heard (and have nyself) is that there is little indication of what Korman is doing in the background. In PyPRP, the raw export log was dumped to the console, and many liked to watch it travel by as an indicator of progress. As such, I have implemented a rudimentary progress monitor that outputs to the console into the export logger. Unfortunately, there is no "easy" way to show progress in the Blender UI currently. Korman makes the assumption that nothing will touch Blender's context while it is executing. Attempts to mvoe the exporter into a background thread have all resulted in spectacular failures. So, this will have to do for now. --- korman/exporter/convert.py | 52 ++++++++++++--- korman/exporter/etlight.py | 46 ++++++++++---- korman/exporter/logger.py | 123 +++++++++++++++++++++++++++++++----- korman/exporter/material.py | 5 ++ korman/exporter/mesh.py | 13 ++-- 5 files changed, 196 insertions(+), 43 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 9416588..81eb6b5 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -43,9 +43,6 @@ class Exporter: def run(self): with logger.ExportLogger(self._op.filepath) as self.report: - self.report.msg("Exporting '{}.age'", self.age_name) - start = time.perf_counter() - # Step 0: Init export resmgr and stuff self.mgr = manager.ExportManager(self) self.mesh = mesh.MeshConverter(self) @@ -54,6 +51,18 @@ class Exporter: self.animation = animation.AnimationConverter(self) self.sumfile = sumfile.SumFile() + # Step 0.9: Init the progress mgr + self.report.progress_add_step("Collecting Objects") + self.report.progress_add_step("Harvesting Actors") + if self._op.bake_lighting: + etlight.LightBaker.add_progress_steps(self.report) + self.report.progress_add_step("Exporting Scene Objects") + self.report.progress_add_step("Exporting Logic Nodes") + self.report.progress_add_step("Finalizing Plasma Logic") + self.report.progress_add_step("Exporting Textures") + self.report.progress_add_step("Composing Geometry") + self.report.progress_start("EXPORTING AGE") + # Step 1: Create the age info and the pages self._export_age_info() @@ -90,17 +99,18 @@ class Exporter: # Step 5.1: Save out the export report. # If the export fails and this doesn't save, we have bigger problems than # these little warnings and notices. + self.report.progress_end() self.report.save() - # And finally we crow about how awesomely fast we are... - end = time.perf_counter() - self.report.msg("\nExported {}.age in {:.2f} seconds", self.age_name, end-start) - def _bake_static_lighting(self): oven = etlight.LightBaker(self.report) oven.bake_static_lighting(self._objects) def _collect_objects(self): + self.report.progress_advance() + self.report.progress_range = len(bpy.data.objects) + inc_progress = self.report.progress_increment + # Grab a naive listing of enabled pages age = bpy.context.scene.world.plasma_age pages_enabled = frozenset([page.name for page in age.pages if page.enabled]) @@ -135,6 +145,7 @@ class Exporter: self._objects.append(obj) elif page not in all_pages: error.add(page, obj.name) + inc_progress() error.raise_if_error() def _export_age_info(self): @@ -186,7 +197,11 @@ class Exporter: return so.coord.object def _export_scene_objects(self): + self.report.progress_advance() + self.report.progress_range = len(self._objects) + inc_progress = self.report.progress_increment log_msg = self.report.msg + for bl_obj in self._objects: log_msg("\n[SceneObject '{}']".format(bl_obj.name)) @@ -213,6 +228,7 @@ class Exporter: for mod in bl_obj.plasma_modifiers.modifiers: log_msg("Exporting '{}' modifier".format(mod.bl_label), indent=1) mod.export(self, bl_obj, sceneobject) + inc_progress() def _export_empty_blobj(self, so, bo): # We don't need to do anything here. This function just makes sure we don't error out @@ -230,18 +246,30 @@ class Exporter: 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) + inc_progress = self.report.progress_increment + self.report.msg("\nChecking Logic Trees...") - need_to_export = ((name, bo, so) for name, (bo, so) in self.want_node_trees.items() - if name not in self.node_trees_exported) + need_to_export = [(name, bo, so) for name, (bo, so) in self.want_node_trees.items() + if name not in self.node_trees_exported] + self.report.progress_value = len(self.want_node_trees) - len(need_to_export) + for tree, bo, so in need_to_export: self.report.msg("NodeTree '{}'", tree, indent=1) bpy.data.node_groups[tree].export(self, bo, so) + inc_progress() def _harvest_actors(self): + self.report.progress_advance() + self.report.progress_range = len(self._objects) + len(bpy.data.textures) + inc_progress = self.report.progress_increment + for bl_obj in self._objects: for mod in bl_obj.plasma_modifiers.modifiers: if mod.enabled: self.actors.update(mod.harvest_actors()) + inc_progress() # This is a little hacky, but it's an edge case... I guess? # We MUST have CoordinateInterfaces for EnvironmentMaps (DCMs, bah) @@ -251,6 +279,7 @@ class Exporter: viewpt = envmap.viewpoint_object if viewpt is not None: self.actors.add(viewpt.name) + inc_progress() def has_coordiface(self, bo): if bo.type in {"CAMERA", "EMPTY", "LAMP"}: @@ -269,6 +298,10 @@ class Exporter: return False def _post_process_scene_objects(self): + self.report.progress_advance() + self.report.progress_range = len(self._objects) + inc_progress = self.report.progress_increment + mat_mgr = self.mesh.material for bl_obj in self._objects: sceneobject = self.mgr.find_object(plSceneObject, bl=bl_obj) @@ -291,3 +324,4 @@ class Exporter: proc = getattr(mod, "post_export", None) if proc is not None: proc(self, bl_obj, sceneobject) + inc_progress() diff --git a/korman/exporter/etlight.py b/korman/exporter/etlight.py index c938520..e3c4d98 100644 --- a/korman/exporter/etlight.py +++ b/korman/exporter/etlight.py @@ -27,9 +27,25 @@ class LightBaker: def __init__(self, report=None): self._lightgroups = {} - self._report = report if report is not None else ExportLogger() + if report is None: + self._report = ExportLogger() + self.add_progress_steps(self._report) + self._report.progress_start("PREVIEWING LIGHTING") + self._own_report = True + else: + self._report = report + self._own_report = False self._uvtexs = {} + def __del__(self): + if self._own_report: + self._report.progress_end() + + @staticmethod + def add_progress_steps(report): + report.progress_add_step("Searching for Bahro") + report.progress_add_step("Baking Static Lighting") + def _apply_render_settings(self, toggle, vcols): render = bpy.context.scene.render toggle.track(render, "use_textures", False) @@ -79,11 +95,15 @@ class LightBaker: return result def _bake_static_lighting(self, bake, toggle): + inc_progress = self._report.progress_increment + # Step 0.9: Make all layers visible. # This prevents context operators from phailing. bpy.context.scene.layers = (True,) * _NUM_RENDER_LAYERS # Step 1: Prepare... Apply UVs, etc, etc, etc + self._report.progress_advance() + self._report.progress_range = len(bake) self._report.msg("Preparing to bake...", indent=1) for key in bake.keys(): if key[0] == "lightmap": @@ -103,21 +123,23 @@ class LightBaker: bake[key].pop(i) else: raise RuntimeError(key[0]) + inc_progress() self._report.msg(" ...") # Step 2: BAKE! + self._report.progress_advance() + self._report.progress_range = len(bake) for key, value in bake.items(): - if not value: - continue - - if key[0] == "lightmap": - self._report.msg("{} Lightmap(s) [H:{:X}]", len(value), hash(key), indent=1) - self._bake_lightmaps(value, key[1:]) - elif key[0] == "vcol": - self._report.msg("{} Crap Light(s)", len(value), indent=1) - self._bake_vcols(value) - else: - raise RuntimeError(key[0]) + if value: + if key[0] == "lightmap": + self._report.msg("{} Lightmap(s) [H:{:X}]", len(value), hash(key), indent=1) + self._bake_lightmaps(value, key[1:]) + elif key[0] == "vcol": + self._report.msg("{} Crap Light(s)", len(value), indent=1) + self._bake_vcols(value) + else: + raise RuntimeError(key[0]) + inc_progress() # Return how many thingos we baked return sum(map(len, bake.values())) diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index b12d8d2..6fe3f30 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -15,21 +15,30 @@ from pathlib import Path import sys +import time + +_HEADING_SIZE = 50 class ExportLogger: def __init__(self, age_path=None): self._porting = [] self._warnings = [] - self._age_path = age_path + self._age_path = Path(age_path) if age_path is not None else None self._file = None + self._progress_steps = [] + self._step_id = -1 + self._step_max = 0 + self._step_progress = 0 + self._time_start_overall = 0 + self._time_start_step = 0 + def __enter__(self): assert self._age_path is not None # Make the log file name from the age file path -- this ensures we're not trying to write # the log file to the same directory Blender.exe is in, which might be a permission error - my_path = Path(self._age_path) - my_path = my_path.with_name("{}_export".format(my_path.stem)).with_suffix(".log") + my_path = self._age_path.with_name("{}_export".format(self._age_path.stem)).with_suffix(".log") self._file = open(str(my_path), "w") return self @@ -37,15 +46,101 @@ class ExportLogger: self._file.close() return False + def progress_add_step(self, name): + assert self._step_id == -1 + self._progress_steps.append(name) + + def progress_advance(self): + """Advances the progress bar to the next step""" + if self._step_id != -1: + self._progress_print_step(done=True) + assert self._step_id < len(self._progress_steps) + + self._step_id += 1 + self._step_max = 0 + self._step_progress = 0 + self._time_start_step = time.perf_counter() + self._progress_print_step() + + def progress_complete_step(self): + """Manually completes the current step""" + assert self._step_id != -1 + self._progress_print_step(done=True) + + def progress_end(self): + self._progress_print_step(done=True) + assert self._step_id+1 == len(self._progress_steps) + + export_time = time.perf_counter() - self._time_start_overall + if self._age_path is not None: + self.msg("\nExported '{}' in {:.2f}s", self._age_path.name, export_time) + print("\nEXPORTED '{}' IN {:.2f}s".format(self._age_path.name, export_time)) + else: + print("\nCOMPLETED IN {:.2f}s".format(export_time)) + self._progress_print_heading() + print() + + def progress_increment(self): + """Increments the progress of the current step""" + assert self._step_id != -1 + self._step_progress += 1 + if self._step_max != 0: + self._progress_print_step() + + def _progress_print_heading(self, text=None): + if text: + num_chars = len(text) + border = "-" * int((_HEADING_SIZE - (num_chars + 2)) / 2) + pad = " " if num_chars % 2 == 1 else "" + print(border, " ", pad, text, " ", border, sep="") + else: + print("-" * _HEADING_SIZE) + + def _progress_print_step(self, done=False): + if done: + stage = "DONE IN {:.2f}s".format(time.perf_counter() - self._time_start_step) + end = "\n" + else: + if self._step_max != 0: + stage = "{} of {}".format(self._step_progress, self._step_max) + else: + stage = "" + end = "\r" + print("{}\t(step {}/{}): {}".format(self._progress_steps[self._step_id], self._step_id+1, + len(self._progress_steps), stage), + end=end) + + def _progress_get_max(self): + return self._step_max + def _progress_set_max(self, value): + assert self._step_id != -1 + self._step_max = value + self._progress_print_step() + progress_range = property(_progress_get_max, _progress_set_max) + + def progress_start(self, action): + if self._age_path is not None: + self.msg("Exporting '{}'", self._age_path.name) + self._progress_print_heading("Korman") + self._progress_print_heading(action) + self._time_start_overall = time.perf_counter() + + def _progress_get_current(self): + return self._step_progress + def _progress_set_current(self, value): + assert self._step_id != -1 + self._step_progress = value + if self._step_max != 0: + self._progress_print_step() + progress_value = property(_progress_get_current, _progress_set_current) + def msg(self, *args, **kwargs): assert args - indent = kwargs.get("indent", 0) - msg = "{}{}".format(" " * indent, args[0]) - if len(args) > 1: - msg = msg.format(*args[1:], **kwargs) - if self._file is None: - print(msg) - else: + if self._file is not None: + indent = kwargs.get("indent", 0) + msg = "{}{}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) self._file.writelines((msg, "\n")) def port(self, *args, **kwargs): @@ -54,9 +149,7 @@ class ExportLogger: msg = "{}PORTING: {}".format(" " * indent, args[0]) if len(args) > 1: msg = msg.format(*args[1:], **kwargs) - if self._file is None: - print(msg) - else: + if self._file is not None: self._file.writelines((msg, "\n")) self._porting.append(args[0]) @@ -70,8 +163,6 @@ class ExportLogger: msg = "{}WARNING: {}".format(" " * indent, args[0]) if len(args) > 1: msg = msg.format(*args[1:], **kwargs) - if self._file is None: - print(msg) - else: + if self._file is not None: self._file.writelines((msg, "\n")) self._warnings.append(args[0]) diff --git a/korman/exporter/material.py b/korman/exporter/material.py index 43d769b..f66f52d 100644 --- a/korman/exporter/material.py +++ b/korman/exporter/material.py @@ -618,6 +618,10 @@ class MaterialConverter: self._pending[key].append(layer.key) def finalize(self): + self._report.progress_advance() + self._report.progress_range = len(self._pending) + inc_progress = self._report.progress_increment + for key, layers in self._pending.items(): name = str(key) self._report.msg("\n[Mipmap '{}']", name) @@ -683,6 +687,7 @@ class MaterialConverter: else: mipmap = pages[page] layer.object.texture = mipmap.key + inc_progress() def get_materials(self, bo): return self._obj2mat.get(bo, []) diff --git a/korman/exporter/mesh.py b/korman/exporter/mesh.py index 2aa2692..711243c 100644 --- a/korman/exporter/mesh.py +++ b/korman/exporter/mesh.py @@ -174,11 +174,15 @@ class MeshConverter: def finalize(self): """Prepares all baked Plasma geometry to be flushed to the disk""" + self._report.progress_advance() + self._report.progress_range = len(self._dspans) + inc_progress = self._report.progress_increment + log_msg = self._report.msg + log_msg("\nFinalizing Geometry") for loc in self._dspans.values(): for dspan in loc.values(): - self._report.msg("\n[DrawableSpans '{}']", dspan.key.name) - self._report.msg("Composing geometry data", indent=1) + log_msg("[DrawableSpans '{}']", dspan.key.name, indent=1) # This mega-function does a lot: # 1. Converts SourceSpans (geospans) to Icicles and bakes geometry into plGBuffers @@ -186,10 +190,7 @@ class MeshConverter: # 3. Builds the plSpaceTree # 4. Clears the SourceSpans dspan.composeGeometry(True, True) - - # Might as well say something else just to fascinate anyone who is playing along - # at home (and actually enjoys reading these lawgs) - self._report.msg("Bounds and SpaceTree in the saddle", indent=1) + inc_progress() def _export_geometry(self, bo, mesh, materials, geospans): geodata = [_GeoData(len(mesh.vertices)) for i in materials] From 4842731a4441890982c3a4e725b949fb33e61f83 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 11 Jun 2017 23:03:23 -0400 Subject: [PATCH 3/7] Better long-running progress report behavior For sanity, I stripped the progress manager into a subclass. From there, I moved all of the progress printing into a thread so that we can accept signals for step progress. If there is no step progress for a time, we show the user a rolling set of ellipses to prove we're not dead (yet). --- korman/exporter/convert.py | 2 +- korman/exporter/etlight.py | 4 +- korman/exporter/logger.py | 189 +++++++++++++++++++++++++------------ 3 files changed, 130 insertions(+), 65 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 81eb6b5..e50d4ae 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -42,7 +42,7 @@ class Exporter: return Path(self._op.filepath).stem def run(self): - with logger.ExportLogger(self._op.filepath) as self.report: + with logger.ExportProgressLogger(self._op.filepath) as self.report: # Step 0: Init export resmgr and stuff self.mgr = manager.ExportManager(self) self.mesh = mesh.MeshConverter(self) diff --git a/korman/exporter/etlight.py b/korman/exporter/etlight.py index e3c4d98..0f1c428 100644 --- a/korman/exporter/etlight.py +++ b/korman/exporter/etlight.py @@ -16,7 +16,7 @@ import bpy from bpy.app.handlers import persistent -from .logger import ExportLogger +from .logger import ExportProgressLogger from .mesh import _VERTEX_COLOR_LAYERS from ..helpers import * @@ -28,7 +28,7 @@ class LightBaker: def __init__(self, report=None): self._lightgroups = {} if report is None: - self._report = ExportLogger() + self._report = ExportProgressLogger() self.add_progress_steps(self._report) self._report.progress_start("PREVIEWING LIGHTING") self._own_report = True diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index 6fe3f30..75ea747 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -14,25 +14,20 @@ # along with Korman. If not, see . from pathlib import Path -import sys +import threading import time _HEADING_SIZE = 50 +_MAX_ELIPSES = 3 +_MAX_TIME_UNTIL_ELIPSES = 2.0 -class ExportLogger: +class _ExportLogger: def __init__(self, age_path=None): self._porting = [] self._warnings = [] self._age_path = Path(age_path) if age_path is not None else None self._file = None - self._progress_steps = [] - self._step_id = -1 - self._step_max = 0 - self._step_progress = 0 - self._time_start_overall = 0 - self._time_start_step = 0 - def __enter__(self): assert self._age_path is not None @@ -46,6 +41,62 @@ class ExportLogger: self._file.close() return False + def msg(self, *args, **kwargs): + assert args + if self._file is not None: + indent = kwargs.get("indent", 0) + msg = "{}{}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) + self._file.writelines((msg, "\n")) + + def port(self, *args, **kwargs): + assert args + indent = kwargs.get("indent", 0) + msg = "{}PORTING: {}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) + if self._file is not None: + self._file.writelines((msg, "\n")) + self._porting.append(args[0]) + + def save(self): + # TODO + pass + + def warn(self, *args, **kwargs): + assert args + indent = kwargs.get("indent", 0) + msg = "{}WARNING: {}".format(" " * indent, args[0]) + if len(args) > 1: + msg = msg.format(*args[1:], **kwargs) + if self._file is not None: + self._file.writelines((msg, "\n")) + self._warnings.append(args[0]) + + +class ExportProgressLogger(_ExportLogger): + def __init__(self, age_path=None): + super().__init__(age_path) + + # Long running operations like the Blender bake_image call make it seem like we've hung + # because it is difficult to inspect the progress of Blender's internal operators. The best + # solution here is to move printing into a thread that can detect long-running ops and display + # something visible such as a moving elipsis + self._thread = threading.Thread(target=self._progress_thread) + self._queued_lines = [] + self._print_condition = threading.Condition() + self._volatile_line = "" + + # Progress manager + self._progress_alive = False + self._progress_steps = [] + self._step_id = -1 + self._step_max = 0 + self._step_progress = 0 + self._time_start_overall = 0 + self._time_start_step = 0 + def progress_add_step(self, name): assert self._step_id == -1 self._progress_steps.append(name) @@ -72,13 +123,19 @@ class ExportLogger: assert self._step_id+1 == len(self._progress_steps) export_time = time.perf_counter() - self._time_start_overall - if self._age_path is not None: - self.msg("\nExported '{}' in {:.2f}s", self._age_path.name, export_time) - print("\nEXPORTED '{}' IN {:.2f}s".format(self._age_path.name, export_time)) - else: - print("\nCOMPLETED IN {:.2f}s".format(export_time)) - self._progress_print_heading() - print() + with self._print_condition: + if self._age_path is not None: + self.msg("\nExported '{}' in {:.2f}s", self._age_path.name, export_time) + self._progress_print_line("\nEXPORTED '{}' IN {:.2f}s".format(self._age_path.name, export_time)) + else: + self._progress_print_line("\nCOMPLETED IN {:.2f}s".format(export_time)) + self._progress_print_heading() + self._progress_print_line("") + self._progress_alive = False + + # Ensure the got dawg thread goes good-bye + self._thread.join(timeout=5.0) + assert not self._thread.is_alive() def progress_increment(self): """Increments the progress of the current step""" @@ -87,28 +144,42 @@ class ExportLogger: if self._step_max != 0: self._progress_print_step() + def _progress_print_line(self, line): + with self._print_condition: + self._queued_lines.append(line) + self._print_condition.notify() + + def _progress_print_volatile(self, line): + with self._print_condition: + self._volatile_line = line + self._print_condition.notify() + def _progress_print_heading(self, text=None): if text: num_chars = len(text) border = "-" * int((_HEADING_SIZE - (num_chars + 2)) / 2) pad = " " if num_chars % 2 == 1 else "" - print(border, " ", pad, text, " ", border, sep="") + line = "{border} {pad}{text} {border}".format(border=border, pad=pad, text=text) + self._progress_print_line(line) else: - print("-" * _HEADING_SIZE) + self._progress_print_line("-" * _HEADING_SIZE) def _progress_print_step(self, done=False): - if done: - stage = "DONE IN {:.2f}s".format(time.perf_counter() - self._time_start_step) - end = "\n" - else: - if self._step_max != 0: - stage = "{} of {}".format(self._step_progress, self._step_max) + with self._print_condition: + if done: + stage = "DONE IN {:.2f}s".format(time.perf_counter() - self._time_start_step) + print_func = self._progress_print_line + self._progress_print_volatile("") else: - stage = "" - end = "\r" - print("{}\t(step {}/{}): {}".format(self._progress_steps[self._step_id], self._step_id+1, - len(self._progress_steps), stage), - end=end) + if self._step_max != 0: + stage = "{} of {}".format(self._step_progress, self._step_max) + else: + stage = "" + print_func = self._progress_print_volatile + + line = "{}\t(step {}/{}): {}".format(self._progress_steps[self._step_id], self._step_id+1, + len(self._progress_steps), stage) + print_func(line) def _progress_get_max(self): return self._step_max @@ -123,7 +194,34 @@ class ExportLogger: self.msg("Exporting '{}'", self._age_path.name) self._progress_print_heading("Korman") self._progress_print_heading(action) + self._progress_alive = True self._time_start_overall = time.perf_counter() + self._thread.start() + + def _progress_thread(self): + num_dots = 0 + while self._progress_alive: + with self._print_condition: + signalled = self._print_condition.wait(timeout=1.0) + print(end='\r') + + # First, we need to print out any queued whole lines. + # NOTE: no need to lock anything here as Blender uses CPython (GIL) + if self._queued_lines: + print(*self._queued_lines, sep='\n') + self._queued_lines.clear() + + # Now, we need to print out the current volatile line, if any. + if self._volatile_line: + print(self._volatile_line, end="") + + # If the proc is long running, let us display some elipses so as to not alarm the user + if self._time_start_step != 0: + if (time.perf_counter() - self._time_start_step) > _MAX_TIME_UNTIL_ELIPSES: + num_dots = 0 if signalled or num_dots == _MAX_ELIPSES else num_dots + 1 + else: + num_dots = 0 + print('.' * num_dots, end=" " * (_MAX_ELIPSES - num_dots)) def _progress_get_current(self): return self._step_progress @@ -133,36 +231,3 @@ class ExportLogger: if self._step_max != 0: self._progress_print_step() progress_value = property(_progress_get_current, _progress_set_current) - - def msg(self, *args, **kwargs): - assert args - if self._file is not None: - indent = kwargs.get("indent", 0) - msg = "{}{}".format(" " * indent, args[0]) - if len(args) > 1: - msg = msg.format(*args[1:], **kwargs) - self._file.writelines((msg, "\n")) - - def port(self, *args, **kwargs): - assert args - indent = kwargs.get("indent", 0) - msg = "{}PORTING: {}".format(" " * indent, args[0]) - if len(args) > 1: - msg = msg.format(*args[1:], **kwargs) - if self._file is not None: - self._file.writelines((msg, "\n")) - self._porting.append(args[0]) - - def save(self): - # TODO - pass - - def warn(self, *args, **kwargs): - assert args - indent = kwargs.get("indent", 0) - msg = "{}WARNING: {}".format(" " * indent, args[0]) - if len(args) > 1: - msg = msg.format(*args[1:], **kwargs) - if self._file is not None: - self._file.writelines((msg, "\n")) - self._warnings.append(args[0]) From d465b86f9c5e567a6c9019066a8b168595223356 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Sun, 11 Jun 2017 23:46:06 -0400 Subject: [PATCH 4/7] Handle errors better in the progress manager Now the progress display will recognize errors using the `__exit__` magic method and print out an error header with the actual error message. This is useful because when the mouse leaves the Blender operator's report error, the error "window" is destroyed. This led to potential silent failures. --- korman/exporter/logger.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index 75ea747..b7cb70e 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -97,6 +97,18 @@ class ExportProgressLogger(_ExportLogger): self._time_start_overall = 0 self._time_start_step = 0 + def __exit__(self, type, value, traceback): + if value is not None: + export_time = time.perf_counter() - self._time_start_overall + with self._print_condition: + self._progress_print_step(done=(self._step_progress == self._step_max), error=True) + self._progress_print_line("\nABORTED AFTER {:.2f}s".format(export_time)) + self._progress_print_heading("ERROR") + self._progress_print_line(str(value)) + self._progress_print_heading() + self._progress_alive = False + return super().__exit__(type, value, traceback) + def progress_add_step(self, name): assert self._step_id == -1 self._progress_steps.append(name) @@ -164,18 +176,18 @@ class ExportProgressLogger(_ExportLogger): else: self._progress_print_line("-" * _HEADING_SIZE) - def _progress_print_step(self, done=False): + def _progress_print_step(self, done=False, error=False): with self._print_condition: if done: stage = "DONE IN {:.2f}s".format(time.perf_counter() - self._time_start_step) print_func = self._progress_print_line self._progress_print_volatile("") else: - if self._step_max != 0: + if self._step_max != 0 and self._step_progress != 0: stage = "{} of {}".format(self._step_progress, self._step_max) else: stage = "" - print_func = self._progress_print_volatile + print_func = self._progress_print_line if error else self._progress_print_volatile line = "{}\t(step {}/{}): {}".format(self._progress_steps[self._step_id], self._step_id+1, len(self._progress_steps), stage) From 5c8a9253febad28148084fedacca56c6a0d46781 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 12 Jun 2017 00:15:19 -0400 Subject: [PATCH 5/7] Sirius said he liked watching the PyPRP log... ... So now he can watch the Korman log in real time --- korman/exporter/convert.py | 3 +- korman/exporter/logger.py | 58 +++++++++++++++++++++++++++++++---- korman/operators/op_export.py | 5 +++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index e50d4ae..3e87bea 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -42,7 +42,8 @@ class Exporter: return Path(self._op.filepath).stem def run(self): - with logger.ExportProgressLogger(self._op.filepath) as self.report: + log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger + with log(self._op.filepath) as self.report: # Step 0: Init export resmgr and stuff self.mgr = manager.ExportManager(self) self.mesh = mesh.MeshConverter(self) diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index b7cb70e..656819e 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -22,11 +22,13 @@ _MAX_ELIPSES = 3 _MAX_TIME_UNTIL_ELIPSES = 2.0 class _ExportLogger: - def __init__(self, age_path=None): + def __init__(self, print_logs, age_path=None): self._porting = [] self._warnings = [] self._age_path = Path(age_path) if age_path is not None else None self._file = None + self._print_logs = print_logs + self._time_start_overall = 0 def __enter__(self): assert self._age_path is not None @@ -49,6 +51,8 @@ class _ExportLogger: if len(args) > 1: msg = msg.format(*args[1:], **kwargs) self._file.writelines((msg, "\n")) + if self._print_logs: + print(msg) def port(self, *args, **kwargs): assert args @@ -58,8 +62,37 @@ class _ExportLogger: msg = msg.format(*args[1:], **kwargs) if self._file is not None: self._file.writelines((msg, "\n")) + if self._print_logs: + print(msg) self._porting.append(args[0]) + + def progress_add_step(self, name): + pass + + def progress_advance(self): + pass + + def progress_complete_step(self): + pass + + def progress_end(self): + if self._age_path is not None: + export_time = time.perf_counter() - self._time_start_overall + self.msg("\nExported '{}' in {:.2f}s", self._age_path.name, export_time) + + # Ensure the got dawg thread goes good-bye + self._thread.join(timeout=5.0) + assert not self._thread.is_alive() + + def progress_increment(self): + pass + + def progress_start(self, action): + if self._age_path is not None: + self.msg("Exporting '{}'", self._age_path.name) + self._time_start_overall = time.perf_counter() + def save(self): # TODO pass @@ -72,12 +105,14 @@ class _ExportLogger: msg = msg.format(*args[1:], **kwargs) if self._file is not None: self._file.writelines((msg, "\n")) + if self._print_logs: + print(msg) self._warnings.append(args[0]) class ExportProgressLogger(_ExportLogger): def __init__(self, age_path=None): - super().__init__(age_path) + super().__init__(False, age_path) # Long running operations like the Blender bake_image call make it seem like we've hung # because it is difficult to inspect the progress of Blender's internal operators. The best @@ -94,7 +129,6 @@ class ExportProgressLogger(_ExportLogger): self._step_id = -1 self._step_max = 0 self._step_progress = 0 - self._time_start_overall = 0 self._time_start_step = 0 def __exit__(self, type, value, traceback): @@ -202,12 +236,10 @@ class ExportProgressLogger(_ExportLogger): progress_range = property(_progress_get_max, _progress_set_max) def progress_start(self, action): - if self._age_path is not None: - self.msg("Exporting '{}'", self._age_path.name) + super().progress_start(action) self._progress_print_heading("Korman") self._progress_print_heading(action) self._progress_alive = True - self._time_start_overall = time.perf_counter() self._thread.start() def _progress_thread(self): @@ -243,3 +275,17 @@ class ExportProgressLogger(_ExportLogger): if self._step_max != 0: self._progress_print_step() progress_value = property(_progress_get_current, _progress_set_current) + + +class ExportVerboseLogger(_ExportLogger): + def __init__(self, age_path): + super().__init__(True, age_path) + self.progress_range = 0 + self.progress_value = 0 + + def __exit__(self, type, value, traceback): + if value is not None: + export_time = time.perf_counter() - self._time_start_overall + self.msg("\nAborted after {:.2f}s", export_time) + self.msg("Error: {}", value) + return super().__exit__(type, value, traceback) diff --git a/korman/operators/op_export.py b/korman/operators/op_export.py index 0a8ad45..a1de7b9 100644 --- a/korman/operators/op_export.py +++ b/korman/operators/op_export.py @@ -45,6 +45,10 @@ class ExportOperator(bpy.types.Operator): "description": "Version of the Plasma Engine to target", "default": "pvPots", # This should be changed when moul is easier to target! "items": game_versions}), + + "verbose": (BoolProperty, {"name": "Display Verbose Log", + "description": "Shows the verbose export log in the console", + "default": False}), } # This wigs out and very bad things happen if it's not directly on the operator... @@ -58,6 +62,7 @@ class ExportOperator(bpy.types.Operator): # The crazy mess we're doing with props on the fly means we have to explicitly draw them :( layout.prop(age, "version") layout.prop(age, "bake_lighting") + layout.prop(age, "verbose") layout.prop(age, "profile_export") def __getattr__(self, attr): From 80ce28ddc8885e89f18af54ddd29a3e8a92acd20 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 12 Jun 2017 15:18:43 -0400 Subject: [PATCH 6/7] Show the console during exports The only major issue with the console based progress solution is that the user would have to remember to press "Toggle System Console" before the export. This button corresponds to the operator `bpy.ops.wm.console_toggle`. Unfortunately, Blender does not expose a way for us to query the console state. So, we have to get nasty and use ctypes to ask the OS if the console window is active. The user may already have it open and hidden behind Blender's UI, after all. This changeset causes the console to open during the export (unless disabled in the export operator). If the console was closed before the export, it closes again once the export is finished, unless there is an error. If the console was open, it remains open. Unfortunately, this only works on Windows. But, according to the source code of `bpy.ops.wm.console_toggle`, Blender's `ghost_toggleConsole` only functions on the Win32 subsystem... SDL, X11, and Cocoa are all no-ops. Future work would include a patch submitted to Blender adding an enum property to the console operator to avoid this code duplication. --- korman/exporter/convert.py | 3 +- korman/exporter/logger.py | 3 ++ korman/korlib/__init__.py | 1 + korman/korlib/console.py | 83 +++++++++++++++++++++++++++++++++++ korman/operators/op_export.py | 8 ++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 korman/korlib/console.py diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 3e87bea..842f0a7 100644 --- a/korman/exporter/convert.py +++ b/korman/exporter/convert.py @@ -14,6 +14,7 @@ # along with Korman. If not, see . import bpy +from ..korlib import ConsoleToggler from pathlib import Path from PyHSPlasma import * import time @@ -43,7 +44,7 @@ class Exporter: def run(self): log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger - with log(self._op.filepath) as self.report: + with ConsoleToggler(self._op.show_console), log(self._op.filepath) as self.report: # Step 0: Init export resmgr and stuff self.mgr = manager.ExportManager(self) self.mesh = mesh.MeshConverter(self) diff --git a/korman/exporter/logger.py b/korman/exporter/logger.py index 656819e..059e04b 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with Korman. If not, see . +from ..korlib import ConsoleToggler from pathlib import Path import threading import time @@ -40,6 +41,8 @@ class _ExportLogger: return self def __exit__(self, type, value, traceback): + if value is not None: + ConsoleToggler().keep_console = True self._file.close() return False diff --git a/korman/korlib/__init__.py b/korman/korlib/__init__.py index 77b9457..7d1cfcc 100644 --- a/korman/korlib/__init__.py +++ b/korman/korlib/__init__.py @@ -71,4 +71,5 @@ except ImportError: size = stream.readInt() return (header, size) else: + from .console import ConsoleToggler from .texture import TEX_DETAIL_ALPHA, TEX_DETAIL_ADD, TEX_DETAIL_MULTIPLY diff --git a/korman/korlib/console.py b/korman/korlib/console.py new file mode 100644 index 0000000..d84106e --- /dev/null +++ b/korman/korlib/console.py @@ -0,0 +1,83 @@ +# This file is part of Korman. +# +# Korman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Korman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Korman. If not, see . + +import bpy +import ctypes +import math +import sys + +class ConsoleToggler: + _instance = None + + def __init__(self, want_console=None): + if want_console is not None: + self._console_wanted = want_console + + def __new__(cls, want_console=None): + if cls._instance is None: + assert want_console is not None + cls._instance = object.__new__(cls) + cls._instance._console_was_visible = cls.is_console_visible() + cls._instance._console_wanted = want_console + cls._instance._context_active = False + cls._instance.keep_console = False + return cls._instance + + def __enter__(self): + if self._context_active: + raise RuntimeError("ConsoleToggler context manager is not reentrant") + self._console_visible = self.is_console_visible() + self._context_active = True + self.activate_console() + return self + + def __exit__(self, type, value, traceback): + if not self._console_was_visible and self._console_wanted: + if self.keep_console: + # Blender thinks the console is currently not visible. However, it actually is. + # So, we will fire off the toggle operator to keep Blender's internal state valid + bpy.ops.wm.console_toggle() + else: + self.hide_console() + self._context_active = False + self.keep_console = False + return False + + def activate_console(self): + if sys.platform == "win32": + hwnd = ctypes.windll.kernel32.GetConsoleWindow() + if self._console_wanted: + ctypes.windll.user32.ShowWindow(hwnd, 1) + if self._console_was_visible or self._console_wanted: + ctypes.windll.user32.BringWindowToTop(hwnd) + + @staticmethod + def hide_console(): + if sys.platform == "win32": + hwnd = ctypes.windll.kernel32.GetConsoleWindow() + ctypes.windll.user32.ShowWindow(hwnd, 0) + + @staticmethod + def is_console_visible(): + if sys.platform == "win32": + hwnd = ctypes.windll.kernel32.GetConsoleWindow() + return bool(ctypes.windll.user32.IsWindowVisible(hwnd)) + + @staticmethod + def is_platform_supported(): + # If you read Blender's source code, GHOST_toggleConsole (the "Toggle System Console" menu + # item) is only implemented on Windows. The majority of our audience is on Windows as well, + # so I honestly don't see this as an issue... + return sys.platform == "win32" diff --git a/korman/operators/op_export.py b/korman/operators/op_export.py index a1de7b9..d65ee5c 100644 --- a/korman/operators/op_export.py +++ b/korman/operators/op_export.py @@ -22,6 +22,7 @@ import pstats from .. import exporter from ..properties.prop_world import PlasmaAge from ..properties.modifiers.logic import game_versions +from ..korlib import ConsoleToggler class ExportOperator(bpy.types.Operator): """Exports ages for Cyan Worlds' Plasma Engine""" @@ -49,6 +50,10 @@ class ExportOperator(bpy.types.Operator): "verbose": (BoolProperty, {"name": "Display Verbose Log", "description": "Shows the verbose export log in the console", "default": False}), + + "show_console": (BoolProperty, {"name": "Display Log Console", + "description": "Forces the Blender System Console open during the export", + "default": True}), } # This wigs out and very bad things happen if it's not directly on the operator... @@ -62,6 +67,9 @@ class ExportOperator(bpy.types.Operator): # The crazy mess we're doing with props on the fly means we have to explicitly draw them :( layout.prop(age, "version") layout.prop(age, "bake_lighting") + row = layout.row() + row.enabled = ConsoleToggler.is_platform_supported() + row.prop(age, "show_console") layout.prop(age, "verbose") layout.prop(age, "profile_export") From be7e9427c46c43fab70fc557b1f49ec1c8fca1c9 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 12 Jun 2017 15:51:52 -0400 Subject: [PATCH 7/7] Blender 2.77 on Win32 fails with ctypes So we bump the version requirement to Blender 2.78 --- korman/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/korman/__init__.py b/korman/__init__.py index 552a43f..5d516e1 100644 --- a/korman/__init__.py +++ b/korman/__init__.py @@ -22,7 +22,7 @@ from . import operators bl_info = { "name": "Korman", "author": "Guild of Writers", - "blender": (2, 77, 0), + "blender": (2, 78, 0), "location": "File > Import-Export", "description": "Exporter for Cyan Worlds' Plasma Engine", "warning": "beta",