16 Commits

Author SHA1 Message Date
Pyogenics
542a4c9298 Hacky models.a3d support 2025-10-07 21:16:30 +01:00
Pyogenics
b07596676d Add notice for the new content guidelines 2025-07-13 18:14:42 +00:00
Pyogenics
4ac8ff0d71 Merge initial a3d export support
Initial a3d export support
2025-06-01 12:41:36 +00:00
Pyogenics
03501ad56f Fix bug where only lowercase model extensions were being detected 2025-05-31 12:22:16 +01:00
Pyogenics
ec3c89f8b0 Add Parma demo 2025-05-30 12:56:42 +01:00
Pyogenics
6673aab38c Avoid key error when loading prop data 2025-05-30 11:34:36 +01:00
Pyogenics
7283d77f48 Export flip UV y component 2025-05-21 18:38:18 +01:00
Pyogenics
3655a0c79d Handle transforms, parents and objects better 2025-05-21 18:18:14 +01:00
Pyogenics
c2580a4273 Import orphaned transforms 2025-05-01 21:29:45 +01:00
Pyogenics
655e8247d0 Fixed bug where A3D mesh names were not being set on export 2025-04-30 19:55:44 +01:00
Pyogenics
1a6c48a3a4 Add version selection to export options 2025-04-30 19:14:42 +01:00
Pyogenics
56a55c8516 Initial version 3 export support 2025-04-30 18:45:17 +01:00
Pyogenics
437d9f079c UV export 2025-04-28 11:28:40 +01:00
Pyogenics
0e06e47f6e Correct parent behaviour for both import and export on version 2 models 2025-04-27 20:23:45 +01:00
Pyogenics
6ef3f6e889 Initial version 2 export support 2025-04-27 15:54:35 +01:00
Pyogenics
b67cee3756 Update readme with new demo images and BattleMap support 2025-04-11 18:47:40 +01:00
16 changed files with 654 additions and 89 deletions

View File

@@ -1,11 +1,13 @@
# WIP io_scene_a3d
Blender plugin to import the proprietary model format `A3D` used by the game [Tanki Online](https://tankionline.com/en/) from [Alternativa Games](https://alternativa.games/), it is not compatible with older the formats used by the flash based Alternativa3D engine (see [this plugin by Davide Jones](https://github.com/davidejones/alternativa3d_tools) instead).
# io_scene_a3d
Blender plugin to import the proprietary model format `A3D` used by the game [Tanki Online](https://tankionline.com/en/) from [Alternativa Games](https://alternativa.games/), it is not compatible with older the formats used by the flash based Alternativa3D engine (see [this plugin by Davide Jones](https://github.com/davidejones/alternativa3d_tools) instead). The plugin can also import Tanki Online binary format maps: `map.bin`, both legacy maps and remaster maps work.
## File format
Check the wiki for file format documentation.
## Legal
Any ripped assets are subject to the Tanki Online [Fan Content Guidelines](https://en.tankiwiki.com/Creating_Fan_Content_Guide) and must only be used for producing fan content like fan art.
> Using original models, maps, or other in-game assets outside the scope of Tanki Online gameplay or fan art is not allowed.
## Installation
### Requirments: Blender version 4.2+
### Requirements: Blender version 4.2+
### Optional: io_scene_3ds plugin for importing legacy maps (non remaster)
Firstly download the repository by clicking the "Code" button and then "Download ZIP".<br>
![step1](./images/step1.png)<br>
@@ -15,50 +17,34 @@ In blender, go to Edit > Preferences and click the "add-ons" button. From there
Select the zip folder you downloaded and you should be good to go.
## Demo
![A3D models used in a blender scene ready for render](./images/demo1.png)<br>
![UV and material surface showcase](./images/demo2.png)<br>
![Terrain mesh example](./images/demo3.png)
## Showcase
![Demonstration showing textured tank hull models](./images/demo1.png)
![Demonstration showing models related to particle effects](./images/demo2.png)
![Demonstration showing remaster map importing](./images/demo3.png)
![Demonstration showing legacy map collision meshes](./images/demo4.png)
## Status
### Work in progress, the project is mostly complete for readonly file access.
### A3D1
No support, I have never seen one of these files and 99.999% of people will only be using A3D2 and A3D3 files so there isn't much point supporting them.
### A3D2
Full readonly support, not all data is imported into blender.
#### Import
- [x] Materials
- diffuse map data is not used by the plugin because it references files that are only accessible if you work at Alternativa Games (such as texture `.psd` source files)
- [x] Meshes
- - [x] Submesh data
- - [x] Coordinates
- - [ ] Normals (data not imported into blender)
- - [x] UVs
- - [ ] Vertex colour (data not imported into blender, not very useful anyway)
- - [ ] Smoothing groups
- [x] Transform
- [x] Object data
#### Export
- [ ] Materials
- [ ] Meshes
- [ ] Transfoms
- [ ] Objects
### A3D3
Full readonly support, not all data is imported into blender.
#### Import
- [x] Materials
- diffuse map data is not used by the plugin because it references files that are only accessible if you work at Alternativa Games (such as texture `.psd` source files)
- [x] Meshes
- - [x] Submesh data
- - [x] Coordinates
- - [ ] Normals (data not imported into blender)
- - [x] UVs
- - [ ] Vertex colour (data not imported into blender, not very useful anyway)
- - [ ] Boundbox (data not imported into blender, blender calculates its own boundbox data)
- [x] Transforms
- [x] Objects
#### Export
- [ ] Materials
- [ ] Meshes
- [ ] Transfoms
- [ ] Objects
### .a3d
The plugin only supports importing models and supports loading the majority of A3D data:
- Materials (color data imported but diffuse map is ignored as it is usually empty or references files that are not available to players)
- Mesh data (vertex positions, normals and UV channels)
- Material indices (each mesh can have multiple materials applied to it)
- Object data (object hierarchy/parents, object names)
- Transform data (object position, scale, rotation)
The plugin only supports version 2 (map props) and version 3 (tank models) files, version 1 is not implemented because it is not currently used in game and I have never seen one of these files before.
### map.bin
The plugin can load Remaster and Legacy maps, legacy maps have incorrect transforms on some props due to the `.3ds` file plugin, not all data is required to import the files into blender, currently supported data is:
- Static geometry (the visual aspect of the map)
- Collision geometry (the collisions of the map)
- Spawnpoints (where tanks spawn)
The plugin also supports `lightmapdata` files that come with remaster maps, these files provide information about the lighting of the map:
- Sun angle and colour
- Ambient light colour
- Object shadow settings (can the object recieve or cast shadows)
- Lightmap UV coordinates (not imported)
- Lightmaps (not imported)
- Lightprobes (not imported)
## File format
Check the wiki for file format documentation.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 KiB

View File

@@ -20,7 +20,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from .IOTools import unpackStream, readNullTerminatedString, calculatePadding
from io import BytesIO
from .IOTools import unpackStream, packStream, readNullTerminatedString, calculatePadding
from . import A3DObjects
'''
@@ -65,6 +67,23 @@ class A3D:
self.readRootBlock2(stream)
elif self.version == 3:
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
@@ -72,6 +91,9 @@ class A3D:
def readRootBlock1(self, stream):
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):
# Verify signature
signature, _ = unpackStream("<2I", stream)
@@ -85,6 +107,21 @@ class A3D:
self.readTransformBlock2(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):
# Verify signature
signature, length = unpackStream("<2I", stream)
@@ -101,6 +138,21 @@ class A3D:
padding = calculatePadding(length)
stream.read(padding)
def writeRootBlock3(self, stream):
buffer = BytesIO()
# Write data to the buffer
print("Writing root block")
self.writeMaterialBlock3(buffer)
self.writeMeshBlock3(buffer)
self.writeTransformBlock3(buffer)
self.writeObjectBlock3(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_ROOTBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
'''
Material data blocks
'''
@@ -117,6 +169,20 @@ class A3D:
material.read2(stream)
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):
# Verify signature
signature, length, materialCount = unpackStream("<3I", stream)
@@ -134,6 +200,24 @@ class A3D:
padding = calculatePadding(length)
stream.read(padding)
def writeMaterialBlock3(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.write3(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_MATERIALBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
# Padding
paddingSize = calculatePadding(buffer.tell())
stream.write(b"\x00" * paddingSize)
'''
Mesh data blocks
'''
@@ -150,6 +234,20 @@ class A3D:
mesh.read2(stream)
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):
# Verify signature
signature, length, meshCount = unpackStream("<3I", stream)
@@ -167,6 +265,24 @@ class A3D:
padding = calculatePadding(length)
stream.read(padding)
def writeMeshBlock3(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.write3(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_MESHBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
# Padding
paddingSize = calculatePadding(buffer.tell())
stream.write(b"\x00" * paddingSize)
'''
Transform data blocks
'''
@@ -187,6 +303,22 @@ class A3D:
parentID, = unpackStream("<i", stream)
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):
# Verify signature
signature, length, transformCount = unpackStream("<3I", stream)
@@ -195,7 +327,6 @@ class A3D:
# Read data
print(f"Reading transform block with {transformCount} transforms and length {length}")
transforms = []
for _ in range(transformCount):
transform = A3DObjects.A3DTransform()
transform.read3(stream)
@@ -209,6 +340,26 @@ class A3D:
padding = calculatePadding(length)
stream.read(padding)
def writeTransformBlock3(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.write3(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())
# Padding
paddingSize = calculatePadding(buffer.tell())
stream.write(b"\x00" * paddingSize)
'''
Object data blocks
'''
@@ -225,6 +376,20 @@ class A3D:
objec.read2(stream)
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):
# Verify signature
signature, length, objectCount = unpackStream("<3I", stream)
@@ -241,3 +406,21 @@ class A3D:
# Padding
padding = calculatePadding(length)
stream.read(padding)
def writeObjectBlock3(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.write3(buffer)
# Write buffer to stream
packStream("<2I", stream, A3D_OBJECTBLOCK_SIGNATURE, buffer.tell())
buffer.seek(0, 0)
stream.write(buffer.read())
# Padding
paddingSize = calculatePadding(buffer.tell())
stream.write(b"\x00" * paddingSize)

View File

@@ -0,0 +1,181 @@
'''
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
)
def mirrorUVY(uv):
x, y = uv
return (x, 1-y)
class A3DBlenderExporter:
def __init__(self, modelData, objects, version=2):
self.modelData = modelData
self.objects = objects
self.version = version
def exportData(self):
print("Exporting blender data to A3D")
# Process objects
materials = {}
meshes = []
transforms = {}
objects = []
for ob in self.objects:
me = ob.data
if me == None:
# Create a transform for this object without the object itself
transform = A3DObjects.A3DTransform()
transform.position = ob.location
rotationW, rotationX, rotationY, rotationZ = ob.rotation_quaternion
transform.rotation = (rotationX, rotationY, rotationZ, rotationW)
transform.scale = ob.scale
transform.name = ob.name
transforms[ob.name] = transform
continue
# 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, ob)
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
transform.name = ob.name
transforms[ob.name] = transform
# Create object
objec = A3DObjects.A3DObject()
objec.name = ob.name
objec.meshID = len(meshes) - 1
objec.transformID = len(transforms) - 1
materialIDs = []
for ma in me.materials:
materialID = list(materials.keys()).index(ma.name)
materialIDs.append(materialID)
objec.materialCount = len(materialIDs)
objec.materialIDs = materialIDs
objects.append(objec)
# Create parentIDs
transformParentIDs = []
for ob in self.objects:
parentOB = ob.parent
if (parentOB == None) or (parentOB.name not in transforms):
if self.version < 3:
transformParentIDs.append(0)
else:
transformParentIDs.append(-1)
else:
parentIndex = list(transforms.keys()).index(parentOB.name)
if self.version < 3:
parentIndex += 1 # Version 2 uses 0 to signify empty parent
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, ob):
mesh = A3DObjects.A3DMesh()
mesh.name = me.name
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]
uv1Vertices = [(0.0, 0.0)] * mesh.vertexCount
for polygon in me.polygons:
i0, i1, i2 = polygon.vertices
uv1Vertices[i0] = mirrorUVY(uv1Data.uv[polygon.loop_start].vector)
uv1Vertices[i1] = mirrorUVY(uv1Data.uv[polygon.loop_start+1].vector)
uv1Vertices[i2] = mirrorUVY(uv1Data.uv[polygon.loop_start+2].vector)
uv1Buffer.data = uv1Vertices
normal2Buffer = A3DObjects.A3DVertexBuffer()
normal2Buffer.bufferType = A3D_VERTEXTYPE_NORMAL2
normal2Buffer.data = normal1Buffer.data
mesh.vertexBufferCount = 3 #XXX: We only do coordinate, normal1 and uv1
mesh.vertexBuffers = [coordinateBuffer, uv1Buffer, 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
# Bound box data
bounds = []
for bound in ob.bound_box:
x, y, z = bound
bounds.append((x, y, z))
mesh.bboxMax = max(bounds)
mesh.bboxMin = min(bounds)
return mesh

View File

@@ -64,21 +64,29 @@ class A3DBlenderImporter:
# Create objects
objects = []
for transformID, transformData in enumerate(self.modelData.transforms):
# Find out if this transform is used by an object
ob = None
for objectData in self.modelData.objects:
if objectData.transformID == transformID:
ob = self.buildBlenderObject(objectData)
break
# Empty transform, create an empty object to represent it
if ob == None:
ob = self.buildBlenderEmptyObject(transformData)
objects.append(ob)
# Assign object parents and link to collection
for obI, ob in enumerate(objects):
# Assign parents
parentID = self.modelData.transformParentIDs[obI]
if parentID == 0 and self.modelData.version < 3:
# version 2 models use 0 to signify empty parent
for objectID, parentID in enumerate(self.modelData.transformParentIDs):
if self.modelData.version < 3:
# version 2 models use 0 to signify empty parent so everything is shifted up
parentID -= 1
if parentID == -1:
continue
elif parentID == -1:
# version 3 models use -1 to signify empty parent
continue
parentOB = objects[parentID]
ob.parent = parentOB
ob = objects[objectID]
ob.parent = objects[parentID]
return objects
@@ -237,3 +245,20 @@ class A3DBlenderImporter:
addImageTextureToMaterial(image, ma.node_tree)
return ob
def buildBlenderEmptyObject(self, transformData):
# Create the object
ob = bpy.data.objects.new(transformData.name, None)
ob.empty_display_size = 10 # Assume that the model is in alternativa scale (x100)
# Set transform
ob.location = transformData.position
ob.scale = transformData.scale
ob.rotation_mode = "QUATERNION"
x, y, z, w = transformData.rotation
ob.rotation_quaternion = (w, x, y, z)
if self.reset_empty_transform:
if transformData.scale == (0.0, 0.0, 0.0): ob.scale = (1.0, 1.0, 1.0)
if transformData.rotation == (0.0, 0.0, 0.0, 0.0): ob.rotation_quaternion = (1.0, 0.0, 0.0, 0.0)
return ob

View File

@@ -20,7 +20,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from .IOTools import unpackStream, readNullTerminatedString, readLengthPrefixedString, calculatePadding
from .IOTools import unpackStream, packStream, readNullTerminatedString, writeNullTerminatedString, readLengthPrefixedString, writeLengthPrefixedString, calculatePadding
class A3DMaterial:
def __init__(self):
@@ -35,6 +35,12 @@ class A3DMaterial:
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):
self.name = readLengthPrefixedString(stream)
self.color = unpackStream("<3f", stream)
@@ -42,6 +48,12 @@ class A3DMaterial:
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
def write3(self, stream):
writeLengthPrefixedString(stream, self.name)
colorR, colorG, colorB = self.color
packStream("<3f", stream, colorR, colorG, colorB)
writeLengthPrefixedString(stream, self.diffuseMap)
class A3DMesh:
def __init__(self):
self.name = ""
@@ -71,6 +83,15 @@ class A3DMesh:
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):
# Read mesh info
self.name = readLengthPrefixedString(stream)
@@ -95,6 +116,24 @@ class A3DMesh:
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
def write3(self, stream):
writeLengthPrefixedString(stream, self.name)
bboxMaxX, bboxMaxY, bboxMaxZ = self.bboxMax
packStream("<3f", stream, bboxMaxX, bboxMaxY, bboxMaxZ)
bboxMinX, bboxMinY, bboxMinZ = self.bboxMin
packStream("<3f", stream, bboxMinX, bboxMinY, bboxMinZ)
packStream("<f", stream, 0.0) # XXX: Unknown float value!
# Write vertex buffers
packStream("<2I", stream, self.vertexCount, self.vertexBufferCount)
for vertexBuffer in self.vertexBuffers:
vertexBuffer.write2(stream)
# Write submeshes
packStream("<I", stream, self.submeshCount)
for submesh in self.submeshes:
submesh.write3(stream)
A3D_VERTEXTYPE_COORDINATE = 1
A3D_VERTEXTYPE_UV1 = 2
A3D_VERTEXTYPE_NORMAL1 = 3
@@ -126,6 +165,12 @@ class A3DVertexBuffer:
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:
def __init__(self):
self.indices = []
@@ -135,25 +180,43 @@ class A3DSubmesh:
self.indexCount = 0
def read2(self, stream):
self.indexCount, = unpackStream("<I", stream) # This is just the face count so multiply it by 3
self.indexCount *= 3
faceCount, = unpackStream("<I", stream)
self.indexCount = faceCount * 3
self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
self.smoothingGroups = list(unpackStream(f"<{self.indexCount//3}I", stream))
self.materialID, = unpackStream("<H", stream)
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):
# Read indices
self.indexCount, = unpackStream("<I", stream)
self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
# Padding
padding = calculatePadding(self.indexCount*2) # Each index is 2 bytes
stream.read(padding)
paddingSize = calculatePadding(self.indexCount*2) # Each index is 2 bytes
stream.read(paddingSize)
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
def write3(self, stream):
packStream("<I", stream, self.indexCount)
for index in self.indices:
packStream("<H", stream, index)
# Padding
paddingSize = calculatePadding(self.indexCount*2) # Each index is 2 bytes
stream.write(b"\x00" * paddingSize)
class A3DTransform:
def __init__(self):
self.name = ""
@@ -168,6 +231,14 @@ class A3DTransform:
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):
self.name = readLengthPrefixedString(stream)
self.position = unpackStream("<3f", stream)
@@ -176,6 +247,15 @@ class A3DTransform:
print(f"[A3DTransform name: {self.name} position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
def write3(self, stream):
writeLengthPrefixedString(stream, self.name)
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)
class A3DObject:
def __init__(self):
self.name = ""
@@ -191,6 +271,10 @@ class A3DObject:
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):
self.meshID, self.transformID, self.materialCount = unpackStream("<3I", stream)
@@ -200,3 +284,8 @@ class A3DObject:
self.materialIDs.append(materialID)
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
def write3(self, stream):
packStream("<3I", stream, self.meshID, self.transformID, self.materialCount)
for materialID in self.materialIDs:
packStream("<i", stream, materialID)

View File

@@ -36,8 +36,9 @@ class BattleMapBlenderImporter:
# Allows subsequent map loads to be faster
libraryCache = {}
def __init__(self, mapData, lightmapData, propLibrarySourcePath, map_scale_factor=0.01, import_static_geom=True, import_collision_geom=False, import_spawn_points=False, import_lightmapdata=False):
def __init__(self, mapData, mapDirectory, lightmapData, propLibrarySourcePath, map_scale_factor=0.01, import_static_geom=True, import_collision_geom=False, import_spawn_points=False, import_lightmapdata=False):
self.mapData = mapData
self.mapDirectory = mapDirectory
self.lightmapData = lightmapData
self.propLibrarySourcePath = propLibrarySourcePath
self.map_scale_factor = map_scale_factor
@@ -51,6 +52,7 @@ class BattleMapBlenderImporter:
self.collisionBoxMesh = None
self.materials = {}
self.modelsA3D = {}
def importData(self):
print("Importing BattleMap data into blender")
@@ -172,8 +174,11 @@ class BattleMapBlenderImporter:
def tryLoadTexture(self, textureName, libraryName):
if libraryName == None:
# For some reason Remaster proplib is alwaus marked as None? This is not true for the ny2024 remaster prop lib though
# For some reason Remaster proplib is always marked as None? This is not true for the ny2024 remaster prop lib though
libraryName = "Remaster"
elif libraryName == "":
# This is only true for a material that is using the atlas in case of models.a3d
return None
propLibrary = self.getPropLibrary(libraryName)
texture = propLibrary.getTexture(f"{textureName}.webp")
@@ -182,8 +187,31 @@ class BattleMapBlenderImporter:
'''
Blender data builders
'''
def getPropFromModelsA3D(self, propName):
if len(self.modelsA3D) == 0:
# Load models.a3d
modelData = A3D()
try:
with open(f"{self.mapDirectory}/models.a3d", "rb") as f: modelData.read(f)
except: return None
modelImporter = A3DBlenderImporter(modelData, None, reset_empty_transform=False, try_import_textures=False)
modelObjects = modelImporter.importData()
# Create props
for ob in modelObjects:
prop = Prop()
prop.createFromObject(ob)
self.modelsA3D[ob.name] = prop
return self.modelsA3D[propName]
def getBlenderProp(self, propData):
# Load prop
prop = None
if propData.libraryName == "":
# Load prop from models.a3d first, we prefer it over the library where possible
prop = self.getPropFromModelsA3D(propData.name)
if prop == None:
# Load prop through libraries if we can't find it in models.a3d
propLibrary = self.getPropLibrary(propData.libraryName)
prop = propLibrary.getProp(propData.name, propData.groupName)
propOB = prop.mainObject.copy() # We want to use a copy of the prop object
@@ -367,8 +395,8 @@ class PropLibrary:
# Create the prop
prop = Prop()
meshInfo = propInfo["mesh"]
spriteInfo = propInfo["sprite"]
meshInfo = propInfo.get("mesh")
spriteInfo = propInfo.get("sprite")
if meshInfo != None:
modelPath = f"{self.directory}/" + meshInfo["file"]
prop.loadModel(modelPath)
@@ -390,8 +418,12 @@ class Prop:
self.objects = []
self.mainObject = None
def createFromObject(self, ob):
self.objects.append(ob)
self.mainObject = ob
def loadModel(self, modelPath):
fileExtension = modelPath.split(".")[-1]
fileExtension = modelPath.split(".")[-1].lower()
if fileExtension == "a3d":
modelData = A3D()
with open(modelPath, "rb") as file: modelData.read(file)

View File

@@ -20,20 +20,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from struct import unpack, calcsize
from struct import unpack, pack, calcsize
def unpackStream(format, stream):
size = calcsize(format)
data = stream.read(size)
return unpack(format, data)
def packStream(format, stream, *data):
packedData = pack(format, *data)
stream.write(packedData)
def readNullTerminatedString(stream):
string = b""
char = stream.read(1)
while char != b"\x00":
string += char
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):
# (it basically works with rounding)
@@ -48,3 +57,12 @@ def readLengthPrefixedString(stream):
stream.read(paddingSize)
return string.decode("utf8", errors="ignore")
def writeLengthPrefixedString(stream, string):
string = string.encode("utf-8")
packStream("<I", stream, len(string))
stream.write(string)
paddingSize = calculatePadding(len(string))
stream.write(b"\x00" * paddingSize)

View File

@@ -22,11 +22,12 @@ SOFTWARE.
import bpy
from bpy.types import Operator, OperatorFileListElement, AddonPreferences
from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty
from bpy_extras.io_utils import ImportHelper
from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty, EnumProperty
from bpy_extras.io_utils import ImportHelper, ExportHelper
from .A3D import A3D
from .A3DBlenderImporter import A3DBlenderImporter
from .A3DBlenderExporter import A3DBlenderExporter
from .BattleMap import BattleMap
from .BattleMapBlenderImporter import BattleMapBlenderImporter
from .LightmapData import LightmapData
@@ -99,6 +100,44 @@ class ImportA3D(Operator, ImportHelper):
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'})
a3d_version: EnumProperty(
items=(
("2", "A3D2", "Version 2 files are used to store map geometry like props and simple models like drones and particle effects"),
("3", "A3D3", "Version 3 files are used to store tank turret and hull models")
),
description="A3D file version",
default="2",
name="version"
)
def draw(self, context):
export_panel_options_a3d(self.layout, self)
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, version=int(self.a3d_version))
modelExporter.exportData()
# Write file
with open(self.filepath, "wb") as file:
modelData.write(file, version=int(self.a3d_version))
return {"FINISHED"}
class ImportBattleMap(Operator, ImportHelper):
bl_idname = "import_scene.tanki_battlemap"
bl_label = "Import map"
@@ -143,7 +182,7 @@ class ImportBattleMap(Operator, ImportHelper):
preferences = context.preferences.addons[__package__].preferences
if not isdir(preferences.propLibrarySourcePath):
raise RuntimeError("Please set a valid prop library folder in addon properties!")
mapImporter = BattleMapBlenderImporter(mapData, lightmapData, preferences.propLibrarySourcePath, self.map_scale_factor, self.import_static_geom, self.import_collision_geom, self.import_spawn_points, self.import_lightmapdata)
mapImporter = BattleMapBlenderImporter(mapData, self.directory, lightmapData, preferences.propLibrarySourcePath, self.map_scale_factor, self.import_static_geom, self.import_collision_geom, self.import_spawn_points, self.import_lightmapdata)
objects = mapImporter.importData()
# Link objects
@@ -167,6 +206,12 @@ def import_panel_options_a3d(layout, operator):
body.prop(operator, "try_import_textures")
body.prop(operator, "reset_empty_transform")
def export_panel_options_a3d(layout, operator):
header, body = layout.panel("alternativa_import_options", default_closed=False)
header.label(text="Options")
if body:
body.prop(operator, "a3d_version")
def import_panel_options_battlemap(layout, operator):
header, body = layout.panel("tanki_battlemap_import_options", default_closed=False)
header.label(text="Options")
@@ -180,6 +225,9 @@ def import_panel_options_battlemap(layout, operator):
def menu_func_import_a3d(self, context):
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):
self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)")
@@ -189,6 +237,7 @@ Registration
classes = [
Preferences,
ImportA3D,
ExportA3D,
ImportBattleMap
]
@@ -196,12 +245,14 @@ def register():
for c in classes:
bpy.utils.register_class(c)
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)
def unregister():
for c in classes:
bpy.utils.unregister_class(c)
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)
if __name__ == "__main__":