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/__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", diff --git a/korman/exporter/convert.py b/korman/exporter/convert.py index 4e26fe0..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 @@ -42,19 +43,28 @@ 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)) - start = time.perf_counter() - + log = logger.ExportVerboseLogger if self._op.verbose else logger.ExportProgressLogger + 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) - self.report = logger.ExportAnalysis() self.physics = physics.PhysicsConverter(self) self.light = rtlight.LightConverter(self) 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() @@ -91,17 +101,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() - print("\nExported {}.age in {:.2f} seconds".format(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): + 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]) @@ -136,6 +147,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): @@ -163,7 +175,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 +199,13 @@ 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: - 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 +213,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,8 +228,9 @@ 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) + 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 @@ -227,21 +245,33 @@ 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...") - 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_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] + self.report.progress_value = len(self.want_node_trees) - len(need_to_export) + 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) + 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 +281,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,7 +300,9 @@ class Exporter: return False def _post_process_scene_objects(self): - print("\nPostprocessing SceneObjects...") + 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: @@ -292,5 +325,5 @@ 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) + inc_progress() diff --git a/korman/exporter/etlight.py b/korman/exporter/etlight.py index ab93089..0f1c428 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 ExportProgressLogger from .mesh import _VERTEX_COLOR_LAYERS from ..helpers import * @@ -24,10 +25,27 @@ _NUM_RENDER_LAYERS = 20 class LightBaker: """ExportTime Lighting""" - def __init__(self): + def __init__(self, report=None): self._lightgroups = {} + if report is None: + self._report = ExportProgressLogger() + 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) @@ -63,7 +81,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: @@ -77,43 +95,51 @@ 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 - print(" Preparing to bake...") + 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": 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(" ...") + 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": - print(" {} Lightmap(s) [H:{:X}]".format(len(value), hash(key))) - self._bake_lightmaps(value, key[1:]) - elif key[0] == "vcol": - print(" {} Crap Light(s)".format(len(value))) - 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 e8dc566..059e04b 100644 --- a/korman/exporter/logger.py +++ b/korman/exporter/logger.py @@ -13,64 +13,282 @@ # 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 sys +import threading +import time -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. - """ +_HEADING_SIZE = 50 +_MAX_ELIPSES = 3 +_MAX_TIME_UNTIL_ELIPSES = 2.0 - _porting = [] - _warnings = [] +class _ExportLogger: + 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 + + # 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 = self._age_path.with_name("{}_export".format(self._age_path.stem)).with_suffix(".log") + self._file = open(str(my_path), "w") + return self + + def __exit__(self, type, value, traceback): + if value is not None: + ConsoleToggler().keep_console = True + 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")) + if self._print_logs: + print(msg) + + 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")) + 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 - def port(self, message, indent=0): - self._porting.append(message) - print(" " * indent, end="") - print("PORTING: {}".format(message)) + 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")) + if self._print_logs: + print(msg) + self._warnings.append(args[0]) - def warn(self, message, indent=0): - self._warnings.append(message) - print(" " * indent, end="") - print("WARNING: {}".format(message)) +class ExportProgressLogger(_ExportLogger): + def __init__(self, age_path=None): + super().__init__(False, age_path) -class ExportLogger: - """Yet Another Logger(TM)""" + # 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 = "" - 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 = my_path.with_name("{}_export".format(my_path.stem)).with_suffix(".log") - self._file = open(str(my_path), "w") + # Progress manager + self._progress_alive = False + self._progress_steps = [] + self._step_id = -1 + self._step_max = 0 + self._step_progress = 0 + self._time_start_step = 0 - for i in dir(self._file): - if not hasattr(self, i): - setattr(self, i, getattr(self._file, i)) + 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 __enter__(self): - self._stdout, sys.stdout = sys.stdout, self._file - self._stderr, sys.stderr = sys.stderr, self._file + def progress_add_step(self, name): + assert self._step_id == -1 + self._progress_steps.append(name) - def __exit__(self, type, value, traceback): - sys.stdout = self._stdout - sys.stderr = self._stderr + 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 + 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""" + assert self._step_id != -1 + self._step_progress += 1 + 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 flush(self): - self._file.flush() - self._stdout.flush() - self._stderr.flush() + 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 "" + line = "{border} {pad}{text} {border}".format(border=border, pad=pad, text=text) + self._progress_print_line(line) + else: + self._progress_print_line("-" * _HEADING_SIZE) - def write(self, str): - self._file.write(str) - self._stdout.write(str) + 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 and self._step_progress != 0: + stage = "{} of {}".format(self._step_progress, self._step_max) + else: + stage = "" + print_func = self._progress_print_line if error else self._progress_print_volatile - def writelines(self, seq): - self._file.writelines(seq) - self._stdout.writelines(seq) + 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 + 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): + super().progress_start(action) + self._progress_print_heading("Korman") + self._progress_print_heading(action) + self._progress_alive = True + 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 + 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) + + +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/exporter/material.py b/korman/exporter/material.py index edeb1b6..f66f52d 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,20 @@ 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): + 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) - print("\n[Mipmap '{}']".format(name)) + self._report.msg("\n[Mipmap '{}']", name) image = key.image oWidth, oHeight = image.size @@ -628,7 +634,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 +647,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 +660,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 +670,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), @@ -680,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, []) @@ -722,6 +730,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..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(): - print("\n[DrawableSpans '{}']".format(dspan.key.name)) - print(" Composing geometry data") + 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) - print(" Bounds and SpaceTree in the saddle") + inc_progress() def _export_geometry(self, bo, mesh, materials, geospans): geodata = [_GeoData(len(mesh.vertices)) for i in materials] @@ -422,7 +423,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 +499,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/__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/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/operators/op_export.py b/korman/operators/op_export.py index 0a8ad45..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""" @@ -45,6 +46,14 @@ 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}), + + "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... @@ -58,6 +67,10 @@ 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") def __getattr__(self, attr): 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.