Added Blender export support.

bugfix: geo.py no longer crashes when writing models with no triangles.
master
TigerKat 5 years ago
parent be2d6f5eed
commit af369f9b31

@ -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.

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

@ -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

@ -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'}

131
geo.py

@ -5,6 +5,11 @@ import zlib
import sys
import traceback
import math
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
@ -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("<I")
@ -711,14 +718,20 @@ class Model:
self.bone_ids += [0] * (15 - self.bone_count)
def encode(self):
#Regenerate dynamic data
if True:
if self.verts is not None and len(self.verts) > 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("<HH", *t)
self.tex_count = len(self.tex_idx)
self.vert_count = len(self.verts)
self.tri_count = len(self.tris)
self.name_ptr = len(self.geo.header_objname_data)
self.geo.header_objname_data += self.name + ZERO_BYTE
self.geo.header_objnames.append(self.name)
@ -886,11 +901,101 @@ class Model:
for i in range(self.bone_count):
print(" : %s" % (BONES_LIST[self.bone_ids[i]], ))
def saveToGeoMesh(self):
geomesh = GeoMesh()
geomesh.have_uvs = self.sts is not None
geomesh.have_weights = self.weights is not None
for i in range(len(self.verts)):
coord = self.verts[i]
normal = self.norms[i]
if geomesh.have_uvs:
uv = self.sts[i]
else:
uv = (0, 0)
if geomesh.have_weights:
weights = []
for i, w in enumerate(self.weights):
weights.append([self.weight_bones[i], w])
else:
weights = []
v = GeoVertex(coord, normal, uv, self.weights)
geomesh.getGeoVertexIndex(v)
texture_indexes = []
for t in self.tex_idx:
texture_indexes += [geomesh.getTextureIndex(self.geo.header_texnames[t[0]])] * t[1]
for i, t in enumerate(self.tris):
geomesh.addFace(geomesh.geovertex[t[0]], geomesh.geovertex[t[1]], geomesh.geovertex[t[2]], texture_indexes[i])
return geomesh
def loadFromGeoMesh(self, geomesh):
self.tris = [] #uncompressDeltas(self.tris_data, 3, self.tri_count, "I")
self.verts = [] #uncompressDeltas(self.verts_data, 3, self.vert_count, "f")
self.norms = [] #uncompressDeltas(self.norms_data, 3, self.vert_count, "f")
self.sts = [] #uncompressDeltas(self.sts_data, 2, self.vert_count, "f")
self.sts3 = None #uncompressDeltas(self.sts3_data, 2, self.vert_count, "f")
self.tex_idx = []
self.reductions = None
self.reflection_quads_data = None
self.reflection_quads = None
self.altpivotinfo = []
self.scale = [1.0, 1.0, 1.0]
self.autolod_dists = [-1, -1, -1]
self.id = -2
if geomesh.have_weights:
self.weights = []
self.weight_bones = []
else:
self.weights = None
self.weight_bones = None
geomesh.sortFaces()
#Determine the remap for converting geomesh textures indexes to geo texture indexes.
texture_remap = []
for i in range(len(geomesh.textures)):
texture_remap.append(self.geo.getTextureIndex(geomesh.textures[i]))
#Determine the remap for converting geomesh weight/bones indexes to geo weight/bone indexes.
weight_remap = []
for i in range(len(geomesh.weights)):
weight_remap.append(BONES_LOOKUP.get(geomesh.weights[i], 0))
#Convert vetices to: positions, normals, uvs, weights
for i, v in enumerate(geomesh.geovertex):
self.verts.append(v.coord)
self.norms.append(v.normal)
self.sts.append(v.uv)
if geomesh.have_weights:
weights = v.selectWeights(2)
if len(weights) == 0:
self.weights.append([1, 0])
self.weight_bones.append([0, 0])
elif len(weights) == 1:
self.weights.append([weights[0][1], 0])
self.weight_bones.append([weights[0][0], 0])
else:
self.weights.append([weights[0][1], weights[1][1]])
self.weight_bones.append([weights[0][0], weights[1][0]])
#Convert faces
texture_index = None
texture_count = 0
for i, t in enumerate(geomesh.face):
self.tris.append(t.vert_indexes)
if texture_index != t.texture_index:
if texture_index is not None:
self.tex_idx.append((texture_remap[texture_index], texture_count))
texture_count = 0
texture_index = t.texture_index
texture_count += 1
if texture_count != 0:
self.tex_idx.append((texture_remap[texture_index], texture_count))
print("self.tex_idx: %s" % (self.tex_idx, ))
class Geo:
def __init__(self):
self.data = b""
self.offset = 0
self.version = -1
self.models = []
self.header_texnames = []
self.header_modelheader_tracklength = 0
pass
def loadFromData(self, data):
self.data = data
@ -988,6 +1093,16 @@ class Geo:
#todo: sanity check size?
return data
def getTextureIndex(self, texture_name):
if isinstance(texture_name, str):
texture_name = bytes(texture_name, "utf-8")
for i in range(len(self.header_texnames)):
if texture_name == self.header_texnames[i]:
return i
i = len(self.header_texnames)
self.header_texnames.append(texture_name)
return i
def encode(self):
self.data = None
self.version = 8
@ -1033,8 +1148,10 @@ class Geo:
self.main_data += data
return o
def encodeMainDataPacked(self, data):
#todo: minimum compression?
o = len(self.main_data)
if data is None or len(data) <= 0:
return (0, 0, o)
#todo: minimum compression?
d = zlib.compress(data)
if len(d) >= len(data):
self.main_data += data
@ -1090,6 +1207,16 @@ class Geo:
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:

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

@ -1,6 +1,10 @@
import math
import struct
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
@ -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:

Loading…
Cancel
Save