Initial version 2 export support

This commit is contained in:
Pyogenics
2025-04-27 15:54:35 +01:00
parent b67cee3756
commit 6ef3f6e889
5 changed files with 326 additions and 8 deletions

View File

@@ -20,7 +20,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
''' '''
from .IOTools import unpackStream, readNullTerminatedString, calculatePadding from io import BytesIO
from .IOTools import unpackStream, packStream, readNullTerminatedString, calculatePadding
from . import A3DObjects from . import A3DObjects
''' '''
@@ -65,6 +67,23 @@ class A3D:
self.readRootBlock2(stream) self.readRootBlock2(stream)
elif self.version == 3: elif self.version == 3:
self.readRootBlock3(stream) self.readRootBlock3(stream)
else:
raise RuntimeError(f"Unknown A3D version: {self.version}")
def write(self, stream, version=2):
# Write header data
stream.write(A3D_SIGNATURE)
packStream("<2H", stream, version, 0)
# Write root block
if version == 1:
self.writeRootBlock1(stream)
elif version == 2:
self.writeRootBlock2(stream)
elif version == 3:
self.writeRootBlock3(stream)
else:
raise RuntimeError(f"Unknown A3D version: {version} whilst writing A3D")
''' '''
Root data blocks Root data blocks
@@ -72,6 +91,9 @@ class A3D:
def readRootBlock1(self, stream): def readRootBlock1(self, stream):
raise RuntimeError("Version 1 files are not supported yet") raise RuntimeError("Version 1 files are not supported yet")
def writeRootBlock1(self, stream):
raise RuntimeError("Version 1 files are not supported yet")
def readRootBlock2(self, stream): def readRootBlock2(self, stream):
# Verify signature # Verify signature
signature, _ = unpackStream("<2I", stream) signature, _ = unpackStream("<2I", stream)
@@ -85,6 +107,21 @@ class A3D:
self.readTransformBlock2(stream) self.readTransformBlock2(stream)
self.readObjectBlock2(stream) self.readObjectBlock2(stream)
def writeRootBlock2(self, stream):
buffer = BytesIO()
# Write data to the buffer
print("Writing root block")
self.writeMaterialBlock2(buffer)
self.writeMeshBlock2(buffer)
self.writeTransformBlock2(buffer)
self.writeObjectBlock2(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_ROOTBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
def readRootBlock3(self, stream): def readRootBlock3(self, stream):
# Verify signature # Verify signature
signature, length = unpackStream("<2I", stream) signature, length = unpackStream("<2I", stream)
@@ -101,6 +138,9 @@ class A3D:
padding = calculatePadding(length) padding = calculatePadding(length)
stream.read(padding) stream.read(padding)
def writeRootBlock3(self, stream):
raise RuntimeError("Version 3 files are not supported yet")
''' '''
Material data blocks Material data blocks
''' '''
@@ -117,6 +157,20 @@ class A3D:
material.read2(stream) material.read2(stream)
self.materials.append(material) self.materials.append(material)
def writeMaterialBlock2(self, stream):
buffer = BytesIO()
# Write data to the buffer
print("Writing material block")
packStream("<I", buffer, len(self.materials))
for material in self.materials:
material.write2(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_MATERIALBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
def readMaterialBlock3(self, stream): def readMaterialBlock3(self, stream):
# Verify signature # Verify signature
signature, length, materialCount = unpackStream("<3I", stream) signature, length, materialCount = unpackStream("<3I", stream)
@@ -150,6 +204,20 @@ class A3D:
mesh.read2(stream) mesh.read2(stream)
self.meshes.append(mesh) self.meshes.append(mesh)
def writeMeshBlock2(self, stream):
buffer = BytesIO()
# Write data to the buffer
print("Writing mesh block")
packStream("<I", buffer, len(self.meshes))
for mesh in self.meshes:
mesh.write2(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_MESHBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
def readMeshBlock3(self, stream): def readMeshBlock3(self, stream):
# Verify signature # Verify signature
signature, length, meshCount = unpackStream("<3I", stream) signature, length, meshCount = unpackStream("<3I", stream)
@@ -187,6 +255,22 @@ class A3D:
parentID, = unpackStream("<i", stream) parentID, = unpackStream("<i", stream)
self.transformParentIDs.append(parentID) self.transformParentIDs.append(parentID)
def writeTransformBlock2(self, stream):
buffer = BytesIO()
# Write data to the buffer
print("Writing transform block")
packStream("<I", buffer, len(self.transforms))
for transform in self.transforms:
transform.write2(buffer)
for parentID in self.transformParentIDs:
packStream("<i", buffer, parentID)
# Write buffer to stream
packStream("<2I", stream, A3D_TRANSFORMBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
def readTransformBlock3(self, stream): def readTransformBlock3(self, stream):
# Verify signature # Verify signature
signature, length, transformCount = unpackStream("<3I", stream) signature, length, transformCount = unpackStream("<3I", stream)
@@ -225,6 +309,20 @@ class A3D:
objec.read2(stream) objec.read2(stream)
self.objects.append(objec) self.objects.append(objec)
def writeObjectBlock2(self, stream):
buffer = BytesIO()
# Write data to the buffer
print("Writing object block")
packStream("<I", buffer, len(self.objects))
for objec in self.objects:
objec.write2(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_OBJECTBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
def readObjectBlock3(self, stream): def readObjectBlock3(self, stream):
# Verify signature # Verify signature
signature, length, objectCount = unpackStream("<3I", stream) signature, length, objectCount = unpackStream("<3I", stream)

View File

@@ -0,0 +1,134 @@
'''
Copyright (c) 2025 Pyogenics <https://github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from . import A3DObjects
from .A3DObjects import (
A3D_VERTEXTYPE_COORDINATE,
A3D_VERTEXTYPE_UV1,
A3D_VERTEXTYPE_NORMAL1,
A3D_VERTEXTYPE_UV2,
A3D_VERTEXTYPE_COLOR,
A3D_VERTEXTYPE_NORMAL2
)
class A3DBlenderExporter:
def __init__(self, modelData, objects):
self.modelData = modelData
self.objects = objects
def exportData(self):
print("Exporting blender data to A3D")
# Process objects
materials = {}
meshes = []
transforms = {}
objects = []
for ob in self.objects:
me = ob.data
# Process materials
for ma in me.materials:
# Make sure we haven't processed this data block already
if ma.name in materials:
continue
materialData = A3DObjects.A3DMaterial()
materialData.name = ma.name
materialData.diffuseMap = ""
colorR, colorG, colorB, _ = ma.diffuse_color
materialData.color = (colorR, colorG, colorB)
materials[ma.name] = materialData
# Create mesh
mesh = self.buildA3DMesh(me)
meshes.append(mesh)
# Create transform
transform = A3DObjects.A3DTransform()
transform.position = ob.location
rotationW, rotationX, rotationY, rotationZ = ob.rotation_quaternion
transform.rotation = (rotationX, rotationY, rotationZ, rotationW)
transform.scale = ob.scale
transforms[ob.name] = transform
# Create object
objec = A3DObjects.A3DObject()
objec.name = ob.name
objec.meshID = len(meshes) - 1
objec.transformID = len(transforms) - 1
objects.append(objec)
# Create parentIDs
transformParentIDs = []
for ob in self.objects:
parentOB = ob.parent
if (parentOB == None) or (ob.name not in transforms):
transformParentIDs.append(0) #XXX: this is only for version 2
else:
parentIndex = list(transforms.keys()).index(ob.name)
transformParentIDs.append(parentIndex)
self.modelData.materials = materials.values()
self.modelData.meshes = meshes
self.modelData.transforms = transforms.values()
self.modelData.transformParentIDs = transformParentIDs
self.modelData.objects = objects
def buildA3DMesh(self, me):
mesh = A3DObjects.A3DMesh()
mesh.vertexCount = len(me.vertices)
# Create vertex buffers
coordinateBuffer = A3DObjects.A3DVertexBuffer()
coordinateBuffer.bufferType = A3D_VERTEXTYPE_COORDINATE
normal1Buffer = A3DObjects.A3DVertexBuffer()
normal1Buffer.bufferType = A3D_VERTEXTYPE_NORMAL1
for vertex in me.vertices:
coordinateBuffer.data.append(vertex.co)
normal1Buffer.data.append(vertex.normal)
uv1Buffer = A3DObjects.A3DVertexBuffer()
uv1Buffer.bufferType = A3D_VERTEXTYPE_UV1
uv1Data = me.uv_layers[0]
for vertex in uv1Data.uv:
uv1Buffer.data.append(vertex.vector)
mesh.vertexBufferCount = 2 #XXX: We only do coordinate, normal1 and uv1
mesh.vertexBuffers = [coordinateBuffer, normal1Buffer]
# Create submeshes
indexArrays = {} # material_index: index array
lastMaterialIndex = None
for polygon in me.polygons:
if polygon.material_index != lastMaterialIndex:
indexArrays[polygon.material_index] = []
indexArrays[polygon.material_index] += polygon.vertices
lastMaterialIndex = polygon.material_index
submeshes = []
for materialID, indexArray in indexArrays.items():
submesh = A3DObjects.A3DSubmesh()
submesh.indexCount = len(indexArray)
submesh.indices = indexArray
submesh.materialID = materialID
submesh.smoothingGroups = [0] * (len(indexArray)//3) # Just set all faces to 0
submeshes.append(submesh)
mesh.submeshCount = len(submeshes)
mesh.submeshes = submeshes
return mesh

View File

@@ -20,7 +20,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
''' '''
from .IOTools import unpackStream, readNullTerminatedString, readLengthPrefixedString, calculatePadding from .IOTools import unpackStream, packStream, readNullTerminatedString, writeNullTerminatedString, readLengthPrefixedString, calculatePadding
class A3DMaterial: class A3DMaterial:
def __init__(self): def __init__(self):
@@ -35,6 +35,12 @@ class A3DMaterial:
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]") print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
def write2(self, stream):
writeNullTerminatedString(stream, self.name)
colorR, colorG, colorB = self.color
packStream("<3f", stream, colorR, colorG, colorB)
writeNullTerminatedString(stream, self.diffuseMap)
def read3(self, stream): def read3(self, stream):
self.name = readLengthPrefixedString(stream) self.name = readLengthPrefixedString(stream)
self.color = unpackStream("<3f", stream) self.color = unpackStream("<3f", stream)
@@ -70,7 +76,16 @@ class A3DMesh:
self.submeshes.append(submesh) self.submeshes.append(submesh)
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]") print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
def write2(self, stream):
packStream("<2I", stream, self.vertexCount, self.vertexBufferCount)
for vertexBuffer in self.vertexBuffers:
vertexBuffer.write2(stream)
packStream("<I", stream, self.submeshCount)
for submesh in self.submeshes:
submesh.write2(stream)
def read3(self, stream): def read3(self, stream):
# Read mesh info # Read mesh info
self.name = readLengthPrefixedString(stream) self.name = readLengthPrefixedString(stream)
@@ -126,6 +141,12 @@ class A3DVertexBuffer:
print(f"[A3DVertexBuffer data: {len(self.data)} buffer type: {self.bufferType}]") print(f"[A3DVertexBuffer data: {len(self.data)} buffer type: {self.bufferType}]")
def write2(self, stream):
packStream("<I", stream, self.bufferType)
for vertex in self.data:
for vertexElement in vertex:
packStream("<f", stream, vertexElement)
class A3DSubmesh: class A3DSubmesh:
def __init__(self): def __init__(self):
self.indices = [] self.indices = []
@@ -135,14 +156,23 @@ class A3DSubmesh:
self.indexCount = 0 self.indexCount = 0
def read2(self, stream): def read2(self, stream):
self.indexCount, = unpackStream("<I", stream) # This is just the face count so multiply it by 3 faceCount, = unpackStream("<I", stream)
self.indexCount *= 3 self.indexCount = faceCount * 3
self.indices = list(unpackStream(f"<{self.indexCount}H", stream)) self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
self.smoothingGroups = list(unpackStream(f"<{self.indexCount//3}I", stream)) self.smoothingGroups = list(unpackStream(f"<{self.indexCount//3}I", stream))
self.materialID, = unpackStream("<H", stream) self.materialID, = unpackStream("<H", stream)
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]") print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
def write2(self, stream):
faceCount = self.indexCount // 3
packStream("<I", stream, faceCount)
for index in self.indices:
packStream("<H", stream, index)
for smoothingGroup in self.smoothingGroups:
packStream("<I", stream, smoothingGroup)
packStream("<H", stream, self.materialID)
def read3(self, stream): def read3(self, stream):
# Read indices # Read indices
self.indexCount, = unpackStream("<I", stream) self.indexCount, = unpackStream("<I", stream)
@@ -168,6 +198,14 @@ class A3DTransform:
print(f"[A3DTransform position: {self.position} rotation: {self.rotation} scale: {self.scale}]") print(f"[A3DTransform position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
def write2(self, stream):
positionX, positionY, positionZ = self.position
packStream("<3f", stream, positionX, positionY, positionZ)
rotationX, rotationY, rotationZ, rotationW = self.rotation
packStream("<4f", stream, rotationX, rotationY, rotationZ, rotationW)
scaleX, scaleY, scaleZ = self.scale
packStream("<3f", stream, scaleX, scaleY, scaleZ)
def read3(self, stream): def read3(self, stream):
self.name = readLengthPrefixedString(stream) self.name = readLengthPrefixedString(stream)
self.position = unpackStream("<3f", stream) self.position = unpackStream("<3f", stream)
@@ -191,6 +229,10 @@ class A3DObject:
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]") print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
def write2(self, stream):
writeNullTerminatedString(stream, self.name)
packStream("<2I", stream, self.meshID, self.transformID)
def read3(self, stream): def read3(self, stream):
self.meshID, self.transformID, self.materialCount = unpackStream("<3I", stream) self.meshID, self.transformID, self.materialCount = unpackStream("<3I", stream)

View File

@@ -20,20 +20,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
''' '''
from struct import unpack, calcsize from struct import unpack, pack, calcsize
def unpackStream(format, stream): def unpackStream(format, stream):
size = calcsize(format) size = calcsize(format)
data = stream.read(size) data = stream.read(size)
return unpack(format, data) return unpack(format, data)
def packStream(format, stream, *data):
packedData = pack(format, *data)
stream.write(packedData)
def readNullTerminatedString(stream): def readNullTerminatedString(stream):
string = b"" string = b""
char = stream.read(1) char = stream.read(1)
while char != b"\x00": while char != b"\x00":
string += char string += char
char = stream.read(1) char = stream.read(1)
return string.decode("utf8", errors="ignore") return string.decode("utf-8", errors="ignore")
def writeNullTerminatedString(stream, string):
string = string.encode("utf-8")
stream.write(string)
stream.write(b"\x00")
def calculatePadding(length): def calculatePadding(length):
# (it basically works with rounding) # (it basically works with rounding)

View File

@@ -23,10 +23,11 @@ SOFTWARE.
import bpy import bpy
from bpy.types import Operator, OperatorFileListElement, AddonPreferences from bpy.types import Operator, OperatorFileListElement, AddonPreferences
from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper, ExportHelper
from .A3D import A3D from .A3D import A3D
from .A3DBlenderImporter import A3DBlenderImporter from .A3DBlenderImporter import A3DBlenderImporter
from .A3DBlenderExporter import A3DBlenderExporter
from .BattleMap import BattleMap from .BattleMap import BattleMap
from .BattleMapBlenderImporter import BattleMapBlenderImporter from .BattleMapBlenderImporter import BattleMapBlenderImporter
from .LightmapData import LightmapData from .LightmapData import LightmapData
@@ -99,6 +100,34 @@ class ImportA3D(Operator, ImportHelper):
return {"FINISHED"} return {"FINISHED"}
class ExportA3D(Operator, ExportHelper):
bl_idname = "export_scene.alternativa"
bl_label = "Export A3D"
bl_description = "Export an A3D model"
bl_options = {'PRESET', 'UNDO'}
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
filename_ext: StringProperty(default=".a3d", options={'HIDDEN'})
def draw(self, context):
pass
def invoke(self, context, event):
return ExportHelper.invoke(self, context, event)
def execute(self, context):
print(f"Exporting blender data to {self.filepath}")
modelData = A3D()
modelExporter = A3DBlenderExporter(modelData, bpy.context.selected_objects)
modelExporter.exportData()
# Write file
with open(self.filepath, "wb") as file:
modelData.write(file, version=2)
return {"FINISHED"}
class ImportBattleMap(Operator, ImportHelper): class ImportBattleMap(Operator, ImportHelper):
bl_idname = "import_scene.tanki_battlemap" bl_idname = "import_scene.tanki_battlemap"
bl_label = "Import map" bl_label = "Import map"
@@ -180,6 +209,9 @@ def import_panel_options_battlemap(layout, operator):
def menu_func_import_a3d(self, context): def menu_func_import_a3d(self, context):
self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)") self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
def menu_func_export_a3d(self, context):
self.layout.operator(ExportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
def menu_func_import_battlemap(self, context): def menu_func_import_battlemap(self, context):
self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)") self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)")
@@ -189,6 +221,7 @@ Registration
classes = [ classes = [
Preferences, Preferences,
ImportA3D, ImportA3D,
ExportA3D,
ImportBattleMap ImportBattleMap
] ]
@@ -196,12 +229,14 @@ def register():
for c in classes: for c in classes:
bpy.utils.register_class(c) bpy.utils.register_class(c)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d) bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d)
bpy.types.TOPBAR_MT_file_export.append(menu_func_export_a3d)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap) bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap)
def unregister(): def unregister():
for c in classes: for c in classes:
bpy.utils.unregister_class(c) bpy.utils.unregister_class(c)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d)
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export_a3d)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap)
if __name__ == "__main__": if __name__ == "__main__":