From af369f9b3192f1a58be5b82865038dd1df18285d Mon Sep 17 00:00:00 2001 From: TigerKat Date: Thu, 18 Jul 2019 23:56:06 +0930 Subject: [PATCH] Added Blender export support. bugfix: geo.py no longer crashes when writing models with no triangles. --- README.md | 10 ++- __init__.py | 93 +++++++++++++++++++++++ bones.py | 200 +++++++++++++++++++++++++------------------------- export_geo.py | 110 +++++++++++++++++++++++++++ geo.py | 143 ++++++++++++++++++++++++++++++++++-- geomesh.py | 128 ++++++++++++++++++++++++++++++++ import_geo.py | 1 + polygrid.py | 12 ++- 8 files changed, 587 insertions(+), 110 deletions(-) create mode 100644 __init__.py create mode 100644 export_geo.py create mode 100644 geomesh.py create mode 100644 import_geo.py diff --git a/README.md b/README.md index 0e90c6f..c30a34c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # geopy -Python tools for manipulating .geo files. The long term plan is to be able to export .geos direct from Blender. +Python tools for manipulating .geo files, and Blender addon for exporting .geo files. + +#Blender Addon + +TODO: Write Blender addon installation and use instructions. + +#Tools ## geo.py Contains the Geo class, which represents the contents of .geo files. Can be run to test the reading and writing functionality. @@ -32,4 +38,6 @@ Operation | Description Multiple operations can be specified and performed in the same run. ## Known Issues + - Not all structures are handled (reflection quads) - Not all structures are regenerated when writing a .geo file. (Reductions) + - Blender import of .geo files is currently a stub. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d5ed82e --- /dev/null +++ b/__init__.py @@ -0,0 +1,93 @@ + +bl_info = { + "name": "City of Heroes (.geo)", + "author": "TigerKat", + "version": (0, 1), + "blender": (2, 79, 0), + "location": "File > Import/Export,", + "description": "City of Heroes (.geo)", + "tracker_url": "https://git.ourodev.com/tigerkat/geopy/issues", + "warning": "", + "category": "Import-Export"} + +if "bpy" in locals(): + import importlib + if "import_geo" in locals(): + importlib.reload(import_geo) + if "export_geo" in locals(): + importlib.reload(export_geo) + if "geo" in locals(): + importlib.reload(geo) + if "geomesh" in locals(): + importlib.reload(geomesh) + if "vec_math" in locals(): + importlib.reload(vec_math) +## Python doesn't reload package sub-modules at the same time as __init__.py! +#import os.path +#import imp, sys +#for filename in [ f for f in os.listdir(os.path.dirname(os.path.realpath(__file__))) if f.endswith(".py") ]: +# if filename == os.path.basename(__file__): continue +# mod = sys.modules.get("{}.{}".format(__name__,filename[:-3])) +# if mod: imp.reload(mod) + +import mathutils +import bpy +from bpy.props import ( + BoolProperty, + EnumProperty, + FloatProperty, + StringProperty, +) + +from bpy_extras.io_utils import ( + ImportHelper, + ExportHelper, +) + +############################################################################## + +class ImportGeo(bpy.types.Operator, ImportHelper): + bl_idname = "import_scene.geo" + bl_label = "Import GEO" + + filename_ext = ".geo" + filter_glob = StringProperty(default="*.geo", options={'HIDDEN'}) + def execute(self, context): + from . import import_geo + keywords = self.as_keywords(ignore=("filter_glob",)) + return import_geo.load(self, context, **keywords) + +class ExportGeo(bpy.types.Operator, ExportHelper): + bl_idname = "export_scene.geo" + bl_label = "Export GEO" + + filename_ext = ".geo" + filter_glob = StringProperty(default="*.geo", options={'HIDDEN'}) + def execute(self, context): + from . import export_geo + keywords = self.as_keywords(ignore=("filter_glob", + "check_existing", + )) + return export_geo.save(self, context, **keywords) + + +def menu_func_import(self, context): + self.layout.operator(ImportGeo.bl_idname, + text="City of Heroes (.geo)") + +def menu_func_export(self, context): + self.layout.operator(ExportGeo.bl_idname, + text="City of Heroes (.geo)") + +def register(): + bpy.utils.register_module(__name__) + bpy.types.INFO_MT_file_import.append(menu_func_import) + bpy.types.INFO_MT_file_export.append(menu_func_export) + +def unregister(): + bpy.utils.unregister_module(__name__) + bpy.types.INFO_MT_file_import.remove(menu_func_import) + bpy.types.INFO_MT_file_export.remove(menu_func_export) + +if __name__ == "__main__": + register() diff --git a/bones.py b/bones.py index 68ea8f3..dc702fa 100644 --- a/bones.py +++ b/bones.py @@ -2,119 +2,121 @@ #List of bone ids. Should be kept strictly ordered. BONES_LIST = [ - "BONEID_HIPS", - "BONEID_WAIST", - "BONEID_CHEST", - "BONEID_NECK", - "BONEID_HEAD", - "BONEID_COL_R", - "BONEID_COL_L", - "BONEID_UARMR", - "BONEID_UARML", - "BONEID_LARMR", - "BONEID_LARML", - "BONEID_HANDR", - "BONEID_HANDL", - "BONEID_F1_R", - "BONEID_F1_L", - "BONEID_F2_R", - "BONEID_F2_L", - "BONEID_T1_R", - "BONEID_T1_L", - "BONEID_T2_R", - "BONEID_T2_L", - "BONEID_T3_R", - "BONEID_T3_L", - "BONEID_ULEGR", - "BONEID_ULEGL", - "BONEID_LLEGR", - "BONEID_LLEGL", - "BONEID_FOOTR", - "BONEID_FOOTL", - "BONEID_TOER", - "BONEID_TOEL", + "Hips", + "Waist", + "Chest", + "Neck", + "Head", + "Col_R", + "Col_L", + "UarmR", + "UarmL", + "LarmR", + "LarmL", + "HandR", + "HandL", + "F1_R", + "F1_L", + "F2_R", + "F2_L", + "T1_R", + "T1_L", + "T2_R", + "T2_L", + "T3_R", + "T3_L", + "UlegR", + "UlegL", + "LlegR", + "LlegL", + "FootR", + "FootL", + "ToeR", + "ToeL", - "BONEID_FACE", - "BONEID_DUMMY", - "BONEID_BREAST", - "BONEID_BELT", - "BONEID_GLOVEL", - "BONEID_GLOVER", - "BONEID_BOOTL", - "BONEID_BOOTR", - "BONEID_RINGL", - "BONEID_RINGR", - "BONEID_WEPL", - "BONEID_WEPR", - "BONEID_HAIR", - "BONEID_EYES", - "BONEID_EMBLEM", - "BONEID_SPADL", - "BONEID_SPADR", - "BONEID_BACK", - "BONEID_NECKLINE", - "BONEID_CLAWL", - "BONEID_CLAWR", - "BONEID_GUN", + "Face", + "Dummy", + "Breast", + "Belt", + "GloveL", + "GloveR", + "BootL", + "BootR", + "RingL", + "RingR", + "WepL", + "WepR", + "Hair", + "Eyes", + "Emblem", + "SpadL", + "SpadR", + "Back", + "Neckline", + "ClawL", + "ClawR", + "Gun", - "BONEID_RWING1", - "BONEID_RWING2", - "BONEID_RWING3", - "BONEID_RWING4", + "RWing1", + "RWing2", + "RWing3", + "RWing4", - "BONEID_LWING1", - "BONEID_LWING2", - "BONEID_LWING3", - "BONEID_LWING4", + "LWing1", + "LWing2", + "LWing3", + "LWing4", - "BONEID_MYSTIC", + "Mystic", - "BONEID_SLEEVEL", - "BONEID_SLEEVER", - "BONEID_ROBE", - "BONEID_BENDMYSTIC", + "SleeveL", + "SleeveR", + "Robe", + "BendMystic", - "BONEID_COLLAR", - "BONEID_BROACH", + "Collar", + "Broach", - "BONEID_BOSOMR", - "BONEID_BOSOML", + "BosomR", + "BosomL", - "BONEID_TOP", - "BONEID_SKIRT", - "BONEID_SLEEVES", + "Top", + "Skirt", + "Sleeves", - "BONEID_BROW", - "BONEID_CHEEKS", - "BONEID_CHIN", - "BONEID_CRANIUM", - "BONEID_JAW", - "BONEID_NOSE", + "Brow", + "Cheeks", + "Chin", + "Cranium", + "Jaw", + "Nose", - "BONEID_HIND_ULEGL", - "BONEID_HIND_LLEGL", - "BONEID_HIND_FOOTL", - "BONEID_HIND_TOEL", - "BONEID_HIND_ULEGR", - "BONEID_HIND_LLEGR", - "BONEID_HIND_FOOTR", - "BONEID_HIND_TOER", - "BONEID_FORE_ULEGL", - "BONEID_FORE_LLEGL", - "BONEID_FORE_FOOTL", - "BONEID_FORE_TOEL", - "BONEID_FORE_ULEGR", - "BONEID_FORE_LLEGR", - "BONEID_FORE_FOOTR", - "BONEID_FORE_TOER", + "Hind_UlegL", + "Hind_LlegL", + "Hind_FootL", + "Hind_ToeL", + "Hind_UlegR", + "Hind_LlegR", + "Hind_FootR", + "Hind_ToeR", + "Fore_UlegL", + "Fore_LlegL", + "Fore_FootL", + "Fore_ToeL", + "Fore_UlegR", + "Fore_LlegR", + "Fore_FootR", + "Fore_ToeR", - "BONEID_LEG_L_JET1", - "BONEID_LEG_L_JET2", - "BONEID_LEG_R_JET1", - "BONEID_LEG_R_JET2", + "Leg_L_Jet1", + "Leg_L_Jet2", + "Leg_R_Jet1", + "Leg_R_Jet2", ] BONES_LOOKUP = {} for i in range(len(BONES_LIST)): BONES_LOOKUP[BONES_LIST[i]] = i + BONES_LOOKUP[BONES_LIST[i].upper()] = i + BONES_LOOKUP[BONES_LIST[i].lower()] = i diff --git a/export_geo.py b/export_geo.py new file mode 100644 index 0000000..13f9006 --- /dev/null +++ b/export_geo.py @@ -0,0 +1,110 @@ +from .geo import Geo +from .geomesh import * +import bpy.path +import bpy + +from bpy_extras.io_utils import axis_conversion + +def convert_mesh(geo_model, mesh, obj): + # Be sure tessface & co are available! + if not mesh.tessfaces and mesh.polygons: + mesh.calc_tessface() + + mesh_verts = mesh.vertices # save a lookup + + has_uv = bool(mesh.tessface_uv_textures) + if has_uv: + active_uv_layer = mesh.tessface_uv_textures.active + if not active_uv_layer: + has_uv = False + else: + active_uv_layer = active_uv_layer.data + + geomesh = GeoMesh() + + texture_name = "white" + print("len(mesh.tessfaces): %s" % len(mesh.tessfaces)) + print("mesh.tessfaces: %s" % repr(mesh.tessfaces)) + for i, f in enumerate(mesh.tessfaces): + if has_uv: + uv = active_uv_layer[i] + texture_image = uv.image + uv = [uv.uv1, uv.uv2, uv.uv3, uv.uv4] + if texture_image is None: + texture_name = "white" + else: + texture_name = texture_image.name + if texture_name == "": + texture_name = bpy.path.display_name_from_filepath(texture_image.filepath) + if texture_name == "": + texture_name = "white" + else: + uv = [(0, 0)] * 4 + texture_name = "white" + f_verts = f.vertices + verts = [] + norms = [] + groups = [] + geoverts = [] + for i, v_index in enumerate(f_verts): + v = mesh_verts[v_index] + verts.append(v.co) + norms.append(v.normal) + weights = [] + for weight in v.groups: + group = obj.vertex_groups[weight.group] + w = [group.name, weight.weight] + weights.append(w) + gv = GeoVertex(v.co, v.normal, uv[i], weights) + geoverts.append(gv) + geomesh.addFace(geoverts, texture_name) + + print("face: vertices: %s uvs: %s norms: %s groups: %s" % (verts, uv, norms, weights)) + geomesh.dump() + geo_model.loadFromGeoMesh(geomesh) + #todo: + pass + +def save(operator, context, filepath = "", global_matrix = None, use_mesh_modifiers = True): + print("export_geo.save(): %s" % (filepath, )) + + geo = Geo() + geo.getTextureIndex("white.tga") + geo.getTextureIndex("white") + + body_name = bpy.path.display_name_from_filepath(filepath) + geo.setName(body_name) + + axis_rotation = axis_conversion('-Y', 'Z', 'Z', 'Y') + axis_rotation.resize_4x4() + + for ob in context.selected_objects: + print("Object: %s (%s)" % (ob.name, ob.type)) + if ob.type != "MESH": + continue + ob.update_from_editmode() + + if global_matrix is None: + from mathutils import Matrix + global_matrix = Matrix() + + # get the modifiers + mesh = ob.to_mesh(bpy.context.scene, use_mesh_modifiers, "PREVIEW") + + #translate_matrix = Matrix.Translation(-ob.location) + translate_matrix = Matrix() + # * ob.matrix_world + mesh.transform(global_matrix * translate_matrix * axis_rotation) + + mesh.calc_normals() + + geo_model = geo.addModel(ob.name) + + convert_mesh(geo_model, mesh, ob) + + data = geo.saveToData() + fh = open(filepath, "wb") + fh.write(data) + fh.close() + + return {'FINISHED'} diff --git a/geo.py b/geo.py index d5d741e..d5b2db3 100755 --- a/geo.py +++ b/geo.py @@ -5,9 +5,14 @@ import zlib import sys import traceback import math -from bones import * -from polygrid import PolyCell, PolyGrid -from util import Data +try: + from .bones import * + from .polygrid import PolyCell, PolyGrid + from .util import Data +except: + from bones import * + from polygrid import PolyCell, PolyGrid + from util import Data #Ver 0 .geo pre-header: #Offset Size Description @@ -461,7 +466,7 @@ class Reductions: print(" changes: %s" % (self.changes, )) print(" positions: %s" % (self.positions, )) print(" tex1s: %s" % (self.tex1s, )) - + class Model: def __init__(self, geo): self.geo = geo @@ -471,6 +476,8 @@ class Model: self.tri_count = None self.reflection_quad_count = None self.tex_idx = None + self.name = b"" + self.textures = [] pass def parseHeaderDataV2(self): (self.flags, ) = self.geo.getHeaderElement(" 0: self.polygrid = PolyGrid(self) self.grid_data = self.polygrid.encode() self.grid_header = self.polygrid.grid_header + self.radius = self.polygrid.radius + self.min = self.polygrid.aabb.min.data + self.max = self.polygrid.aabb.max.data else: self.polygrid = None self.grid_data = b"" self.grid_header = (0, 0.0, 0.0, 0.0, 1.0, 1.0, 0, 0) + self.radius = 0 + self.min = [0, 0, 0] + self.max = [0, 0, 0] if self.geo.version >= 7: #todo: build reductions if self.reductions is not None: @@ -767,6 +780,8 @@ class Model: for t in self.tex_idx: texidx_data.encode("= len(data): self.main_data += data @@ -1089,7 +1206,17 @@ class Geo: size = struct.calcsize(fmt) data = struct.unpack(fmt, self.main_data[offset : offset + size]) return data - + + def setName(self, name): + if isinstance(name, str): + name = bytes(name, "utf-8") + self.header_modelheader_name = name + def addModel(self, name): + if isinstance(name, str): + name = bytes(name, "utf-8") + self.models.append(Model(self)) + self.models[-1].name = name + return self.models[-1] if __name__ == "__main__": if len(sys.argv) <= 1: diff --git a/geomesh.py b/geomesh.py new file mode 100644 index 0000000..2e4da08 --- /dev/null +++ b/geomesh.py @@ -0,0 +1,128 @@ +import functools + +def weight_cmp(a, b): + return a[1] < b[1] or (a[1] == b[1] and a[0] < b[0]) + +def tuple_weights(weights): + return tuple((tuple(w) for w in weights)) + +class GeoVertex: + def __init__(self, coord, normal, uv, weights): + self.coord = coord + self.normal = normal + self.uv = uv + self.weights = weights + def __eq__(self, other): + return self.coord == other.coord and self.normal == other.normal and self.uv == other.uv and self.weights == other.weights + def __hash__(self): + #print("__hash__: %s" % ((tuple(self.coord), tuple(self.normal), tuple(self.uv), tuple(self.weights)), )) + return hash((tuple(self.coord), tuple(self.normal), tuple(self.uv), tuple_weights(self.weights))) + def selectWeights(self, count = None): + """Returns the list of weights attached to this vertex. List is sorted by weight, with the strongest first. If 'count' is given, only 'count' strongest are return. The final list is normalized so the sum is 1.""" + if len(self.weights) <= 0: + return [] + weights = list(self.weights) + #sort by weight + weights.sort(key = functools.cmp_to_key(weight_cmp), reverse = True) + if count is not None and len(weights) > count: + weights = weights[0:count] + nw = 0.0 + for w in weights: + nw += w[1] + if nw <= 0: + for w in weights: + w[1] = 0 + weights[0][1] = 1 + else: + for w in weights: + w[1] /= nw + return weights + def dump(self): + print(" GeoVertex: coord: %s normal: %s uv: %s weights: %s" % (self.coord, self.normal, self.uv, self.weights)) +class GeoFace: + def __init__(self, vert_indexes, texture_index): + self.vert_indexes = vert_indexes + self.texture_index = texture_index + def __eq__(self, other): + return self.vert_indexes == other.vert_indexes and self.texture_index == other.texture_index + def dump(self): + print(" GeoFace: vertex indexes: %s texture index: %s" % (self.vert_indexes, self.texture_index)) +class GeoMesh: + def __init__(self): + self.geovertex = [] + self.geovertex_map = {} + self.textures = [] + self.textures_map = {} + self.weights = [] + self.weights_map = {} + self.face = [] + self.have_weights = False + self.have_uvs = True + def getGeoVertexIndex(self, gv): + index = self.geovertex_map.get(gv, len(self.geovertex)) + if index == len(self.geovertex): + self.geovertex_map[gv] = index + self.geovertex.append(gv) + return index + def getTextureIndex(self, name): + index = self.textures_map.get(name, len(self.textures)) + if index == len(self.textures): + self.textures_map[name] = index + self.textures.append(name) + return index + def getWeightIndex(self, name): + index = self.weights_map.get(name, len(self.weights)) + if index == len(self.weights): + self.weights_map[name] = index + self.weights.append(name) + return index + + def addFace(self, geovertices, texture_name): + l = len(geovertices) + if l > 3: + #Do a naive conversion to a triangle fan, add each of those triangles as a face. Will give bad results in shape is not convex. + #Choose the start point as the one closest to the origin. Ties are resolved by lexical comparison of the coordinates. + start = 0 + start_dist = geovertices[0].coord.magnitude + for i in range(1, len(geovertices)): + dist = geovertices[i].coord.magnitude + if dist < start_dist: + start = i + start_dist = dist + elif dist == start_dist: + for j in range(3): + if geovertices[i].coord[j] < geovertices[start].coord[j]: + start = i + start_dist = dist + break + for i in range(2, len(geovertices)): + i1 = (start + i - 1) % l + i2 = (start + i) % l + self.addFace([geovertices[start], geovertices[i1], geovertices[i2]], texture_name) + return + elif l < 3: + return + for i in range(3): + for w in geovertices[i].weights: + w[0] = self.getWeightIndex(w[0]) + self.have_weights = True + geovertices_index = [self.getGeoVertexIndex(geovertices[0]), + self.getGeoVertexIndex(geovertices[1]), + self.getGeoVertexIndex(geovertices[2])] + self.face.append(GeoFace(geovertices_index, self.getTextureIndex(texture_name))) + pass + def sortFaces(self): + #Sort faces so they're grouped by texture index. + #todo: + pass + + def dump(self): + print("GeoMesh:") + print(" Textures: %s" % (self.textures, )) + print(" Weights: %s" % (self.weights, )) + print(" Vertices:") + for i, v in enumerate(self.geovertex): + v.dump() + print(" Faces:") + for i, f in enumerate(self.face): + f.dump() diff --git a/import_geo.py b/import_geo.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/import_geo.py @@ -0,0 +1 @@ + diff --git a/polygrid.py b/polygrid.py index b4341ef..246c5e2 100644 --- a/polygrid.py +++ b/polygrid.py @@ -1,8 +1,12 @@ import math import struct -from vec_math import Vector3, Quaternion, Aabb, Triangle -from util import Data +try: + from .vec_math import Vector3, Quaternion, Aabb, Triangle + from .util import Data +except: + from vec_math import Vector3, Quaternion, Aabb, Triangle + from util import Data #PolyGrid is an octree where the leaf nodes contain a list of all triangles that intersect that leaf node. #Each node on a PolyGrid is a PolyCell. @@ -139,6 +143,8 @@ class PolyGrid: self.position = [0, 0, 0] self.width = 1.0 self.bits = 0 + self.aabb = Aabb() + self.radius = 0 pass def parsePolyGridData(self, data, grid_header): self.cell = PolyCell() @@ -168,6 +174,8 @@ class PolyGrid: self.position = aabb.min sz = aabb.size() radius = sz.mag() * 0.5 + self.aabb = aabb + self.radius = radius if radius > 2000: min_width = 1024 elif radius > 1000: