structures.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. """The Godot file format has several concepts such as headings and subresources
  2. This file contains classes to help dealing with the actual writing to the file
  3. """
  4. import os
  5. import math
  6. import copy
  7. import collections
  8. import mathutils
  9. class ValidationError(Exception):
  10. """An error type for explicitly delivering error messages to user."""
  11. class ESCNFile:
  12. """The ESCN file consists of three major sections:
  13. - paths to external resources
  14. - internal resources
  15. - nodes
  16. Because the write order is important, you have to know all the resources
  17. before you can start writing nodes. This class acts as a container to store
  18. the file before it can be written out in full
  19. Things appended to this file should have the method "to_string()" which is
  20. used when writing the file
  21. """
  22. def __init__(self, heading):
  23. self.heading = heading
  24. self.nodes = []
  25. self.internal_resources = []
  26. self._internal_hashes = {}
  27. self.external_resources = []
  28. self._external_hashes = {}
  29. def get_external_resource(self, hashable):
  30. """Searches for existing external resources, and returns their
  31. resource ID. Returns None if it isn't in the file"""
  32. return self._external_hashes.get(hashable)
  33. def add_external_resource(self, item, hashable):
  34. """External resources are indexed by ID. This function ensures no
  35. two items have the same ID. It returns the index to the resource.
  36. An error is thrown if the hashable matches an existing resource. You
  37. should check get_external_resource before converting it into godot
  38. format
  39. The resource is not written to the file until the end, so you can
  40. modify the resource after adding it to the file"""
  41. if self.get_external_resource(hashable) is not None:
  42. raise Exception("Attempting to add object to file twice")
  43. self.external_resources.append(item)
  44. index = len(self.external_resources)
  45. item.heading['id'] = index
  46. self._external_hashes[hashable] = index
  47. return index
  48. def get_internal_resource(self, hashable):
  49. """Searches for existing internal resources, and returns their
  50. resource ID"""
  51. return self._internal_hashes.get(hashable)
  52. def add_internal_resource(self, item, hashable):
  53. """See comment on external resources. It's the same"""
  54. if self.get_internal_resource(hashable) is not None:
  55. raise Exception("Attempting to add object to file twice")
  56. resource_id = self.force_add_internal_resource(item)
  57. self._internal_hashes[hashable] = resource_id
  58. return resource_id
  59. def force_add_internal_resource(self, item):
  60. """Add an internal resource without providing an hashable,
  61. ATTENTION: it should not be called unless an hashable can not
  62. be found"""
  63. self.internal_resources.append(item)
  64. index = len(self.internal_resources)
  65. item.heading['id'] = index
  66. return index
  67. def add_node(self, item):
  68. """Adds a node to this file. Nodes aren't indexed, so none of
  69. the complexity of the other resource types"""
  70. self.nodes.append(item)
  71. def fix_paths(self, export_settings):
  72. """Ensures all external resource paths are relative to the exported
  73. file"""
  74. for res in self.external_resources:
  75. res.fix_path(export_settings)
  76. def to_string(self):
  77. """Serializes the file ready to dump out to disk"""
  78. sections = (
  79. self.heading.to_string(),
  80. '\n\n'.join(i.to_string() for i in self.external_resources),
  81. '\n\n'.join(e.to_string() for e in self.internal_resources),
  82. '\n\n'.join(n.to_string() for n in self.nodes)
  83. )
  84. return "\n\n".join([s for s in sections if s]) + "\n"
  85. class FileEntry(collections.OrderedDict):
  86. '''Everything inside the file looks pretty much the same. A heading
  87. that looks like [type key=val key=val...] and contents that is newline
  88. separated key=val pairs. This FileEntry handles the serialization of
  89. on entity into this form'''
  90. def __init__(self, entry_type, heading_dict=(), values_dict=()):
  91. self.entry_type = entry_type
  92. self.heading = collections.OrderedDict(heading_dict)
  93. # This string is copied verbaitum, so can be used for custom writing
  94. self.contents = ''
  95. super().__init__(values_dict)
  96. def generate_heading_string(self):
  97. """Convert the heading dict into [type key=val key=val ...]"""
  98. out_str = '[{}'.format(self.entry_type)
  99. for var in self.heading:
  100. val = self.heading[var]
  101. if isinstance(val, str):
  102. val = '"{}"'.format(val)
  103. out_str += " {}={}".format(var, val)
  104. out_str += ']'
  105. return out_str
  106. def generate_body_string(self):
  107. """Convert the contents of the super/internal dict into newline
  108. separated key=val pairs"""
  109. lines = []
  110. for var in self:
  111. val = self[var]
  112. val = to_string(val)
  113. lines.append('{} = {}'.format(var, val))
  114. return "\n".join(lines)
  115. def to_string(self):
  116. """Serialize this entire entry"""
  117. heading = self.generate_heading_string()
  118. body = self.generate_body_string()
  119. if body and self.contents:
  120. return "{}\n\n{}\n{}".format(heading, body, self.contents)
  121. if body:
  122. return "{}\n\n{}".format(heading, body)
  123. return heading
  124. class NodeTemplate(FileEntry):
  125. """Most things inside the escn file are Nodes that make up the scene tree.
  126. This is a template node that can be used to contruct nodes of any type.
  127. It is not intended that other classes in the exporter inherit from this,
  128. but rather that all the exported nodes use this template directly."""
  129. def __init__(self, name, node_type, parent_node):
  130. # set child, parent relation
  131. self.children = []
  132. self.parent = parent_node
  133. # filter out special character
  134. invalid_chs = ('.', '\\', '/', ':')
  135. node_name = ''.join(filter(lambda ch: ch not in invalid_chs, name))
  136. if parent_node is not None:
  137. # solve duplication
  138. counter = 1
  139. child_name_set = {c.get_name() for c in self.parent.children}
  140. node_name_base = node_name
  141. while node_name in child_name_set:
  142. node_name = node_name_base + str(counter).zfill(3)
  143. counter += 1
  144. parent_node.children.append(self)
  145. super().__init__(
  146. "node",
  147. collections.OrderedDict((
  148. ("name", node_name),
  149. ("type", node_type),
  150. ("parent", parent_node.get_path())
  151. ))
  152. )
  153. else:
  154. # root node
  155. super().__init__(
  156. "node",
  157. collections.OrderedDict((
  158. ("type", node_type),
  159. ("name", node_name)
  160. ))
  161. )
  162. def get_name(self):
  163. """Get the name of the node in Godot scene"""
  164. return self.heading['name']
  165. def get_path(self):
  166. """Get the node path in the Godot scene"""
  167. # root node
  168. if 'parent' not in self.heading:
  169. return '.'
  170. # children of root node
  171. if self.heading['parent'] == '.':
  172. return self.heading['name']
  173. return self.heading['parent'] + '/' + self.heading['name']
  174. def get_type(self):
  175. """Get the node type in Godot scene"""
  176. return self.heading["type"]
  177. class ExternalResource(FileEntry):
  178. """External Resouces are references to external files. In the case of
  179. an escn export, this is mostly used for images, sounds and so on"""
  180. def __init__(self, path, resource_type):
  181. super().__init__(
  182. 'ext_resource',
  183. collections.OrderedDict((
  184. # ID is overwritten by ESCN_File.add_external_resource
  185. ('id', None),
  186. ('path', path),
  187. ('type', resource_type)
  188. ))
  189. )
  190. def fix_path(self, export_settings):
  191. """Makes the resource path relative to the exported file"""
  192. # The replace line is because godot always works in linux
  193. # style slashes, and python doing relpath uses the one
  194. # from the native OS
  195. self.heading['path'] = os.path.relpath(
  196. self.heading['path'],
  197. os.path.dirname(export_settings["path"]),
  198. ).replace('\\', '/')
  199. class InternalResource(FileEntry):
  200. """ A resource stored internally to the escn file, such as the
  201. description of a material """
  202. def __init__(self, resource_type, name):
  203. super().__init__(
  204. 'sub_resource',
  205. collections.OrderedDict((
  206. # ID is overwritten by ESCN_File.add_internal_resource
  207. ('id', None),
  208. ('type', resource_type)
  209. ))
  210. )
  211. self['resource_name'] = '"{}"'.format(
  212. name.replace('.', '').replace('/', '')
  213. )
  214. class Array(list):
  215. """In the escn file there are lots of arrays which are defined by
  216. a type (eg Vector3Array) and then have lots of values. This helps
  217. to serialize that sort of array. You can also pass in custom separators
  218. and suffixes.
  219. Note that the constructor values parameter flattens the list using the
  220. add_elements method
  221. """
  222. def __init__(self, prefix, seperator=', ', suffix=')', values=()):
  223. self.prefix = prefix
  224. self.seperator = seperator
  225. self.suffix = suffix
  226. super().__init__()
  227. self.add_elements(values)
  228. self.__str__ = self.to_string
  229. def add_elements(self, list_of_lists):
  230. """Add each element from a list of lists to the array (flatten the
  231. list of lists)"""
  232. for lis in list_of_lists:
  233. self.extend(lis)
  234. def to_string(self):
  235. """Convert the array to serialized form"""
  236. return "{}{}{}".format(
  237. self.prefix,
  238. self.seperator.join([to_string(v) for v in self]),
  239. self.suffix
  240. )
  241. class Map(collections.OrderedDict):
  242. """An ordered dict, used to serialize to a dict to escn file. Note
  243. that the key should be string, but for the value will be applied
  244. with to_string() method"""
  245. def __init__(self):
  246. super().__init__()
  247. self.__str__ = self.to_string
  248. def to_string(self):
  249. """Convert the map to serialized form"""
  250. return ("{\n\t" +
  251. ',\n\t'.join(['"{}":{}'.format(k, to_string(v))
  252. for k, v in self.items()]) +
  253. "\n}")
  254. class NodePath:
  255. """Node in scene points to other node or node's attribute,
  256. for example, a MeshInstane points to a Skeleton. """
  257. def __init__(self, from_here, to_there, attribute_pointed=''):
  258. self.relative_path = os.path.normpath(
  259. os.path.relpath(to_there, from_here)
  260. )
  261. if os.sep == '\\':
  262. # Ensure node path use '/' on windows as well
  263. self.relative_path = self.relative_path.replace('\\', '/')
  264. self.attribute_name = attribute_pointed
  265. def new_copy(self, attribute=None):
  266. """Return a new instance of the current NodePath and
  267. able to change the attribute pointed"""
  268. new_node_path = copy.deepcopy(self)
  269. if attribute is not None:
  270. new_node_path.attribute_name = attribute
  271. return new_node_path
  272. def to_string(self):
  273. """Serialize a node path"""
  274. return 'NodePath("{}:{}")'.format(
  275. self.relative_path,
  276. self.attribute_name
  277. )
  278. class RGBA:
  279. """Color with an Alpha channel.
  280. Use when you need to export a color with alpha, as mathutils.Color lacks
  281. an alpha channel.
  282. See https://developer.blender.org/T53540
  283. """
  284. def __init__(self, values):
  285. self.values = values
  286. def to_string(self):
  287. """Convert the color to serialized form"""
  288. return color_to_string(self.values)
  289. def fix_matrix(mtx):
  290. """ Shuffles a matrix to change from y-up to z-up"""
  291. # TODO: can this be replaced my a matrix multiplcation?
  292. trans = mathutils.Matrix(mtx)
  293. up_axis = 2
  294. for i in range(3):
  295. trans[1][i], trans[up_axis][i] = trans[up_axis][i], trans[1][i]
  296. for i in range(3):
  297. trans[i][1], trans[i][up_axis] = trans[i][up_axis], trans[i][1]
  298. trans[1][3], trans[up_axis][3] = trans[up_axis][3], trans[1][3]
  299. trans[up_axis][0] = -trans[up_axis][0]
  300. trans[up_axis][1] = -trans[up_axis][1]
  301. trans[0][up_axis] = -trans[0][up_axis]
  302. trans[1][up_axis] = -trans[1][up_axis]
  303. trans[up_axis][3] = -trans[up_axis][3]
  304. return trans
  305. _AXIS_CORRECT = mathutils.Matrix.Rotation(math.radians(-90), 4, 'X')
  306. def fix_directional_transform(mtx):
  307. """Used to correct spotlights and cameras, which in blender are
  308. Z-forwards and in Godot are Y-forwards"""
  309. return mtx @ _AXIS_CORRECT
  310. def fix_bone_attachment_transform(attachment_obj, blender_transform):
  311. """Godot and blender bone children nodes' transform relative to
  312. different bone joints, so there is a difference of bone_length
  313. along bone direction axis"""
  314. armature_obj = attachment_obj.parent
  315. bone_length = armature_obj.data.bones[attachment_obj.parent_bone].length
  316. mtx = mathutils.Matrix(blender_transform)
  317. mtx[1][3] += bone_length
  318. return mtx
  319. def fix_bone_attachment_location(attachment_obj, location_vec):
  320. """Fix the bone length difference in location vec3 of
  321. BoneAttachment object"""
  322. armature_obj = attachment_obj.parent
  323. bone_length = armature_obj.data.bones[attachment_obj.parent_bone].length
  324. vec = mathutils.Vector(location_vec)
  325. vec[1] += bone_length
  326. return vec
  327. def gamma_correct(color):
  328. """Apply sRGB color space gamma correction to the given color"""
  329. if isinstance(color, float):
  330. # seperate color channel
  331. return color ** (1 / 2.2)
  332. # mathutils.Color does not support alpha yet, so just use RGB
  333. # see: https://developer.blender.org/T53540
  334. color = color[0:3]
  335. # note that here use a widely mentioned sRGB approximation gamma = 2.2
  336. # it is good enough, the exact gamma of sRGB can be find at
  337. # https://en.wikipedia.org/wiki/SRGB
  338. if len(color) > 3:
  339. color = color[:3]
  340. return mathutils.Color(tuple([x ** (1 / 2.2) for x in color]))
  341. # ------------------ Implicit Conversions of Blender Types --------------------
  342. def mat4_to_string(mtx, prefix='Transform(', suffix=')'):
  343. """Converts a matrix to a "Transform" string that can be parsed by Godot"""
  344. mtx = fix_matrix(mtx)
  345. array = Array(prefix, suffix=suffix)
  346. for row in range(3):
  347. for col in range(3):
  348. array.append(mtx[row][col])
  349. # Export the basis
  350. for axis in range(3):
  351. array.append(mtx[axis][3])
  352. return array.to_string()
  353. def color_to_string(rgba):
  354. """Converts an RGB colors in range 0-1 into a fomat Godot can read. Accepts
  355. iterables of 3 or 4 in length, but is designed for mathutils.Color"""
  356. alpha = 1.0 if len(rgba) < 4 else rgba[3]
  357. col = list(rgba[0:3]) + [alpha]
  358. return Array('Color(', values=[col]).to_string()
  359. def vector_to_string(vec):
  360. """Encode a mathutils.vector. actually, it accepts iterable of any length,
  361. but 2, 3 are best...."""
  362. return Array('Vector{}('.format(len(vec)), values=[vec]).to_string()
  363. def float_to_string(num):
  364. """Intelligently rounds float numbers"""
  365. if abs(num) < 1e-15:
  366. # This should make floating point errors round sanely. It does mean
  367. # that if you have objects with large scaling factors and tiny meshes,
  368. # then the object may "collapse" to zero.
  369. # There are still some e-8's that appear in the file, but I think
  370. # people would notice it collapsing.
  371. return '0.0'
  372. return '{:.6}'.format(num)
  373. def to_string(val):
  374. """Attempts to convert any object into a string using the conversions
  375. table, explicit conversion, or falling back to the str() method"""
  376. if hasattr(val, "to_string"):
  377. val = val.to_string()
  378. else:
  379. converter = CONVERSIONS.get(type(val))
  380. if converter is not None:
  381. val = converter(val)
  382. else:
  383. val = str(val)
  384. return val
  385. # Finds the correct conversion function for a datatype
  386. CONVERSIONS = {
  387. float: float_to_string,
  388. bool: lambda x: 'true' if x else 'false',
  389. mathutils.Matrix: mat4_to_string,
  390. mathutils.Color: color_to_string,
  391. mathutils.Vector: vector_to_string
  392. }