46 Commits

Author SHA1 Message Date
Pyogenics
b8fe36205b Merge map.bin support into main
Support for loading map.bin files
2025-04-11 12:35:34 +00:00
Pyogenics
1fc2856e84 Check if user set a prop library path when importing a BattleMap 2025-04-11 13:24:43 +01:00
Pyogenics
4851dec975 Reuse collision mesh data blocks 2025-04-10 22:38:12 +01:00
Pyogenics
20ed280a9d Add some minor comments, logging and move classes around 2025-04-10 19:18:41 +01:00
Pyogenics
b1794014dc Add option to scale map in import menu 2025-04-10 14:15:44 +01:00
Pyogenics
91ee61d692 Correct material importing, legacy maps actually have correct materials now 2025-04-10 13:48:52 +01:00
Pyogenics
d971ebade3 Make lightmapdata importing optional and fix error where we attempted to load it even when the map didn't have lightmapdata (like legacy maps) 2025-04-10 11:43:05 +01:00
Pyogenics
4866a3ff8a Initial lightmapdata import 2025-04-09 11:30:57 +01:00
Pyogenics
4b2ba7eba1 Refactor Alternativa Protocol implementation 2025-04-07 17:40:15 +01:00
Pyogenics
8a96286bae Initial, broken, support for legacy maps 2025-04-07 13:17:50 +01:00
Pyogenics
5b35a26c6e Tweak map materials 2025-04-06 20:26:28 +01:00
Pyogenics
8a41d7a47a Import normal vertex buffers to correct inaccurate lighting in blender due to bad geometry 2025-04-06 19:47:12 +01:00
Pyogenics
79a6d9d786 Clean up material import abit 2025-04-01 21:27:10 +01:00
Pyogenics
45a32c2ba4 New demo images 2025-03-31 20:50:45 +01:00
Pyogenics
2ce9b3aa75 Sloppy material importing for maps 2025-03-31 20:48:35 +01:00
Pyogenics
bbabbda16f Import collision planes and boxes 2025-03-31 18:54:08 +01:00
Pyogenics
f07e9a58ee Import spawnpoints and some collision geometry 2025-03-30 21:09:38 +01:00
Pyogenics
8141194dc1 Add demo of BattleMap import 2025-03-29 20:02:47 +00:00
Pyogenics
a245b6b1a0 Initial working remaster map imports 2025-03-29 16:08:29 +00:00
Pyogenics
6cfab91dc4 Add initial BattleMap code 2025-03-29 12:43:43 +00:00
Pyogenics
46c8b0ebdf Add more demo images 2025-03-27 16:05:19 +00:00
Pyogenics
1d35ea7b0f Fix bug where we try add materials when there are none 2025-03-26 18:28:04 +00:00
Pyogenics
a4d62b33e7 Allow for importing multiple files simultaneously 2025-03-25 17:26:29 +00:00
Pyogenics
34d40f70a2 Fix incorrect geometry importing, now triangles should be joined together 2025-03-22 21:49:49 +00:00
Pyogenics
f9de035859 Correct transform parent reading and implement parent assignment
Thanks to tubixpvp for pointing out the error with transform parent reading
2025-03-19 14:28:04 +00:00
Pyogenics
ac96886e46 Corrected formatting 2025-01-26 19:32:59 +00:00
Pyogenics
880746a9ce Add status section 2025-01-25 17:54:56 +00:00
Pyogenics
92d66a20d4 Add auto texture import option 2025-01-19 15:50:02 +00:00
Pyogenics
caf3caee50 Add reset empty transforms option 2025-01-19 12:19:27 +00:00
Pyogenics
d3988cd10f Extract blender import code to improve code quality 2025-01-19 12:04:28 +00:00
Pyogenics
db68e3c8f4 Add initial import options 2025-01-18 16:05:27 +00:00
Pyogenics
24dbdaeb1c Remove some debug print statements 2025-01-18 14:36:36 +00:00
Pyogenics
044b7338ad Add zip gitignore, blender outputs plugins as zip 2025-01-18 14:22:52 +00:00
Pyogenics
02a87f4a05 Import UV2 data 2025-01-18 14:21:41 +00:00
Currency
45314b11e8 Update README.md
added the guide
2024-12-12 21:15:42 +01:00
Currency
110c387ec9 instructions 2024-12-12 22:12:53 +02:00
Pyogenics
73fce85791 Flip y UV coordinate to match blender 2024-12-10 21:13:10 +00:00
Pyogenics
67327c7fb5 Added new demo screenshots 2024-12-10 20:53:18 +00:00
Pyogenics
11fdb83627 Ignore invalid strings, some models use them for some reason 2024-12-09 13:53:29 +00:00
Pyogenics
e4e395b6e1 Initial blender importing 2024-12-02 18:19:23 +00:00
Pyogenics
643d23d6e7 Store array count fields instead of relying on len 2024-11-25 14:27:24 +00:00
Pyogenics
696a65e5a2 Add A3D version 3 support 2024-11-24 18:34:10 +00:00
Pyogenics
e8fd653c80 Add new A3D2 reading 2024-11-23 17:39:51 +00:00
Pyogenics
019b0c54e8 Add pycache to gitignore 2024-11-21 20:49:58 +00:00
Pyogenics
eb9e774592 Create fresh plugin using new extension system 2024-11-21 19:05:25 +00:00
Pyogenics
647c665566 Update readme 2024-10-26 23:14:13 +01:00
28 changed files with 2040 additions and 576 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Build and cache
__pycache__/
*.zip
# Editor files
.venv/
.vscode/

View File

@@ -1,7 +1,64 @@
# io_scene_a3d
Blender plugin to load A3D 3.2 and 3.3 models (3.1 not supported), 3.2 is most complete 3.3 is not so complete; this code will eventually be merged into the alternativa3d_tools github repo.
# 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).
The code can read all the A3D3 data but not all of it is imported yet, some material data + transforms + some vertex data and the codebase could use a cleanup.
## File format
Check the wiki for file format documentation.
- https://github.com/davidejones/alternativa3d_tools/issues/9
- https://github.com/davidejones/alternativa3d_tools
## Installation
### Requirments: Blender version 4.2+
Firstly download the repository by clicking the "Code" button and then "Download ZIP".<br>
![step1](./images/step1.png)<br>
In blender, go to Edit > Preferences and click the "add-ons" button. From there click the arrow on the top right and click "Install from disk".<br>
![step2](./images/step2.png)<br>
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)
## 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

BIN
images/demo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
images/demo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/demo3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

BIN
images/demo4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/demo5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/demo6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
images/demo7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
images/demo8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

BIN
images/step1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
images/step2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

243
io_scene_a3d/A3D.py Normal file
View File

@@ -0,0 +1,243 @@
'''
Copyright (c) 2024 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 .IOTools import unpackStream, readNullTerminatedString, calculatePadding
from . import A3DObjects
'''
A3D constants
'''
A3D_SIGNATURE = b"A3D\0"
A3D_ROOTBLOCK_SIGNATURE = 1
A3D_MATERIALBLOCK_SIGNATURE = 4
A3D_MESHBLOCK_SIGNATURE = 2
A3D_TRANSFORMBLOCK_SIGNATURE = 3
A3D_OBJECTBLOCK_SIGNATURE = 5
'''
A3D model object
'''
class A3D:
def __init__(self):
self.version = 0
self.materials = []
self.meshes = []
self.transforms = []
self.transformParentIDs = []
self.objects = []
'''
Main IO
'''
def read(self, stream):
# Check signature
signature = stream.read(4)
if signature != A3D_SIGNATURE:
raise RuntimeError(f"Invalid A3D signature: {signature}")
# Read file version and read version specific data
self.version, _ = unpackStream("<2H", stream) # Likely major.minor version code
print(f"Reading A3D version {self.version}")
if self.version == 1:
self.readRootBlock1(stream)
elif self.version == 2:
self.readRootBlock2(stream)
elif self.version == 3:
self.readRootBlock3(stream)
'''
Root data blocks
'''
def readRootBlock1(self, stream):
raise RuntimeError("Version 1 files are not supported yet")
def readRootBlock2(self, stream):
# Verify signature
signature, _ = unpackStream("<2I", stream)
if signature != A3D_ROOTBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid root data block signature: {signature}")
# Read data
print(f"Reading root block")
self.readMaterialBlock2(stream)
self.readMeshBlock2(stream)
self.readTransformBlock2(stream)
self.readObjectBlock2(stream)
def readRootBlock3(self, stream):
# Verify signature
signature, length = unpackStream("<2I", stream)
if signature != A3D_ROOTBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid root data block signature: {signature}")
# Read data
self.readMaterialBlock3(stream)
self.readMeshBlock3(stream)
self.readTransformBlock3(stream)
self.readObjectBlock3(stream)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Material data blocks
'''
def readMaterialBlock2(self, stream):
# Verify signature
signature, _, materialCount = unpackStream("<3I", stream)
if signature != A3D_MATERIALBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid material data block signature: {signature}")
# Read data
print(f"Reading material block with {materialCount} materials")
for _ in range(materialCount):
material = A3DObjects.A3DMaterial()
material.read2(stream)
self.materials.append(material)
def readMaterialBlock3(self, stream):
# Verify signature
signature, length, materialCount = unpackStream("<3I", stream)
if signature != A3D_MATERIALBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid material data block signature: {signature}")
# Read data
print(f"Reading material block with {materialCount} materials and length {length}")
for _ in range(materialCount):
material = A3DObjects.A3DMaterial()
material.read3(stream)
self.materials.append(material)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Mesh data blocks
'''
def readMeshBlock2(self, stream):
# Verify signature
signature, _, meshCount = unpackStream("<3I", stream)
if signature != A3D_MESHBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid mesh data block signature: {signature}")
# Read data
print(f"Reading mesh block with {meshCount} meshes")
for _ in range(meshCount):
mesh = A3DObjects.A3DMesh()
mesh.read2(stream)
self.meshes.append(mesh)
def readMeshBlock3(self, stream):
# Verify signature
signature, length, meshCount = unpackStream("<3I", stream)
if signature != A3D_MESHBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid mesh data block signature: {signature}")
# Read data
print(f"Reading mesh block with {meshCount} meshes and length {length}")
for _ in range(meshCount):
mesh = A3DObjects.A3DMesh()
mesh.read3(stream)
self.meshes.append(mesh)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Transform data blocks
'''
def readTransformBlock2(self, stream):
# Verify signature
signature, _, transformCount = unpackStream("<3I", stream)
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid transform data block signature: {signature}")
# Read data
print(f"Reading transform block with {transformCount} transforms")
for _ in range(transformCount):
transform = A3DObjects.A3DTransform()
transform.read2(stream)
self.transforms.append(transform)
# Read parent ids
for _ in range(transformCount):
parentID, = unpackStream("<i", stream)
self.transformParentIDs.append(parentID)
def readTransformBlock3(self, stream):
# Verify signature
signature, length, transformCount = unpackStream("<3I", stream)
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid transform data block signature: {signature}")
# Read data
print(f"Reading transform block with {transformCount} transforms and length {length}")
transforms = []
for _ in range(transformCount):
transform = A3DObjects.A3DTransform()
transform.read3(stream)
self.transforms.append(transform)
# Read parent ids
for _ in range(transformCount):
parentID, = unpackStream("<i", stream)
self.transformParentIDs.append(parentID)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Object data blocks
'''
def readObjectBlock2(self, stream):
# Verify signature
signature, _, objectCount = unpackStream("<3I", stream)
if signature != A3D_OBJECTBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid object data block signature: {signature}")
# Read data
print(f"Reading object block with {objectCount} objects")
for _ in range(objectCount):
objec = A3DObjects.A3DObject()
objec.read2(stream)
self.objects.append(objec)
def readObjectBlock3(self, stream):
# Verify signature
signature, length, objectCount = unpackStream("<3I", stream)
if signature != A3D_OBJECTBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid object data block signature: {signature}")
# Read data
print(f"Reading object block with {objectCount} objects and length {length}")
for _ in range(objectCount):
objec = A3DObjects.A3DObject()
objec.read3(stream)
self.objects.append(objec)
# Padding
padding = calculatePadding(length)
stream.read(padding)

View File

@@ -1,40 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.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.
'''
class A3D3Mesh:
def __init__(self, coordinates, uv1, normals, uv2, colors, unknown, submeshes):
# Vertex data
self.coordinates = coordinates
self.uv1 = uv1
self.normals = normals
self.uv2 = uv2
self.colors = colors
self.unknown = unknown
self.submeshes = submeshes
self.faces = [] # Aggregate of all submesh face data, easier for blender importing
# Object data
self.name = ""
self.transform = None
class A3D3Submesh:
def __init__(self, faces, smoothingGroups, material):
self.faces = faces
self.smoothingGroups = smoothingGroups
self.material = material
class A3D3Transform:
def __init__(self, position, rotation, scale, name):
self.position = position
self.rotation = rotation
self.scale = scale
self.name = name
self.parentID = 0

View File

@@ -1,157 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.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 .A3DIOTools import unpackStream, readNullTerminatedString
from .A3D3Shared import A3D3Mesh, A3D3Submesh, A3D3Transform
'''
A3D version 3 type 2
'''
class A3D3_2:
def __init__(self):
# Object data
self.materialNames = [] # Used to lookup names from materialID
self.materials = {}
self.meshes = []
self.transforms = []
'''
IO
'''
def readSubmesh(self, stream):
print("Reading submesh")
faceCount, = unpackStream("<I", stream)
faces = []
for _ in range(faceCount):
face = unpackStream("<3H", stream)
faces.append(face)
smoothGroups = []
for _ in range(faceCount):
smoothGroup, = unpackStream("<I", stream)
smoothGroups.append(smoothGroup)
materialID, = unpackStream("<H", stream)
material = self.materialNames[materialID]
submesh = A3D3Submesh(faces, smoothGroups, material)
return submesh
def readVertices(self, vertexCount, floatCount, stream):
vertices = []
for _ in range(vertexCount):
vertex = unpackStream(f"{floatCount}f", stream)
vertices.append(vertex)
return vertices
def readMaterialBlock(self, stream):
print("Reading material block")
marker, _, materialCount = unpackStream("<3I", stream)
if marker != 4:
raise RuntimeError(f"Invalid material block marker: {marker}")
for _ in range(materialCount):
materialName = readNullTerminatedString(stream)
_ = unpackStream("3f", stream)
diffuseMap = readNullTerminatedString(stream)
self.materialNames.append(materialName)
self.materials[materialName] = diffuseMap
def readMeshBlock(self, stream):
print("Reading mesh block")
marker, _, meshCount = unpackStream("<3I", stream)
if marker != 2:
raise RuntimeError(f"Invalid mesh block marker: {marker}")
for meshI in range(meshCount):
print(f"Reading mesh {meshI}")
# Vertices
coordinates = []
uv1 = []
normals = []
uv2 = []
colors = []
unknown = []
submeshes = []
# Read vertex buffers
vertexCount, vertexBufferCount = unpackStream("<2I", stream)
for vertexBufferI in range(vertexBufferCount):
print(f"Reading vertex buffer {vertexBufferI} with {vertexCount} vertices")
bufferType, = unpackStream("<I", stream)
if bufferType == 1:
coordinates = self.readVertices(vertexCount, 3, stream)
elif bufferType == 2:
uv1 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 3:
normals = self.readVertices(vertexCount, 3, stream)
elif bufferType == 4:
uv2 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 5:
colors = self.readVertices(vertexCount, 4, stream)
elif bufferType == 6:
unknown = self.readVertices(vertexCount, 3, stream)
else:
raise RuntimeError(f"Unknown vertex buffer type {bufferType}")
# Read submeshes
submeshCount, = unpackStream("<I", stream)
for _ in range(submeshCount):
submesh = self.readSubmesh(stream)
submeshes.append(submesh)
mesh = A3D3Mesh(coordinates, uv1, normals, uv2, colors, unknown, submeshes)
mesh.faces += submesh.faces
self.meshes.append(mesh)
def readTransformBlock(self, stream):
print("Reading transform block")
marker, _, transformCount = unpackStream("<3I", stream)
if marker != 3:
raise RuntimeError(f"Invalid transform block marker: {marker}")
for _ in range(transformCount):
position = unpackStream("<3f", stream)
rotation = unpackStream("<4f", stream)
scale = unpackStream("<3f", stream)
transform = A3D3Transform(position, rotation, scale, "")
self.transforms.append(transform)
for transformI in range(transformCount):
transformID, = unpackStream("<I", stream)
self.transforms[transformI].id = transformID
# Heirarchy data
def readObjectBlock(self, stream):
print("Reading object block")
marker, _, objectCount = unpackStream("<3I", stream)
if marker != 5:
raise RuntimeError(f"Invalid object block marker: {marker}")
for _ in range(objectCount):
objectName = readNullTerminatedString(stream)
meshID, transformID = unpackStream("<2I", stream)
self.meshes[meshID].transform = self.transforms[transformID]
self.meshes[meshID].name = objectName
'''
Drivers
'''
def read(self, stream):
print("Reading A3D3 type 2")
self.readMaterialBlock(stream)
self.readMeshBlock(stream)
self.readTransformBlock(stream)
self.readObjectBlock(stream)

View File

@@ -1,157 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.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 .A3DIOTools import unpackStream, readString, calculatePadding
from .A3D3Shared import A3D3Mesh, A3D3Submesh, A3D3Transform
'''
A3D version 3 type 3
'''
class A3D3_3:
def __init__(self):
self.materials = {}
self.materialNames = []
self.meshes = []
self.transforms = []
def readSubmesh(self, stream):
print("Reading submesh")
indexCount, = unpackStream("<I", stream)
faces = []
for _ in range(indexCount//3):
face = unpackStream("<3H", stream)
faces.append(face)
paddingSize = calculatePadding(indexCount*2)
stream.read(paddingSize)
submesh = A3D3Submesh(faces, [], 0) # XXX: Maybe this should be `None` instead of 0?
return submesh
def readVertices(self, vertexCount, floatCount, stream):
vertices = []
for _ in range(vertexCount):
vertex = unpackStream(f"{floatCount}f", stream)
vertices.append(vertex)
return vertices
def readMaterialBlock(self, stream):
print("Reading material block")
marker, _, materialCount = unpackStream("<3I", stream)
if marker != 4:
raise RuntimeError(f"Invalid material block marker: {marker}")
for _ in range(materialCount):
materialName = readString(stream)
floats = unpackStream("<3f", stream)
diffuseMap = readString(stream)
print(f"{materialName} {floats} {diffuseMap}")
self.materialNames.append(materialName)
self.materials[materialName] = diffuseMap
def readMeshBlock(self, stream):
print("Reading mesh block")
marker, _, meshCount = unpackStream("<3I", stream)
if marker != 2:
raise RuntimeError(f"Invalid mesh block marker: {marker}")
for meshI in range(meshCount):
print(f"Reading mesh {meshI} @ {stream.tell()}")
# Vertices
coordinates = []
uv1 = []
normals = []
uv2 = []
colors = []
unknown = []
submeshes = []
meshName = readString(stream)
unknownFloats = unpackStream("7f", stream)
print(f"{meshName} {unknownFloats}")
# Read vertex buffers
vertexCount, vertexBufferCount = unpackStream("<2I", stream)
for vertexBufferI in range(vertexBufferCount):
print(f"Reading vertex buffer {vertexBufferI} with {vertexCount} vertices")
bufferType, = unpackStream("<I", stream)
if bufferType == 1:
coordinates = self.readVertices(vertexCount, 3, stream)
elif bufferType == 2:
uv1 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 3:
normals = self.readVertices(vertexCount, 3, stream)
elif bufferType == 4:
uv2 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 5:
colors = self.readVertices(vertexCount, 4, stream)
elif bufferType == 6:
unknown = self.readVertices(vertexCount, 3, stream)
else:
raise RuntimeError(f"Unknown vertex buffer type {bufferType}")
# Read submeshes
submeshCount, = unpackStream("<I", stream)
for _ in range(submeshCount):
submesh = self.readSubmesh(stream)
submeshes.append(submesh)
mesh = A3D3Mesh(coordinates, uv1, normals, uv2, colors, unknown, submeshes)
mesh.faces += submesh.faces
self.meshes.append(mesh)
def readTransformBlock(self, stream):
print("Reading transform block")
marker, _, transformCount = unpackStream("<3I", stream)
if marker != 3:
raise RuntimeError(f"Invalid transform block marker: {marker}")
for _ in range(transformCount):
name = readString(stream)
position = unpackStream("<3f", stream)
rotation = unpackStream("<4f", stream)
scale = unpackStream("<3f", stream)
print(f"{name} {position} {rotation} {scale}")
transform = A3D3Transform(position, rotation, scale, name)
self.transforms.append(transform)
for transformI in range(transformCount):
transformID, = unpackStream("<I", stream)
self.transforms[transformI].id = transformID
# Heirarchy data
def readObjectBlock(self, stream):
print("Reading object block")
marker, _, objectCount = unpackStream("<3I", stream)
if marker != 5:
raise RuntimeError(f"Invalid object block marker: {marker}")
for _ in range(objectCount):
meshID, transformID, materialCount = unpackStream("<3I", stream)
for materialI in range(materialCount):
materialID, = unpackStream("<i", stream)
if materialID >= 0:
self.meshes[meshID].submeshes[materialI].material = self.materialNames[materialID]
self.meshes[meshID].transform = self.transforms[transformID]
def read(self, stream):
print("Reading A3D3 type 3")
self.readMaterialBlock(stream)
self.readMeshBlock(stream)
self.readTransformBlock(stream)
self.readObjectBlock(stream)

View File

@@ -0,0 +1,239 @@
'''
Copyright (c) 2024 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.
'''
import bpy
from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
from bpy_extras.image_utils import load_image
from .A3DObjects import (
A3D_VERTEXTYPE_COORDINATE,
A3D_VERTEXTYPE_UV1,
A3D_VERTEXTYPE_NORMAL1,
A3D_VERTEXTYPE_UV2,
A3D_VERTEXTYPE_COLOR,
A3D_VERTEXTYPE_NORMAL2
)
from .BlenderMaterialUtils import addImageTextureToMaterial
def mirrorUVY(uv):
x, y = uv
return (x, 1-y)
class A3DBlenderImporter:
def __init__(self, modelData, directory, reset_empty_transform=True, try_import_textures=True):
self.modelData = modelData
self.directory = directory
self.materials = []
self.meshes = []
# User settings
self.reset_empty_transform = reset_empty_transform
self.try_import_textures = try_import_textures
def importData(self):
print("Importing A3D model data into blender")
# Create materials
for materialData in self.modelData.materials:
ma = self.buildBlenderMaterial(materialData)
self.materials.append(ma)
# Build meshes
for meshData in self.modelData.meshes:
me = self.buildBlenderMesh(meshData)
self.meshes.append(me)
# Create objects
objects = []
for objectData in self.modelData.objects:
ob = self.buildBlenderObject(objectData)
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
continue
elif parentID == -1:
# version 3 models use -1 to signify empty parent
continue
parentOB = objects[parentID]
ob.parent = parentOB
return objects
'''
Blender data builders
'''
def buildBlenderMaterial(self, materialData):
ma = bpy.data.materials.new(materialData.name)
maWrapper = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
maWrapper.base_color = materialData.color
maWrapper.roughness = 1.0
return ma
def buildBlenderMesh(self, meshData):
me = bpy.data.meshes.new(meshData.name)
# Gather all vertex data
coordinates = []
uv1 = []
normal1 = []
uv2 = []
colors = []
normal2 = []
for vertexBuffer in meshData.vertexBuffers:
if vertexBuffer.bufferType == A3D_VERTEXTYPE_COORDINATE:
coordinates += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_UV1:
uv1 += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_NORMAL1:
normal1 += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_UV2:
uv2 += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_COLOR:
colors += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_NORMAL2:
normal2 += vertexBuffer.data
# Add blender vertices
blenderCoordinates = []
for coordinate in coordinates:
blenderCoordinates += coordinate # Blender doesn't like tuples
me.vertices.add(len(blenderCoordinates)//3)
me.vertices.foreach_set("co", blenderCoordinates)
# Aggregate submesh data and import
indices = []
for submesh in meshData.submeshes:
indices += submesh.indices
me.loops.add(len(indices))
me.loops.foreach_set("vertex_index", indices)
me.polygons.add(len(indices)//3)
me.polygons.foreach_set("loop_start", range(0, len(indices), 3))
# UVs
if len(uv1) != 0:
uvData = me.uv_layers.new(name="UV1").data
for po in me.polygons:
uvData[po.loop_start].uv = mirrorUVY(uv1[indices[po.loop_start]])
uvData[po.loop_start+1].uv = mirrorUVY(uv1[indices[po.loop_start+1]])
uvData[po.loop_start+2].uv = mirrorUVY(uv1[indices[po.loop_start+2]])
if len(uv2) != 0:
uvData = me.uv_layers.new(name="UV2").data
for po in me.polygons:
uvData[po.loop_start].uv = mirrorUVY(uv2[indices[po.loop_start]])
uvData[po.loop_start+1].uv = mirrorUVY(uv2[indices[po.loop_start+1]])
uvData[po.loop_start+2].uv = mirrorUVY(uv2[indices[po.loop_start+2]])
# Apply materials (version 2)
faceIndexBase = 0
for submeshI, submesh in enumerate(meshData.submeshes):
if submesh.materialID == None or len(self.materials) == 0: #XXX: perhaps try add a material slot to the object so we still make use of the submesh data instead of skipping it when there are no materials?
# if materialID is None then this is a version 3 model submesh
continue
me.materials.append(self.materials[submesh.materialID])
for faceI in range(submesh.indexCount//3):
me.polygons[faceI+faceIndexBase].material_index = submeshI
faceIndexBase += submesh.indexCount//3
#XXX: call this before we assign split normals, if you do not it causes a segmentation fault
me.validate()
# Split normals
if len(normal1) != 0:
me.normals_split_custom_set_from_vertices(normal1)
elif len(normal2) != 0:
me.normals_split_custom_set_from_vertices(normal2)
# Finalise
me.update()
return me
def buildBlenderObject(self, objectData):
me = self.meshes[objectData.meshID]
mesh = self.modelData.meshes[objectData.meshID]
transform = self.modelData.transforms[objectData.transformID]
# Apply materials to mesh (version 3)
for materialID in objectData.materialIDs:
if materialID == -1:
continue
me.materials.append(self.materials[materialID])
# Set the default material to the first one we added
for polygon in me.polygons:
polygon.material_index = 0
# Select a name for the blender object
#XXX: review this, maybe we should just stick to the name we are given
name = ""
if objectData.name != "":
name = objectData.name
elif mesh.name != "":
name = mesh.name
else:
name = transform.name
# Create the object
ob = bpy.data.objects.new(name, me)
# Set transform
ob.location = transform.position
ob.scale = transform.scale
ob.rotation_mode = "QUATERNION"
x, y, z, w = transform.rotation
ob.rotation_quaternion = (w, x, y, z)
if self.reset_empty_transform:
if transform.scale == (0.0, 0.0, 0.0): ob.scale = (1.0, 1.0, 1.0)
if transform.rotation == (0.0, 0.0, 0.0, 0.0): ob.rotation_quaternion = (1.0, 0.0, 0.0, 0.0)
# Attempt to load textures
if self.try_import_textures and len(me.materials) != 0:
ma = me.materials[0] # Assume this is the main material
name = name.lower()
if name == "hull" or name == "turret":
# lightmap.webp
print("Load lightmap")
# Load image
image = load_image("lightmap.webp", self.directory, check_existing=True)
# Apply image
addImageTextureToMaterial(image, ma.node_tree)
elif "track" in name:
# tracks.webp
print("Load tracks")
# Load image
image = load_image("tracks.webp", self.directory, check_existing=True)
# Apply image
addImageTextureToMaterial(image, ma.node_tree)
elif "wheel" in name:
# wheels.webp
print("Load wheels")
# Load image
image = load_image("wheels.webp", self.directory, check_existing=True)
# Apply image
addImageTextureToMaterial(image, ma.node_tree)
return ob

View File

@@ -1,38 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.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 struct import unpack, calcsize
def unpackStream(format, stream):
size = calcsize(format)
data = stream.read(size)
return unpack(format, data)
def readNullTerminatedString(stream):
string = b""
char = stream.read(1)
while char != b"\x00":
string += char
char = stream.read(1)
return string.decode("utf8")
def calculatePadding(length):
# (it basically works with rounding)
paddingSize = (((length + 3) // 4) * 4) - length
return paddingSize
def readString(stream):
length, = unpackStream("<I", stream)
string = stream.read(length)
paddingSize = calculatePadding(length)
stream.read(paddingSize)
return string.decode("utf8")

202
io_scene_a3d/A3DObjects.py Normal file
View File

@@ -0,0 +1,202 @@
'''
Copyright (c) 2024 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 .IOTools import unpackStream, readNullTerminatedString, readLengthPrefixedString, calculatePadding
class A3DMaterial:
def __init__(self):
self.name = ""
self.color = (0.0, 0.0, 0.0)
self.diffuseMap = ""
def read2(self, stream):
self.name = readNullTerminatedString(stream)
self.color = unpackStream("<3f", stream)
self.diffuseMap = readNullTerminatedString(stream)
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
def read3(self, stream):
self.name = readLengthPrefixedString(stream)
self.color = unpackStream("<3f", stream)
self.diffuseMap = readLengthPrefixedString(stream)
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
class A3DMesh:
def __init__(self):
self.name = ""
self.bboxMax = None
self.bboxMin = None
self.vertexBuffers = []
self.submeshes = []
self.vertexCount = 0
self.vertexBufferCount = 0
self.submeshCount = 0
def read2(self, stream):
# Read vertex buffers
self.vertexCount, self.vertexBufferCount = unpackStream("<2I", stream)
for _ in range(self.vertexBufferCount):
vertexBuffer = A3DVertexBuffer()
vertexBuffer.read2(self.vertexCount, stream)
self.vertexBuffers.append(vertexBuffer)
# Read submeshes
self.submeshCount, = unpackStream("<I", stream)
for _ in range(self.submeshCount):
submesh = A3DSubmesh()
submesh.read2(stream)
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)}]")
def read3(self, stream):
# Read mesh info
self.name = readLengthPrefixedString(stream)
# XXX: bbox order maybe incorrect, check this (might be min then max and not max then min)
self.bboxMax = unpackStream("<3f", stream)
self.bboxMin = unpackStream("<3f", stream)
stream.read(4) # XXX: Unknown float value
# Read vertex buffers
self.vertexCount, self.vertexBufferCount = unpackStream("<2I", stream)
for _ in range(self.vertexBufferCount):
vertexBuffer = A3DVertexBuffer()
vertexBuffer.read2(self.vertexCount, stream)
self.vertexBuffers.append(vertexBuffer)
# Read submeshes
self.submeshCount, = unpackStream("<I", stream)
for _ in range(self.submeshCount):
submesh = A3DSubmesh()
submesh.read3(stream)
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)}]")
A3D_VERTEXTYPE_COORDINATE = 1
A3D_VERTEXTYPE_UV1 = 2
A3D_VERTEXTYPE_NORMAL1 = 3
A3D_VERTEXTYPE_UV2 = 4
A3D_VERTEXTYPE_COLOR = 5
A3D_VERTEXTYPE_NORMAL2 = 6
# LUT for vertex buffer types -> vertex size
A3DVertexSize = {
A3D_VERTEXTYPE_COORDINATE: 3,
A3D_VERTEXTYPE_UV1: 2,
A3D_VERTEXTYPE_NORMAL1: 3,
A3D_VERTEXTYPE_UV2: 2,
A3D_VERTEXTYPE_COLOR: 4,
A3D_VERTEXTYPE_NORMAL2: 3
}
class A3DVertexBuffer:
def __init__(self):
self.data = []
self.bufferType = None
def read2(self, vertexCount, stream):
self.bufferType, = unpackStream("<I", stream)
if not (self.bufferType in A3DVertexSize.keys()):
raise RuntimeError(f"Unknown vertex buffer type: {self.bufferType}")
for _ in range(vertexCount):
vertexSize = A3DVertexSize[self.bufferType]
vertex = unpackStream(f"<{vertexSize}f", stream)
self.data.append(vertex)
print(f"[A3DVertexBuffer data: {len(self.data)} buffer type: {self.bufferType}]")
class A3DSubmesh:
def __init__(self):
self.indices = []
self.smoothingGroups = []
self.materialID = None
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
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 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)
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
class A3DTransform:
def __init__(self):
self.name = ""
self.position = (0.0, 0.0, 0.0)
self.rotation = (0.0, 0.0, 0.0, 0.0)
self.scale = (0.0, 0.0, 0.0)
def read2(self, stream):
self.position = unpackStream("<3f", stream)
self.rotation = unpackStream("<4f", stream)
self.scale = unpackStream("<3f", stream)
print(f"[A3DTransform position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
def read3(self, stream):
self.name = readLengthPrefixedString(stream)
self.position = unpackStream("<3f", stream)
self.rotation = unpackStream("<4f", stream)
self.scale = unpackStream("<3f", stream)
print(f"[A3DTransform name: {self.name} position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
class A3DObject:
def __init__(self):
self.name = ""
self.meshID = None
self.transformID = None
self.materialIDs = []
self.materialCount = 0
def read2(self, stream):
self.name = readNullTerminatedString(stream)
self.meshID, self.transformID = unpackStream("<2I", stream)
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
def read3(self, stream):
self.meshID, self.transformID, self.materialCount = unpackStream("<3I", stream)
# Read material IDs
for _ in range(self.materialCount):
materialID, = unpackStream("<i", stream)
self.materialIDs.append(materialID)
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")

View File

@@ -0,0 +1,163 @@
'''
Copyright (c) 2025 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 zlib import decompress
from io import BytesIO
from .IOTools import unpackStream
def unwrapPacket(stream):
print("Unwrapping packet")
# Determine size and compression
packetFlags = int.from_bytes(stream.read(1))
compressedPacket = (packetFlags & 0b01000000) > 0
packetLength = 0
packetLengthType = packetFlags & 0b10000000
if packetLengthType == 0:
# This is a short packet
packetLength = int.from_bytes(stream.read(1))
packetLength += (packetFlags & 0b00111111) << 8 # Part of the length is embedded in the flags field
else:
# This is a long packet
packetLength = int.from_bytes(stream.read(3), "big")
packetLength += (packetFlags & 0b00111111) << 24
# Decompress the packet if needed
packetData = stream.read(packetLength)
if compressedPacket:
print("Decompressing packet")
packetData = decompress(packetData)
return BytesIO(packetData)
def readOptionalMask(stream):
print("Reading optional mask")
optionalMask = []
# Determine mask type (there are multiple length types)
maskFlags = int.from_bytes(stream.read(1))
maskLengthType = maskFlags & 0b10000000
if maskLengthType == 0:
# Short mask: 5 optional bits + upto 3 extra bytes
# First read the integrated optional bits
integratedOptionalBits = maskFlags << 3 # Trim flag bits so we're left with the optionals and some padding bits
for bitI in range(7, 2, -1): #0b11111000 left to right
optional = (integratedOptionalBits & 2**bitI) == 0
optionalMask.append(optional)
# Now read the external bytes
externalByteCount = (maskFlags & 0b01100000) >> 5
externalBytes = stream.read(externalByteCount)
for externalByte in externalBytes:
for bitI in range(7, -1, -1): #0b11111111 left to right
optional = (externalByte & 2**bitI) == 0
optionalMask.append(optional)
else:
# This type of mask encodes an extra length/count field to increase the number of possible optionals significantly
maskLengthType = maskFlags & 0b01000000
externalByteCount = 0
if maskLengthType == 0:
# Medium mask: stores number of bytes used for the optional mask in the last 6 bits of the flags
externalByteCount = maskFlags & 0b00111111
else:
# Long mask: # Medium mask: stores number of bytes used for the optional mask in the last 6 bits of the flags + 2 extra bytes
externalByteCount = (maskFlags & 0b00111111) << 16
externalByteCount += int.from_bytes(stream.read(2), "big")
# Read the external bytes
externalBytes = stream.read(externalByteCount)
for externalByte in externalBytes:
for bitI in range(7, -1, -1): #0b11111111 left to right
optional = (externalByte & 2**bitI) == 0
optionalMask.append(optional)
optionalMask.reverse()
return optionalMask
'''
Array type readers
'''
def readArrayLength(packet):
arrayLength = 0
arrayFlags = int.from_bytes(packet.read(1))
arrayLengthType = arrayFlags & 0b10000000
if arrayLengthType == 0:
# Short array
arrayLength = arrayFlags & 0b01111111
else:
# Long array
arrayLengthType = arrayFlags & 0b01000000
if arrayLengthType == 0:
# Length in last 6 bits of flags + next byte
arrayLength = (arrayFlags & 0b00111111) << 8
arrayLength += int.from_bytes(packet.read(1))
else:
# Length in last 6 bits of flags + next 2 byte
arrayLength = (arrayFlags & 0b00111111) << 16
arrayLength += int.from_bytes(packet.read(2), "big")
return arrayLength
def readObjectArray(packet, objReader, optionalMask):
arrayLength = readArrayLength(packet)
objects = []
for _ in range(arrayLength):
obj = objReader()
obj.read(packet, optionalMask)
objects.append(obj)
return objects
def readString(packet):
stringLength = readArrayLength(packet)
string = packet.read(stringLength)
string = string.decode("utf-8")
return string
def readInt16Array(packet):
arrayLength = readArrayLength(packet)
integers = unpackStream(f"{arrayLength}h", packet)
return list(integers)
def readIntArray(packet):
arrayLength = readArrayLength(packet)
integers = unpackStream(f"{arrayLength}i", packet)
return list(integers)
def readInt64Array(packet):
arrayLength = readArrayLength(packet)
integers = unpackStream(f"{arrayLength}q", packet)
return list(integers)
def readFloatArray(packet):
arrayLength = readArrayLength(packet)
floats = unpackStream(f">{arrayLength}f", packet)
return list(floats)

276
io_scene_a3d/BattleMap.py Normal file
View File

@@ -0,0 +1,276 @@
'''
Copyright (c) 2024 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 .IOTools import unpackStream
from . import AlternativaProtocol
'''
Objects
'''
class AtlasRect:
def __init__(self):
self.height = 0
self.libraryName = ""
self.name = ""
self.width = 0
self.x = 0
self.y = 0
def read(self, stream, optionalMask):
self.height, = unpackStream(">I", stream)
self.libraryName = AlternativaProtocol.readString(stream)
self.name = AlternativaProtocol.readString(stream)
self.width, self.x, self.y = unpackStream(">3I", stream)
class CollisionBox:
def __init__(self):
self.position = (0.0, 0.0, 0.0)
self.rotation = (0.0, 0.0, 0.0)
self.size = (0.0, 0.0, 0.0)
def read(self, stream, optionalMask):
self.position = unpackStream(">3f", stream)
self.rotation = unpackStream(">3f", stream)
self.size = unpackStream(">3f", stream)
class CollisionPlane:
def __init__(self):
self.length = 0.0
self.position = (0.0, 0.0, 0.0)
self.rotation = (0.0, 0.0, 0.0)
self.width = 0.0
def read(self, stream, optionalMask):
self.length, = unpackStream(">d", stream)
self.position = unpackStream(">3f", stream)
self.rotation = unpackStream(">3f", stream)
self.width, = unpackStream(">d", stream)
class CollisionTriangle:
def __init__(self):
self.length = 0.0
self.position = (0.0, 0.0, 0.0)
self.rotation = (0.0, 0.0, 0.0)
self.v0 = (0.0, 0.0, 0.0)
self.v1 = (0.0, 0.0, 0.0)
self.v2 = (0.0, 0.0, 0.0)
def read(self, stream, optionalMask):
self.length, = unpackStream(">d", stream)
self.position = unpackStream(">3f", stream)
self.rotation = unpackStream(">3f", stream)
self.v0 = unpackStream(">3f", stream)
self.v1 = unpackStream(">3f", stream)
self.v2 = unpackStream(">3f", stream)
class ScalarParameter:
def __init__(self):
self.name = ""
self.value = 0.0
def read(self, stream, optionalMask):
self.name = AlternativaProtocol.readString(stream)
self.value, = unpackStream(">f", stream)
class TextureParameter:
def __init__(self):
self.name = ""
self.textureName = ""
# Optional
self.libraryName = None
def read(self, stream, optionalMask):
if optionalMask.pop():
self.libraryName = AlternativaProtocol.readString(stream)
self.name = AlternativaProtocol.readString(stream)
self.textureName = AlternativaProtocol.readString(stream)
class Vector2Parameter:
def __init__(self):
self.name = ""
self.value = (0.0, 0.0)
def __init__(self, stream, optionalMask):
self.name = AlternativaProtocol.readString(stream)
self.value = unpackStream(">2f", stream)
class Vector3Parameter:
def __init__(self):
self.name = ""
self.value = (0.0, 0.0, 0.0)
def __init__(self, stream, optionalMask):
self.name = AlternativaProtocol.readString(stream)
self.value = unpackStream(">3f", stream)
class Vector4Parameter:
def __init__(self):
self.name = ""
self.value = (0.0, 0.0, 0.0, 0.0)
def read(self, stream, optionalMask):
self.name = AlternativaProtocol.readString(stream)
self.value = unpackStream(">4f", stream)
'''
Main objects
'''
class Atlas:
def __init__(self):
self.height = 0
self.name = ""
self.padding = 0
self.rects = []
self.width = 0
def read(self, stream, optionalMask):
self.height, unpackStream(">i", stream)
self.name = AlternativaProtocol.readString(stream)
self.padding = unpackStream(">I", stream)
self.rects = AlternativaProtocol.readObjectArray(stream, AtlasRect, optionalMask)
self.width, = unpackStream(">I", stream)
class Batch:
def __init__(self):
self.materialID = 0
self.name = ""
self.position = (0.0, 0.0, 0.0)
self.propIDs = ""
def read(self, stream, optionalMask):
self.materialID, = unpackStream(">I", stream)
self.name = AlternativaProtocol.readString(stream)
self.position = unpackStream(">3f", stream)
self.propIDs = AlternativaProtocol.readString(stream)
class CollisionGeometry:
def __init__(self):
self.boxes = []
self.planes = []
self.triangles = []
def read(self, stream, optionalMask):
self.boxes = AlternativaProtocol.readObjectArray(stream, CollisionBox, optionalMask)
self.planes = AlternativaProtocol.readObjectArray(stream, CollisionPlane, optionalMask)
self.triangles = AlternativaProtocol.readObjectArray(stream, CollisionTriangle, optionalMask)
class Material:
def __init__(self):
self.ID = 0
self.name = ""
self.shader = ""
self.textureParameters = None
# Optional
self.scalarParameters = None
self.vector2Parameters = None
self.vector3Parameters = None
self.vector4Parameters = None
def read(self, stream, optionalMask):
self.ID, = unpackStream(">I", stream)
self.name = AlternativaProtocol.readString(stream)
if optionalMask.pop():
self.scalarParameters = AlternativaProtocol.readObjectArray(stream, ScalarParameter, optionalMask)
self.shader = AlternativaProtocol.readString(stream)
self.textureParameters = AlternativaProtocol.readObjectArray(stream, TextureParameter, optionalMask)
if optionalMask.pop():
self.vector2Parameters = AlternativaProtocol.readObjectArray(stream, Vector2Parameter, optionalMask)
if optionalMask.pop():
self.vector3Parameters = AlternativaProtocol.readObjectArray(stream, Vector3Parameter, optionalMask)
if optionalMask.pop():
self.vector4Parameters = AlternativaProtocol.readObjectArray(stream, Vector4Parameter, optionalMask)
class SpawnPoint:
def __init__(self):
self.position = (0.0, 0.0, 0.0)
self.rotation = (0.0, 0.0, 0.0)
self.type = 0
def read(self, stream, optionalMask):
self.position = unpackStream(">3f", stream)
self.rotation = unpackStream(">3f", stream)
self.type, = unpackStream(">I", stream)
class Prop:
def __init__(self):
self.ID = 0
self.libraryName = ""
self.materialID = 0
self.name = ""
self.position = (0.0, 0.0, 0.0)
# Optional
self.groupName = None
self.rotation = None
self.scale = None
def read(self, stream, optionalMask):
if optionalMask.pop():
self.groupName = AlternativaProtocol.readString(stream)
self.ID, = unpackStream(">I", stream)
self.libraryName = AlternativaProtocol.readString(stream)
self.materialID, = unpackStream(">I", stream)
self.name = AlternativaProtocol.readString(stream)
self.position = unpackStream(">3f", stream)
if optionalMask.pop():
self.rotation = unpackStream(">3f", stream)
if optionalMask.pop():
self.scale = unpackStream(">3f", stream)
'''
Main
'''
class BattleMap:
def __init__(self):
self.atlases = []
self.batches = []
self.collisionGeometry = None
self.collisionGeometryOutsideGamingZone = None
self.materials = []
self.spawnPoints = []
self.staticGeometry = []
'''
IO
'''
def read(self, stream):
print("Reading BattleMap")
# Read packet
packet = AlternativaProtocol.unwrapPacket(stream)
optionalMask = AlternativaProtocol.readOptionalMask(packet)
# Read data
if optionalMask.pop():
self.atlases = AlternativaProtocol.readObjectArray(packet, Atlas, optionalMask)
if optionalMask.pop():
self.batches = AlternativaProtocol.readObjectArray(packet, Batch, optionalMask)
self.collisionGeometry = CollisionGeometry()
self.collisionGeometry.read(packet, optionalMask)
self.collisionGeometryOutsideGamingZone = CollisionGeometry()
self.collisionGeometryOutsideGamingZone.read(packet, optionalMask)
self.materials = AlternativaProtocol.readObjectArray(packet, Material, optionalMask)
if optionalMask.pop():
self.spawnPoints = AlternativaProtocol.readObjectArray(packet, SpawnPoint, optionalMask)
self.staticGeometry = AlternativaProtocol.readObjectArray(packet, Prop, optionalMask)

View File

@@ -0,0 +1,445 @@
'''
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 json import load
import bpy
from bpy_extras.image_utils import load_image
from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
import bmesh
from mathutils import Matrix
from .A3D import A3D
from .A3DBlenderImporter import A3DBlenderImporter
from .BlenderMaterialUtils import addImageTextureToMaterial, decodeIntColorToTuple
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):
self.mapData = mapData
self.lightmapData = lightmapData
self.propLibrarySourcePath = propLibrarySourcePath
self.map_scale_factor = map_scale_factor
self.import_static_geom = import_static_geom
self.import_collision_geom = import_collision_geom
self.import_spawn_points = import_spawn_points
self.import_lightmapdata = import_lightmapdata
# Cache for collision meshes, don't cache triangles because they are set using unique vertices
self.collisionPlaneMesh = None
self.collisionBoxMesh = None
self.materials = {}
def importData(self):
print("Importing BattleMap data into blender")
# Process materials
for materialData in self.mapData.materials:
ma = self.createBlenderMaterial(materialData)
self.materials[materialData.ID] = ma
# Static geometry
propObjects = []
if self.import_static_geom:
# Load props
for propData in self.mapData.staticGeometry:
ob = self.getBlenderProp(propData)
propObjects.append(ob)
print(f"Loaded {len(propObjects)} prop objects")
# Collision geometry
collisionObjects = []
if self.import_collision_geom:
# Create collision meshes
self.collisionPlaneMesh = bpy.data.meshes.new("collisionPlane")
bm = bmesh.new()
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1.0)
bm.to_mesh(self.collisionPlaneMesh)
bm.free()
self.collisionBoxMesh = bpy.data.meshes.new("collisionBox")
bm = bmesh.new()
bmesh.ops.create_cube(bm)
bm.to_mesh(self.collisionBoxMesh)
bm.free()
# Load collision meshes
collisionTriangles = self.mapData.collisionGeometry.triangles + self.mapData.collisionGeometryOutsideGamingZone.triangles
collisionTriangleObjects = self.createBlenderCollisionTriangles(collisionTriangles)
collisionPlanes = self.mapData.collisionGeometry.planes + self.mapData.collisionGeometryOutsideGamingZone.planes
collisionPlaneObjects = self.createBlenderCollisionPlanes(collisionPlanes)
collisionBoxes = self.mapData.collisionGeometry.boxes + self.mapData.collisionGeometryOutsideGamingZone.boxes
collisionBoxObjects = self.createBlenderCollisionBoxes(collisionBoxes)
collisionObjects += collisionTriangleObjects
collisionObjects += collisionPlaneObjects
collisionObjects += collisionBoxObjects
print(f"Loaded {len(collisionObjects)} collision objects")
# Spawn points
spawnPointObjects = []
if self.import_spawn_points:
# Create spawn points
for spawnPointData in self.mapData.spawnPoints:
ob = self.createBlenderSpawnPoint(spawnPointData)
spawnPointObjects.append(ob)
print(f"Loaded {len(spawnPointObjects)} spawn points")
# Create container object to store all our objects
objects = propObjects + collisionObjects + spawnPointObjects
mapOB = bpy.data.objects.new("BattleMap", None)
mapOB.empty_display_size = 100 # Alternativa use a x100 scale
mapOB.scale = (self.map_scale_factor, self.map_scale_factor, self.map_scale_factor)
objects.append(mapOB)
# Create empty objects to group each type of object
if self.import_static_geom:
groupOB = bpy.data.objects.new("StaticGeometry", None)
groupOB.parent = mapOB
objects.append(groupOB)
for ob in propObjects:
ob.parent = groupOB
if self.import_collision_geom:
groupOB = bpy.data.objects.new("CollisionGeometry", None)
groupOB.parent = mapOB
objects.append(groupOB)
for ob in collisionObjects:
ob.parent = groupOB
if self.import_spawn_points:
groupOB = bpy.data.objects.new("SpawnPoints", None)
groupOB.parent = mapOB
objects.append(groupOB)
for ob in spawnPointObjects:
ob.parent = groupOB
# Lighting data
if self.import_lightmapdata:
# Create a sun light object
li = bpy.data.lights.new("DirectionalLight", "SUN")
li.color = decodeIntColorToTuple(self.lightmapData.lightColour)
ob = bpy.data.objects.new(li.name, li)
ob.location = (0.0, 0.0, 1000.0) # Just place it like 10 meters off the ground (in alternativa units)
lightAngleX, lightAngleZ = self.lightmapData.lightAngle
ob.rotation_mode = "XYZ"
ob.rotation_euler = (lightAngleX, 0.0, lightAngleZ)
ob.parent = mapOB
objects.append(ob)
# Set ambient world light
scene = bpy.context.scene
if scene.world == None:
wd = bpy.data.worlds.new("map")
scene.world = wd
world = scene.world
world.use_nodes = False
world.color = decodeIntColorToTuple(self.lightmapData.ambientLightColour)
return objects
def getPropLibrary(self, libraryName):
# First check if we've already loaded the required prop library
if not libraryName in self.libraryCache:
# Load the proplib
libraryPath = f"{self.propLibrarySourcePath}/{libraryName}"
library = PropLibrary(libraryPath)
self.libraryCache[libraryName] = library
return self.libraryCache[libraryName]
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
libraryName = "Remaster"
propLibrary = self.getPropLibrary(libraryName)
texture = propLibrary.getTexture(f"{textureName}.webp")
return texture
'''
Blender data builders
'''
def getBlenderProp(self, propData):
# Load prop
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
# Assign data
propOB.name = f"{propData.name}_{propData.ID}"
propOB.location = propData.position
propOB.rotation_mode = "XYZ"
propRotation = propData.rotation
if propRotation == None:
propRotation = (0.0, 0.0, 0.0)
propOB.rotation_euler = propRotation
propScale = propData.scale
if propScale == None:
propScale = (1.0, 1.0, 1.0)
propOB.scale = propScale
# Lighting info
if self.import_lightmapdata:
lightingMapObject = None
for mapObject in self.lightmapData.mapObjects:
if mapObject.index == propData.ID:
lightingMapObject = mapObject
break
if lightingMapObject != None:
#XXX: do something with lightingMapObject.recieveShadows??
propOB.visible_shadow = lightingMapObject.castShadows
# Material
ma = self.materials[propData.materialID]
if len(propOB.data.materials) != 0:
# Create a duplicate mesh object if it needs a different material, XXX: could probably cache these to reuse datablocks
if propOB.data.materials[0] != ma:
propOB.data = propOB.data.copy()
propOB.data.materials[0] = ma
return propOB
def createBlenderCollisionTriangles(self, collisionTriangles):
objects = []
for collisionTriangle in collisionTriangles:
# Create the mesh
me = bpy.data.meshes.new("collisionTriangle")
# Create array for coordinate data, blender doesn't like tuples
vertices = []
vertices += collisionTriangle.v0
vertices += collisionTriangle.v1
vertices += collisionTriangle.v2
# Assign coordinates
me.vertices.add(3)
me.vertices.foreach_set("co", vertices)
me.loops.add(3)
me.loops.foreach_set("vertex_index", [0, 1, 2])
me.polygons.add(1)
me.polygons.foreach_set("loop_start", [0])
me.validate()
me.update()
# Create object
ob = bpy.data.objects.new("collisionTriangle", me)
ob.location = collisionTriangle.position
ob.rotation_mode = "XYZ"
ob.rotation_euler = collisionTriangle.rotation
#print(collisionTriangle.length) # XXX: how to handle collisionTriangle.length?
objects.append(ob)
return objects
def createBlenderCollisionPlanes(self, collisionPlanes):
objects = []
for collisionPlane in collisionPlanes:
# Create object
ob = bpy.data.objects.new("collisionPlane", self.collisionPlaneMesh)
ob.location = collisionPlane.position
ob.rotation_mode = "XYZ"
ob.rotation_euler = collisionPlane.rotation
ob.scale = (collisionPlane.width*0.5, collisionPlane.length*0.5, 1.0) # Unsure why they double the width and length, could be because of central origin?
objects.append(ob)
return objects
def createBlenderCollisionBoxes(self, collisionBoxes):
objects = []
for collisionBox in collisionBoxes:
# Create object
ob = bpy.data.objects.new("collisionBox", self.collisionBoxMesh)
ob.location = collisionBox.position
ob.rotation_mode = "XYZ"
ob.rotation_euler = collisionBox.rotation
ob.scale = collisionBox.size
objects.append(ob)
return objects
def createBlenderSpawnPoint(self, spawnPointData):
#TODO: implement spawn type name lookup
ob = bpy.data.objects.new(f"SpawnPoint_{spawnPointData.type}", None)
ob.empty_display_type = "ARROWS"
ob.empty_display_size = 100 # The map will be at 100x scale so it's a good idea to match that here
ob.location = spawnPointData.position
ob.rotation_mode = "XYZ"
ob.rotation_euler = spawnPointData.rotation
return ob
def createBlenderMaterial(self, materialData):
ma = bpy.data.materials.new(f"{materialData.ID}_{materialData.name}")
# Shader specific logic
if materialData.shader == "TankiOnline/SingleTextureShader" or materialData.shader == "TankiOnline/SingleTextureShaderWinter":
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
bsdf.roughness_set(1.0)
bsdf.ior_set(1.0)
# Try load texture
textureParameter = materialData.textureParameters[0]
texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName)
addImageTextureToMaterial(texture, ma.node_tree)
elif materialData.shader == "TankiOnline/SpriteShader":
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
bsdf.roughness_set(1.0)
bsdf.ior_set(1.0)
# Try load texture
textureParameter = materialData.textureParameters[0]
texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName)
addImageTextureToMaterial(texture, ma.node_tree, linkAlpha=True)
elif materialData.shader == "TankiOnline/Terrain":
# XXX: still need to figure out how to do the terrain properly, all manual attempts have yielded mixed results
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
bsdf.roughness_set(1.0)
bsdf.ior_set(1.0)
bsdf.base_color_set((0.0, 0.0, 0.0))
else:
pass # Unknown shader
return ma
class PropLibrary:
propGroups = {}
def __init__(self, directory):
self.directory = directory
self.libraryInfo = {}
# Load library info
with open(f"{self.directory}/library.json", "r") as file: self.libraryInfo = load(file)
print(f"Loaded prop library: " + self.libraryInfo["name"])
def getProp(self, propName, groupName):
# Create the prop group if it's not already loaded
if not groupName in self.propGroups:
self.propGroups[groupName] = {}
# Load the prop if it's not already loaded
if not propName in self.propGroups[groupName]:
# Find the prop group
groupInfo = None
for group in self.libraryInfo["groups"]:
if group["name"] == groupName:
groupInfo = group
break
if groupInfo == None:
raise RuntimeError(f"Unable to find prop group with name {groupName} in " + self.libraryInfo["name"])
# Find the prop
propInfo = None
for prop in groupInfo["props"]:
if prop["name"] == propName:
propInfo = prop
break
if propInfo == None:
raise RuntimeError(f"Unable to find prop with name {propName} in {groupName} from " + self.libraryInfo["name"])
# Create the prop
prop = Prop()
meshInfo = propInfo["mesh"]
spriteInfo = propInfo["sprite"]
if meshInfo != None:
modelPath = f"{self.directory}/" + meshInfo["file"]
prop.loadModel(modelPath)
elif spriteInfo != None:
prop.loadSprite(propInfo)
else:
#XXX: Uhhhhhh, empty prop?
pass
self.propGroups[groupName][propName] = prop
return self.propGroups[groupName][propName]
def getTexture(self, textureName):
im = load_image(textureName, self.directory)
return im
class Prop:
def __init__(self):
self.objects = []
self.mainObject = None
def loadModel(self, modelPath):
fileExtension = modelPath.split(".")[-1]
if fileExtension == "a3d":
modelData = A3D()
with open(modelPath, "rb") as file: modelData.read(file)
# Import the model
modelImporter = A3DBlenderImporter(modelData, None, reset_empty_transform=False, try_import_textures=False)
self.objects = modelImporter.importData()
elif fileExtension == "3ds":
bpy.ops.import_scene.max3ds(filepath=modelPath, use_apply_transform=False)
for ob in bpy.context.selectable_objects:
# The imported objects are added to the active collection, remove them
bpy.context.collection.objects.unlink(ob)
# Correct the origin XXX: this does not work for all cases, investigate more
ob.animation_data_clear()
x, y, z = -ob.location.x, -ob.location.y, -ob.location.z
objectOrigin = Matrix.Translation((x, y, z))
ob.data.transform(objectOrigin)
ob.location = (0.0, 0.0, 0.0)
self.objects.append(ob)
else:
raise RuntimeError(f"Unknown model file extension: {fileExtension}")
# Identify the main parent object
for ob in self.objects:
if ob.parent == None: self.mainObject = ob
if self.mainObject == None:
raise RuntimeError(f"Unable to find the parent object for: {modelPath}")
def loadSprite(self, propInfo):
spriteInfo = propInfo["sprite"]
# Create a plane we can use for the sprite
me = bpy.data.meshes.new(propInfo["name"])
# bm = bmesh.new()
# bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=spriteInfo["scale"]*100)
# bm.to_mesh(me)
# bm.free()
ob = bpy.data.objects.new(me.name, me)
# Assign data
ob.scale = (spriteInfo["width"], 1.0, spriteInfo["height"]) #XXX: this should involve spriteInfo["scale"] probably?
spriteOrigin = Matrix.Translation((0.0, spriteInfo["originY"], 0.0))
me.transform(spriteOrigin)
# Finalise
self.objects.append(ob)
self.mainObject = ob

View File

@@ -0,0 +1,51 @@
'''
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.
'''
'''
Functions
'''
def addImageTextureToMaterial(image, node_tree, linkAlpha=False):
nodes = node_tree.nodes
links = node_tree.links
# Check if this material has already been edited
if len(nodes) > 2:
return
# Create nodes
bsdfNode = nodes.get("Principled BSDF")
textureNode = nodes.new(type="ShaderNodeTexImage")
links.new(textureNode.outputs["Color"], bsdfNode.inputs["Base Color"])
if linkAlpha:
links.new(textureNode.outputs["Alpha"], bsdfNode.inputs["Alpha"])
# Apply image
if image != None: textureNode.image = image
def decodeIntColorToTuple(intColor):
# Fromat is argb
a = (intColor >> 24) & 255
r = (intColor >> 16) & 255
g = (intColor >> 8) & 255
b = intColor & 255
return (r/255, g/255, b/255)

50
io_scene_a3d/IOTools.py Normal file
View File

@@ -0,0 +1,50 @@
'''
Copyright (c) 2024 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 struct import unpack, calcsize
def unpackStream(format, stream):
size = calcsize(format)
data = stream.read(size)
return unpack(format, data)
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")
def calculatePadding(length):
# (it basically works with rounding)
paddingSize = (((length + 3) // 4) * 4) - length
return paddingSize
def readLengthPrefixedString(stream):
length, = unpackStream("<I", stream)
string = stream.read(length)
paddingSize = calculatePadding(length)
stream.read(paddingSize)
return string.decode("utf8", errors="ignore")

View File

@@ -0,0 +1,113 @@
'''
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 .IOTools import unpackStream
from . import AlternativaProtocol
class LightmapData:
def __init__(self):
self.lightColour = (0.0, 0.0, 0.0)
self.ambientLightColour = (0.0, 0.0, 0.0)
self.lightAngle = (0.0, 0.0) # (x, z)
self.lightmaps = []
self.mapObjects = []
def read(self, stream):
print("Reading LightmapData")
# There is no signature so just start reading data and hope this is actually a lightmap data file
version, = unpackStream("<I", stream)
print(f"Reading LightmapData version {version}")
if version == 1:
self.read1(stream)
elif version == 2:
self.read2(stream)
else:
raise RuntimeError(f"Unknown LightmapData version: {version}")
'''
Version specific readers
'''
def read1(self, stream):
raise RuntimeError("Version 1 LightmapData is not implemented yet")
def read2(self, stream):
# Light info
self.lightColour, self.ambientLightColour = unpackStream("<2I", stream)
self.lightAngle = unpackStream("<2f", stream)
# Lightmaps
lightmapCount, = unpackStream("<I", stream)
print(f"Reading {lightmapCount} lightmaps")
for _ in range(lightmapCount):
lightmap = AlternativaProtocol.readString(stream)
self.lightmaps.append(lightmap)
# Map objects
mapObjectCount, = unpackStream("<I", stream)
print(f"Reading {mapObjectCount} map objects")
for _ in range(mapObjectCount):
mapObject = MapObject()
mapObject.read(stream)
self.mapObjects.append(mapObject)
#XXX: there is more data but do we actually care about it?
print(f"[LightmapData2 lightColour: {hex(self.lightColour)} ambientLightColour: {hex(self.ambientLightColour)} lightAngle: {self.lightAngle}]")
'''
Objects
'''
class MapObject:
def __init__(self):
self.index = 0
self.lightmapIndex = 0
self.lightmapScaleOffset = (0.0, 0.0, 0.0, 0.0)
self.UV1 = []
self.UV2 = []
self.castShadows = False
self.recieveShadows = False
def read(self, stream):
self.index, self.lightmapIndex = unpackStream("<2i", stream)
# Read lightmap data
if self.lightmapIndex >= 0:
self.lightmapScaleOffset = unpackStream("<4f", stream)
# Check if we have UVs and read them
hasUVs, = unpackStream("b", stream)
if hasUVs > 0:
vertexCount, = unpackStream("<I", stream)
for _ in range(vertexCount//2):
UV1 = unpackStream("<2f", stream)
self.UV1.append(UV1)
UV2 = unpackStream("<2f", stream)
self.UV2.append(UV2)
# Light settings
castShadows, recieveShadows = unpackStream("2b", stream)
self.castShadows = castShadows > 0
self.recieveShadows = recieveShadows > 0
print(f"[MapObject index: {self.index} lightmapIndex: {self.lightmapIndex} lightmapScaleOffset: {self.lightmapScaleOffset} UV1: {len(self.UV1)} UV2: {len(self.UV2)} castShadows: {self.castShadows} recieveShadows: {self.recieveShadows}]")

View File

@@ -1,196 +1,208 @@
'''
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
Copyright (c) 2024 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:
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 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.
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.
'''
bl_info = {
"name": "Modern A3D",
"description": "Support for modern a3d models",
"author": "Pyogenics, https://www.github.com/Pyogenics",
"version": (1, 0, 0),
"blender": (4, 0, 0),
"location": "File > Import-Export",
"category": "Import-Export"
}
import bmesh
import bpy
from bpy.types import Operator
from bpy.props import StringProperty
from bpy.types import Operator, OperatorFileListElement, AddonPreferences
from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty
from bpy_extras.io_utils import ImportHelper
from .A3D3_2 import A3D3_2
from .A3D3_3 import A3D3_3
from .A3DIOTools import unpackStream
from .A3D import A3D
from .A3DBlenderImporter import A3DBlenderImporter
from .BattleMap import BattleMap
from .BattleMapBlenderImporter import BattleMapBlenderImporter
from .LightmapData import LightmapData
from os.path import isdir
from time import time
'''
Addon preferences
'''
class Preferences(AddonPreferences):
bl_idname = __package__
propLibrarySourcePath: StringProperty(name="Prop library source path", subtype='DIR_PATH')
def draw(self, context):
layout = self.layout
layout.prop(self, "propLibrarySourcePath")
'''
Operators
'''
class ImportA3DModern(Operator, ImportHelper):
bl_idname = "import_scene.a3dmodern"
class ImportA3D(Operator, ImportHelper):
bl_idname = "import_scene.alternativa"
bl_label = "Import A3D"
bl_description = "Import an A3D model"
bl_options = {'PRESET', 'UNDO'}
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
files: CollectionProperty(type=OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
# User options
create_collection: BoolProperty(name="Create collection", description="Create a collection to hold all the model objects", default=False)
try_import_textures: BoolProperty(name="Search for textures", description="Automatically search for lightmap, track and wheel textures and attempt to apply them", default=True)
reset_empty_transform: BoolProperty(name="Reset empty transforms", description="Reset rotation and scale if it is set to 0, more useful for version 2 models like props", default=True)
def draw(self, context):
import_panel_options_a3d(self.layout, self)
def invoke(self, context, event):
return ImportHelper.invoke(self, context, event)
def execute(self, context):
filepath = self.filepath
print(f"Importing A3D scene from {filepath}")
importStartTime = time()
objects = []
for file in self.files:
filepath = f"{self.directory}/{file.name}"
# Read the file
print(f"Reading A3D data from {filepath}")
modelData = A3D()
with open(filepath, "rb") as file:
modelData.read(file)
with open(filepath, "rb") as file:
signature = file.read(4)
if signature != b"A3D\0":
raise RuntimeError(f"Invalid A3D signature: {signature}")
variant, _, rootBlockMarker, _ = unpackStream("<2H2I", file)
if rootBlockMarker != 1:
raise RuntimeError(f"Invalid root block marker: {rootBlockMarker}")
if variant == 3:
a3d = A3D3_3()
a3d.read(file)
# Import data into blender
modelImporter = A3DBlenderImporter(modelData, self.directory, self.reset_empty_transform, self.try_import_textures)
objects += modelImporter.importData()
for mesh in a3d.meshes:
blenderMesh = self.createBlenderMeshMin(mesh)
blenderObject = bpy.data.objects.new(mesh.name, blenderMesh)
bpy.context.collection.objects.link(blenderObject)
elif variant == 2:
a3d = A3D3_2()
a3d.read(file)
# Link objects to collection
collection = bpy.context.collection
if self.create_collection:
collection = bpy.data.collections.new("Collection")
bpy.context.collection.children.link(collection)
for obI, ob in enumerate(objects):
collection.objects.link(ob)
# Create our materials
materials = {}
for materialName in a3d.materialNames:
materials[materialName] = bpy.data.materials.new(materialName)
a3dMesh = a3d.meshes[0]
blenderMesh = self.createBlenderMesh(a3dMesh, materials)
blenderObject = bpy.data.objects.new(a3dMesh.name, blenderMesh)
bpy.context.collection.objects.link(blenderObject)
elif variant == 1:
pass
else:
pass
self.report({"INFO"}, f"Loaded A3D")
importEndTime = time()
self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s")
return {"FINISHED"}
def createBlenderMeshMin(self, mesh):
me = bpy.data.meshes.new(mesh.name)
bm = bmesh.new()
class ImportBattleMap(Operator, ImportHelper):
bl_idname = "import_scene.tanki_battlemap"
bl_label = "Import map"
bl_description = "Import a BIN format Tanki Online map file"
bl_options = {'PRESET', 'UNDO'}
for coord in mesh.coordinates:
bm.verts.new(coord)
bm.verts.ensure_lookup_table()
bm.verts.index_update()
for face in mesh.faces:
v1, v2, v3 = face
bm.faces.new([
bm.verts[v1],
bm.verts[v2],
bm.verts[v3]
])
filter_glob: StringProperty(default="*.bin", options={'HIDDEN'})
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
layers = []
if len(mesh.uv1) != 0:
layers.append(
(bm.loops.layers.uv.new("UV1"), mesh.uv1)
)
print("has UV1")
if len(mesh.uv2) != 0:
layers.append(
(bm.loops.layers.uv.new("UV2"), mesh.uv2)
)
print("has UV2")
for face in bm.faces:
for loop in face.loops:
for uvLayer, uvData in layers: loop[uvLayer].uv = uvData[loop.vert.index]
loop.vert.normal = mesh.normals[loop.vert.index]
# User options
import_static_geom: BoolProperty(name="Import static geometry", description="Static geometry includes all the visual aspects of the map", default=True)
import_collision_geom: BoolProperty(name="Import collision geometry", description="Collision geometry defines the geometry used for collision checks and cannot normally be seen by players", default=False)
import_spawn_points: BoolProperty(name="Import spawn points", description="Places a marker at locations where tanks can spawn", default=False)
import_lightmapdata: BoolProperty(name="Import lighting information", description="Loads the lightmapdata file which stores information about the sun, ambient lighting and shadow settings. Only works on remaster maps.", default=True)
map_scale_factor: FloatProperty(name="Map scale", description="Sets the map's default scale, maps and models are at a 100x scale so this allows you to directly import the map in the right size.", default=0.01, min=0.0, soft_max=1.0)
bm.to_mesh(me)
me.update()
def draw(self, context):
import_panel_options_battlemap(self.layout, self)
return me
def invoke(self, context, event):
return ImportHelper.invoke(self, context, event)
def execute(self, context):
print(f"Reading BattleMap data from {self.filepath}")
importStartTime = time()
def createBlenderMesh(self, mesh, materials):
me = bpy.data.meshes.new(mesh.name)
bm = bmesh.new()
# lightmapdata files only exist for remaster maps
lightmapData = LightmapData()
if self.import_lightmapdata:
try:
with open(f"{self.directory}/lightmapdata", "rb") as file: lightmapData.read(file)
except:
print("Couldn't open lightmapdata file, ignoring")
self.import_lightmapdata = False
for coord in mesh.coordinates:
bm.verts.new(coord)
bm.verts.ensure_lookup_table()
bm.verts.index_update()
for face in mesh.faces:
v1, v2, v3 = face
bm.faces.new([
bm.verts[v1],
bm.verts[v2],
bm.verts[v3]
])
# read map data
mapData = BattleMap()
with open(self.filepath, "rb") as file:
mapData.read(file)
layers = []
if len(mesh.uv1) != 0:
layers.append(
(bm.loops.layers.uv.new("UV1"), mesh.uv1)
)
print("has UV1")
if len(mesh.uv2) != 0:
layers.append(
(bm.loops.layers.uv.new("UV2"), mesh.uv2)
)
print("has UV2")
for face in bm.faces:
for loop in face.loops:
for uvLayer, uvData in layers: loop[uvLayer].uv = uvData[loop.vert.index]
loop.vert.normal = mesh.normals[loop.vert.index]
# Import data into blender
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)
objects = mapImporter.importData()
bm.to_mesh(me)
me.update()
# Link objects
collection = bpy.context.collection
for ob in objects:
collection.objects.link(ob)
importEndTime = time()
self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s")
# Materials
for submesh in mesh.submeshes:
material = materials[submesh.material]
me.materials.append(material)
materialI = len(me.materials) - 1
for polygon in me.polygons:
polygon.material_index = materialI
return me
return {"FINISHED"}
'''
Menu
'''
def import_panel_options_a3d(layout, operator):
header, body = layout.panel("alternativa_import_options", default_closed=False)
header.label(text="Options")
if body:
body.prop(operator, "create_collection")
body.prop(operator, "try_import_textures")
body.prop(operator, "reset_empty_transform")
def import_panel_options_battlemap(layout, operator):
header, body = layout.panel("tanki_battlemap_import_options", default_closed=False)
header.label(text="Options")
if body:
body.prop(operator, "import_static_geom")
body.prop(operator, "import_collision_geom")
body.prop(operator, "import_spawn_points")
body.prop(operator, "import_lightmapdata")
body.prop(operator, "map_scale_factor")
def menu_func_import_a3d(self, context):
self.layout.operator(ImportA3DModern.bl_idname, text="A3D Modern")
self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
def menu_func_import_battlemap(self, context):
self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)")
'''
Register
Registration
'''
classes = {
ImportA3DModern
}
classes = [
Preferences,
ImportA3D,
ImportBattleMap
]
def register():
# Register classes
for c in classes:
bpy.utils.register_class(c)
# File > Import-Export
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d)
# bpy.types.TOPBAR_MT_file_export.append(menu_func_export_dava)
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap)
def unregister():
# Unregister classes
for c in classes:
bpy.utils.unregister_class(c)
# Remove `File > Import-Export`
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d)
# bpy.types.TOPBAR_MT_file_export.remove(menu_func_export_dava)
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap)
if __name__ == "__main__":
register()

View File

@@ -1,38 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.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 .A3DIOTools import unpackStream
from .A3D3_2 import A3D3_2
from .A3D3_3 import A3D3_3
def readA3D(file):
signature = file.read(4)
if signature != b"A3D\0":
raise RuntimeError(f"Invalid A3D signature: {signature}")
variant, _, rootBlockMarker, _ = unpackStream("<2H2I", file)
if rootBlockMarker != 1:
raise RuntimeError(f"Invalid root block marker: {rootBlockMarker}")
if variant == 3:
a3d = A3D3_3()
a3d.read(file)
elif variant == 2:
a3d = A3D3_2()
a3d.read(file)
elif variant == 1:
pass
else:
raise RuntimeError(f"Unknown A3D variant: {variant}")
from sys import argv
if __name__ == "__main__":
with open(argv[1], "rb") as file:
readA3D(file)

View File

@@ -0,0 +1,36 @@
schema_version = "1.0.0"
id = "alternativa3d_tanki_format"
version = "1.0.0"
name = "Alternativa3D file format (Tanki Online HTML5)"
tagline = "Import-Export Alternativa3D 3D models used by Tanki Online HTML5"
maintainer = "Pyogenics <https://github.com/Pyogenics>"
type = "add-on"
website = "https://github.com/MapMakersAndProgrammers/io_scene_a3d"
tags = ["Import-Export"]
blender_version_min = "4.2.0"
license = [
"SPDX:MIT",
]
copyright = [
"2024 Pyogenics",
]
# wheels = [
# ]
[permissions]
files = "Import-Export Alternativa3D 3D model files"
# [build]
# # These are the default build excluded patterns.
# # You only need to edit them if you want different options.
# paths_exclude_pattern = [
# "__pycache__/",
# "/.git/",
# "/*.zip",
# ]