Compare commits
54 Commits
v1.0.0-dev
...
mapmodelsa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542a4c9298 | ||
|
|
b07596676d | ||
|
|
4ac8ff0d71 | ||
|
|
03501ad56f | ||
|
|
ec3c89f8b0 | ||
|
|
6673aab38c | ||
|
|
7283d77f48 | ||
|
|
3655a0c79d | ||
|
|
c2580a4273 | ||
|
|
655e8247d0 | ||
|
|
1a6c48a3a4 | ||
|
|
56a55c8516 | ||
|
|
437d9f079c | ||
|
|
0e06e47f6e | ||
|
|
6ef3f6e889 | ||
|
|
b67cee3756 | ||
|
|
b8fe36205b | ||
|
|
1fc2856e84 | ||
|
|
4851dec975 | ||
|
|
20ed280a9d | ||
|
|
b1794014dc | ||
|
|
91ee61d692 | ||
|
|
d971ebade3 | ||
|
|
4866a3ff8a | ||
|
|
4b2ba7eba1 | ||
|
|
8a96286bae | ||
|
|
5b35a26c6e | ||
|
|
8a41d7a47a | ||
|
|
79a6d9d786 | ||
|
|
45a32c2ba4 | ||
|
|
2ce9b3aa75 | ||
|
|
bbabbda16f | ||
|
|
f07e9a58ee | ||
|
|
8141194dc1 | ||
|
|
a245b6b1a0 | ||
|
|
6cfab91dc4 | ||
|
|
46c8b0ebdf | ||
|
|
1d35ea7b0f | ||
|
|
a4d62b33e7 | ||
|
|
34d40f70a2 | ||
|
|
f9de035859 | ||
|
|
ac96886e46 | ||
|
|
880746a9ce | ||
|
|
92d66a20d4 | ||
|
|
caf3caee50 | ||
|
|
d3988cd10f | ||
|
|
db68e3c8f4 | ||
|
|
24dbdaeb1c | ||
|
|
044b7338ad | ||
|
|
02a87f4a05 | ||
|
|
45314b11e8 | ||
|
|
110c387ec9 | ||
|
|
73fce85791 | ||
|
|
67327c7fb5 |
8
.gitignore
vendored
@@ -1 +1,7 @@
|
||||
__pycache__/
|
||||
# Build and cache
|
||||
__pycache__/
|
||||
*.zip
|
||||
|
||||
# Editor files
|
||||
.venv/
|
||||
.vscode/
|
||||
54
README.md
@@ -1,10 +1,50 @@
|
||||
# WIP io_scene_a3d
|
||||
Blender plugin to import the proprietary model format `A3D` used by the game [Tanki Online](https://tankionline.com/en/) from [Alternativa Games](https://alternativa.games/), it is not compatible with older the formats used by the flash based Alternativa3D engine (see [this plugin by Davide Jones](https://github.com/davidejones/alternativa3d_tools) instead).
|
||||
# io_scene_a3d
|
||||
Blender plugin to import the proprietary model format `A3D` used by the game [Tanki Online](https://tankionline.com/en/) from [Alternativa Games](https://alternativa.games/), it is not compatible with older the formats used by the flash based Alternativa3D engine (see [this plugin by Davide Jones](https://github.com/davidejones/alternativa3d_tools) instead). The plugin can also import Tanki Online binary format maps: `map.bin`, both legacy maps and remaster maps work.
|
||||
|
||||
## Legal
|
||||
Any ripped assets are subject to the Tanki Online [Fan Content Guidelines](https://en.tankiwiki.com/Creating_Fan_Content_Guide) and must only be used for producing fan content like fan art.
|
||||
> Using original models, maps, or other in-game assets outside the scope of Tanki Online gameplay or fan art is not allowed.
|
||||
|
||||
## Installation
|
||||
### Requirements: Blender version 4.2+
|
||||
### Optional: io_scene_3ds plugin for importing legacy maps (non remaster)
|
||||
|
||||
Firstly download the repository by clicking the "Code" button and then "Download ZIP".<br>
|
||||
<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>
|
||||
<br>
|
||||
|
||||
Select the zip folder you downloaded and you should be good to go.
|
||||
|
||||
## Showcase
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Status
|
||||
### .a3d
|
||||
The plugin only supports importing models and supports loading the majority of A3D data:
|
||||
- Materials (color data imported but diffuse map is ignored as it is usually empty or references files that are not available to players)
|
||||
- Mesh data (vertex positions, normals and UV channels)
|
||||
- Material indices (each mesh can have multiple materials applied to it)
|
||||
- Object data (object hierarchy/parents, object names)
|
||||
- Transform data (object position, scale, rotation)
|
||||
|
||||
The plugin only supports version 2 (map props) and version 3 (tank models) files, version 1 is not implemented because it is not currently used in game and I have never seen one of these files before.
|
||||
### map.bin
|
||||
The plugin can load Remaster and Legacy maps, legacy maps have incorrect transforms on some props due to the `.3ds` file plugin, not all data is required to import the files into blender, currently supported data is:
|
||||
- Static geometry (the visual aspect of the map)
|
||||
- Collision geometry (the collisions of the map)
|
||||
- Spawnpoints (where tanks spawn)
|
||||
The plugin also supports `lightmapdata` files that come with remaster maps, these files provide information about the lighting of the map:
|
||||
- Sun angle and colour
|
||||
- Ambient light colour
|
||||
- Object shadow settings (can the object recieve or cast shadows)
|
||||
- Lightmap UV coordinates (not imported)
|
||||
- Lightmaps (not imported)
|
||||
- Lightprobes (not imported)
|
||||
|
||||
## File format
|
||||
Check the wiki for file format documentation.
|
||||
|
||||
## Demo
|
||||
<br>
|
||||
<br>
|
||||

|
||||
BIN
images/demo1.png
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 1.0 MiB |
BIN
images/demo2.png
|
Before Width: | Height: | Size: 722 KiB After Width: | Height: | Size: 1022 KiB |
BIN
images/demo3.png
|
Before Width: | Height: | Size: 522 KiB After Width: | Height: | Size: 2.9 MiB |
BIN
images/demo4.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
images/demo5.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
images/step1.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
images/step2.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -20,7 +20,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from .IOTools import unpackStream, readNullTerminatedString, calculatePadding
|
||||
from io import BytesIO
|
||||
|
||||
from .IOTools import unpackStream, packStream, readNullTerminatedString, calculatePadding
|
||||
from . import A3DObjects
|
||||
|
||||
'''
|
||||
@@ -38,9 +40,12 @@ A3D model object
|
||||
'''
|
||||
class A3D:
|
||||
def __init__(self):
|
||||
self.version = 0
|
||||
|
||||
self.materials = []
|
||||
self.meshes = []
|
||||
self.transforms = {}
|
||||
self.transforms = []
|
||||
self.transformParentIDs = []
|
||||
self.objects = []
|
||||
|
||||
'''
|
||||
@@ -53,15 +58,32 @@ class A3D:
|
||||
raise RuntimeError(f"Invalid A3D signature: {signature}")
|
||||
|
||||
# Read file version and read version specific data
|
||||
version, _ = unpackStream("<2H", stream) # Likely major.minor version code
|
||||
print(f"Reading A3D version {version}")
|
||||
self.version, _ = unpackStream("<2H", stream) # Likely major.minor version code
|
||||
print(f"Reading A3D version {self.version}")
|
||||
|
||||
if version == 1:
|
||||
if self.version == 1:
|
||||
self.readRootBlock1(stream)
|
||||
elif version == 2:
|
||||
elif self.version == 2:
|
||||
self.readRootBlock2(stream)
|
||||
elif version == 3:
|
||||
elif self.version == 3:
|
||||
self.readRootBlock3(stream)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown A3D version: {self.version}")
|
||||
|
||||
def write(self, stream, version=2):
|
||||
# Write header data
|
||||
stream.write(A3D_SIGNATURE)
|
||||
packStream("<2H", stream, version, 0)
|
||||
|
||||
# Write root block
|
||||
if version == 1:
|
||||
self.writeRootBlock1(stream)
|
||||
elif version == 2:
|
||||
self.writeRootBlock2(stream)
|
||||
elif version == 3:
|
||||
self.writeRootBlock3(stream)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown A3D version: {version} whilst writing A3D")
|
||||
|
||||
'''
|
||||
Root data blocks
|
||||
@@ -69,6 +91,9 @@ class A3D:
|
||||
def readRootBlock1(self, stream):
|
||||
raise RuntimeError("Version 1 files are not supported yet")
|
||||
|
||||
def writeRootBlock1(self, stream):
|
||||
raise RuntimeError("Version 1 files are not supported yet")
|
||||
|
||||
def readRootBlock2(self, stream):
|
||||
# Verify signature
|
||||
signature, _ = unpackStream("<2I", stream)
|
||||
@@ -82,6 +107,21 @@ class A3D:
|
||||
self.readTransformBlock2(stream)
|
||||
self.readObjectBlock2(stream)
|
||||
|
||||
def writeRootBlock2(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing root block")
|
||||
self.writeMaterialBlock2(buffer)
|
||||
self.writeMeshBlock2(buffer)
|
||||
self.writeTransformBlock2(buffer)
|
||||
self.writeObjectBlock2(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_ROOTBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
def readRootBlock3(self, stream):
|
||||
# Verify signature
|
||||
signature, length = unpackStream("<2I", stream)
|
||||
@@ -98,6 +138,21 @@ class A3D:
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
def writeRootBlock3(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing root block")
|
||||
self.writeMaterialBlock3(buffer)
|
||||
self.writeMeshBlock3(buffer)
|
||||
self.writeTransformBlock3(buffer)
|
||||
self.writeObjectBlock3(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_ROOTBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
'''
|
||||
Material data blocks
|
||||
'''
|
||||
@@ -114,6 +169,20 @@ class A3D:
|
||||
material.read2(stream)
|
||||
self.materials.append(material)
|
||||
|
||||
def writeMaterialBlock2(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing material block")
|
||||
packStream("<I", buffer, len(self.materials))
|
||||
for material in self.materials:
|
||||
material.write2(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_MATERIALBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
def readMaterialBlock3(self, stream):
|
||||
# Verify signature
|
||||
signature, length, materialCount = unpackStream("<3I", stream)
|
||||
@@ -131,6 +200,24 @@ class A3D:
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
def writeMaterialBlock3(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing material block")
|
||||
packStream("<I", buffer, len(self.materials))
|
||||
for material in self.materials:
|
||||
material.write3(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_MATERIALBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
# Padding
|
||||
paddingSize = calculatePadding(buffer.tell())
|
||||
stream.write(b"\x00" * paddingSize)
|
||||
|
||||
'''
|
||||
Mesh data blocks
|
||||
'''
|
||||
@@ -147,6 +234,20 @@ class A3D:
|
||||
mesh.read2(stream)
|
||||
self.meshes.append(mesh)
|
||||
|
||||
def writeMeshBlock2(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing mesh block")
|
||||
packStream("<I", buffer, len(self.meshes))
|
||||
for mesh in self.meshes:
|
||||
mesh.write2(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_MESHBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
def readMeshBlock3(self, stream):
|
||||
# Verify signature
|
||||
signature, length, meshCount = unpackStream("<3I", stream)
|
||||
@@ -164,6 +265,24 @@ class A3D:
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
def writeMeshBlock3(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing mesh block")
|
||||
packStream("<I", buffer, len(self.meshes))
|
||||
for mesh in self.meshes:
|
||||
mesh.write3(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_MESHBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
# Padding
|
||||
paddingSize = calculatePadding(buffer.tell())
|
||||
stream.write(b"\x00" * paddingSize)
|
||||
|
||||
'''
|
||||
Transform data blocks
|
||||
'''
|
||||
@@ -171,44 +290,76 @@ class A3D:
|
||||
# Verify signature
|
||||
signature, _, transformCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
|
||||
print(f"{stream.tell()}")
|
||||
raise RuntimeError(f"Invalid transform data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading transform block with {transformCount} transforms")
|
||||
transforms = []
|
||||
for _ in range(transformCount):
|
||||
transform = A3DObjects.A3DTransform()
|
||||
transform.read2(stream)
|
||||
transforms.append(transform)
|
||||
# Read and assign transform ids
|
||||
for transformI in range(transformCount):
|
||||
transformID, = unpackStream("<I", stream)
|
||||
self.transforms[transformID] = transforms[transformI]
|
||||
self.transforms.append(transform)
|
||||
# Read parent ids
|
||||
for _ in range(transformCount):
|
||||
parentID, = unpackStream("<i", stream)
|
||||
self.transformParentIDs.append(parentID)
|
||||
|
||||
def writeTransformBlock2(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing transform block")
|
||||
packStream("<I", buffer, len(self.transforms))
|
||||
for transform in self.transforms:
|
||||
transform.write2(buffer)
|
||||
for parentID in self.transformParentIDs:
|
||||
packStream("<i", buffer, parentID)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_TRANSFORMBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
def readTransformBlock3(self, stream):
|
||||
# Verify signature
|
||||
signature, length, transformCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
|
||||
print(f"{stream.tell()}")
|
||||
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)
|
||||
transforms.append(transform)
|
||||
# Read and assign transform ids
|
||||
for transformI in range(transformCount):
|
||||
transformID, = unpackStream("<I", stream)
|
||||
self.transforms[transformI] = transforms[transformI] #XXX: The IDs seem to be incorrect and instead map to index?
|
||||
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)
|
||||
|
||||
def writeTransformBlock3(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing transform block")
|
||||
packStream("<I", buffer, len(self.transforms))
|
||||
for transform in self.transforms:
|
||||
transform.write3(buffer)
|
||||
for parentID in self.transformParentIDs:
|
||||
packStream("<i", buffer, parentID)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_TRANSFORMBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
# Padding
|
||||
paddingSize = calculatePadding(buffer.tell())
|
||||
stream.write(b"\x00" * paddingSize)
|
||||
|
||||
'''
|
||||
Object data blocks
|
||||
'''
|
||||
@@ -216,7 +367,6 @@ class A3D:
|
||||
# Verify signature
|
||||
signature, _, objectCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_OBJECTBLOCK_SIGNATURE:
|
||||
print(f"{stream.tell()}")
|
||||
raise RuntimeError(f"Invalid object data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
@@ -226,11 +376,24 @@ class A3D:
|
||||
objec.read2(stream)
|
||||
self.objects.append(objec)
|
||||
|
||||
def writeObjectBlock2(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing object block")
|
||||
packStream("<I", buffer, len(self.objects))
|
||||
for objec in self.objects:
|
||||
objec.write2(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_OBJECTBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
def readObjectBlock3(self, stream):
|
||||
# Verify signature
|
||||
signature, length, objectCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_OBJECTBLOCK_SIGNATURE:
|
||||
print(f"{stream.tell()}")
|
||||
raise RuntimeError(f"Invalid object data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
@@ -242,4 +405,22 @@ class A3D:
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
stream.read(padding)
|
||||
|
||||
def writeObjectBlock3(self, stream):
|
||||
buffer = BytesIO()
|
||||
|
||||
# Write data to the buffer
|
||||
print("Writing object block")
|
||||
packStream("<I", buffer, len(self.objects))
|
||||
for objec in self.objects:
|
||||
objec.write3(buffer)
|
||||
|
||||
# Write buffer to stream
|
||||
packStream("<2I", stream, A3D_OBJECTBLOCK_SIGNATURE, buffer.tell())
|
||||
buffer.seek(0, 0)
|
||||
stream.write(buffer.read())
|
||||
|
||||
# Padding
|
||||
paddingSize = calculatePadding(buffer.tell())
|
||||
stream.write(b"\x00" * paddingSize)
|
||||
|
||||
181
io_scene_a3d/A3DBlenderExporter.py
Normal file
@@ -0,0 +1,181 @@
|
||||
'''
|
||||
Copyright (c) 2025 Pyogenics <https://github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from . import A3DObjects
|
||||
from .A3DObjects import (
|
||||
A3D_VERTEXTYPE_COORDINATE,
|
||||
A3D_VERTEXTYPE_UV1,
|
||||
A3D_VERTEXTYPE_NORMAL1,
|
||||
A3D_VERTEXTYPE_UV2,
|
||||
A3D_VERTEXTYPE_COLOR,
|
||||
A3D_VERTEXTYPE_NORMAL2
|
||||
)
|
||||
|
||||
def mirrorUVY(uv):
|
||||
x, y = uv
|
||||
return (x, 1-y)
|
||||
|
||||
class A3DBlenderExporter:
|
||||
def __init__(self, modelData, objects, version=2):
|
||||
self.modelData = modelData
|
||||
self.objects = objects
|
||||
self.version = version
|
||||
|
||||
def exportData(self):
|
||||
print("Exporting blender data to A3D")
|
||||
|
||||
# Process objects
|
||||
materials = {}
|
||||
meshes = []
|
||||
transforms = {}
|
||||
objects = []
|
||||
for ob in self.objects:
|
||||
me = ob.data
|
||||
if me == None:
|
||||
# Create a transform for this object without the object itself
|
||||
transform = A3DObjects.A3DTransform()
|
||||
transform.position = ob.location
|
||||
rotationW, rotationX, rotationY, rotationZ = ob.rotation_quaternion
|
||||
transform.rotation = (rotationX, rotationY, rotationZ, rotationW)
|
||||
transform.scale = ob.scale
|
||||
transform.name = ob.name
|
||||
transforms[ob.name] = transform
|
||||
|
||||
continue
|
||||
|
||||
# Process materials
|
||||
for ma in me.materials:
|
||||
# Make sure we haven't processed this data block already
|
||||
if ma.name in materials:
|
||||
continue
|
||||
|
||||
materialData = A3DObjects.A3DMaterial()
|
||||
materialData.name = ma.name
|
||||
materialData.diffuseMap = ""
|
||||
colorR, colorG, colorB, _ = ma.diffuse_color
|
||||
materialData.color = (colorR, colorG, colorB)
|
||||
|
||||
materials[ma.name] = materialData
|
||||
# Create mesh
|
||||
mesh = self.buildA3DMesh(me, ob)
|
||||
meshes.append(mesh)
|
||||
# Create transform
|
||||
transform = A3DObjects.A3DTransform()
|
||||
transform.position = ob.location
|
||||
rotationW, rotationX, rotationY, rotationZ = ob.rotation_quaternion
|
||||
transform.rotation = (rotationX, rotationY, rotationZ, rotationW)
|
||||
transform.scale = ob.scale
|
||||
transform.name = ob.name
|
||||
transforms[ob.name] = transform
|
||||
# Create object
|
||||
objec = A3DObjects.A3DObject()
|
||||
objec.name = ob.name
|
||||
objec.meshID = len(meshes) - 1
|
||||
objec.transformID = len(transforms) - 1
|
||||
materialIDs = []
|
||||
for ma in me.materials:
|
||||
materialID = list(materials.keys()).index(ma.name)
|
||||
materialIDs.append(materialID)
|
||||
objec.materialCount = len(materialIDs)
|
||||
objec.materialIDs = materialIDs
|
||||
objects.append(objec)
|
||||
# Create parentIDs
|
||||
transformParentIDs = []
|
||||
for ob in self.objects:
|
||||
parentOB = ob.parent
|
||||
if (parentOB == None) or (parentOB.name not in transforms):
|
||||
if self.version < 3:
|
||||
transformParentIDs.append(0)
|
||||
else:
|
||||
transformParentIDs.append(-1)
|
||||
else:
|
||||
parentIndex = list(transforms.keys()).index(parentOB.name)
|
||||
if self.version < 3:
|
||||
parentIndex += 1 # Version 2 uses 0 to signify empty parent
|
||||
transformParentIDs.append(parentIndex)
|
||||
|
||||
self.modelData.materials = materials.values()
|
||||
self.modelData.meshes = meshes
|
||||
self.modelData.transforms = transforms.values()
|
||||
self.modelData.transformParentIDs = transformParentIDs
|
||||
self.modelData.objects = objects
|
||||
|
||||
def buildA3DMesh(self, me, ob):
|
||||
mesh = A3DObjects.A3DMesh()
|
||||
mesh.name = me.name
|
||||
mesh.vertexCount = len(me.vertices)
|
||||
|
||||
# Create vertex buffers
|
||||
coordinateBuffer = A3DObjects.A3DVertexBuffer()
|
||||
coordinateBuffer.bufferType = A3D_VERTEXTYPE_COORDINATE
|
||||
normal1Buffer = A3DObjects.A3DVertexBuffer()
|
||||
normal1Buffer.bufferType = A3D_VERTEXTYPE_NORMAL1
|
||||
for vertex in me.vertices:
|
||||
coordinateBuffer.data.append(vertex.co)
|
||||
normal1Buffer.data.append(vertex.normal)
|
||||
uv1Buffer = A3DObjects.A3DVertexBuffer()
|
||||
uv1Buffer.bufferType = A3D_VERTEXTYPE_UV1
|
||||
uv1Data = me.uv_layers[0]
|
||||
uv1Vertices = [(0.0, 0.0)] * mesh.vertexCount
|
||||
for polygon in me.polygons:
|
||||
i0, i1, i2 = polygon.vertices
|
||||
uv1Vertices[i0] = mirrorUVY(uv1Data.uv[polygon.loop_start].vector)
|
||||
uv1Vertices[i1] = mirrorUVY(uv1Data.uv[polygon.loop_start+1].vector)
|
||||
uv1Vertices[i2] = mirrorUVY(uv1Data.uv[polygon.loop_start+2].vector)
|
||||
uv1Buffer.data = uv1Vertices
|
||||
|
||||
normal2Buffer = A3DObjects.A3DVertexBuffer()
|
||||
normal2Buffer.bufferType = A3D_VERTEXTYPE_NORMAL2
|
||||
normal2Buffer.data = normal1Buffer.data
|
||||
|
||||
mesh.vertexBufferCount = 3 #XXX: We only do coordinate, normal1 and uv1
|
||||
mesh.vertexBuffers = [coordinateBuffer, uv1Buffer, normal1Buffer]
|
||||
|
||||
# Create submeshes
|
||||
indexArrays = {} # material_index: index array
|
||||
lastMaterialIndex = None
|
||||
for polygon in me.polygons:
|
||||
if polygon.material_index != lastMaterialIndex:
|
||||
indexArrays[polygon.material_index] = []
|
||||
|
||||
indexArrays[polygon.material_index] += polygon.vertices
|
||||
lastMaterialIndex = polygon.material_index
|
||||
submeshes = []
|
||||
for materialID, indexArray in indexArrays.items():
|
||||
submesh = A3DObjects.A3DSubmesh()
|
||||
submesh.indexCount = len(indexArray)
|
||||
submesh.indices = indexArray
|
||||
submesh.materialID = materialID
|
||||
submesh.smoothingGroups = [0] * (len(indexArray)//3) # Just set all faces to 0
|
||||
submeshes.append(submesh)
|
||||
mesh.submeshCount = len(submeshes)
|
||||
mesh.submeshes = submeshes
|
||||
|
||||
# Bound box data
|
||||
bounds = []
|
||||
for bound in ob.bound_box:
|
||||
x, y, z = bound
|
||||
bounds.append((x, y, z))
|
||||
mesh.bboxMax = max(bounds)
|
||||
mesh.bboxMin = min(bounds)
|
||||
|
||||
return mesh
|
||||
264
io_scene_a3d/A3DBlenderImporter.py
Normal file
@@ -0,0 +1,264 @@
|
||||
'''
|
||||
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 transformID, transformData in enumerate(self.modelData.transforms):
|
||||
# Find out if this transform is used by an object
|
||||
ob = None
|
||||
for objectData in self.modelData.objects:
|
||||
if objectData.transformID == transformID:
|
||||
ob = self.buildBlenderObject(objectData)
|
||||
break
|
||||
|
||||
# Empty transform, create an empty object to represent it
|
||||
if ob == None:
|
||||
ob = self.buildBlenderEmptyObject(transformData)
|
||||
|
||||
objects.append(ob)
|
||||
|
||||
# Assign parents
|
||||
for objectID, parentID in enumerate(self.modelData.transformParentIDs):
|
||||
if self.modelData.version < 3:
|
||||
# version 2 models use 0 to signify empty parent so everything is shifted up
|
||||
parentID -= 1
|
||||
if parentID == -1:
|
||||
continue
|
||||
ob = objects[objectID]
|
||||
ob.parent = objects[parentID]
|
||||
|
||||
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
|
||||
|
||||
def buildBlenderEmptyObject(self, transformData):
|
||||
# Create the object
|
||||
ob = bpy.data.objects.new(transformData.name, None)
|
||||
ob.empty_display_size = 10 # Assume that the model is in alternativa scale (x100)
|
||||
|
||||
# Set transform
|
||||
ob.location = transformData.position
|
||||
ob.scale = transformData.scale
|
||||
ob.rotation_mode = "QUATERNION"
|
||||
x, y, z, w = transformData.rotation
|
||||
ob.rotation_quaternion = (w, x, y, z)
|
||||
if self.reset_empty_transform:
|
||||
if transformData.scale == (0.0, 0.0, 0.0): ob.scale = (1.0, 1.0, 1.0)
|
||||
if transformData.rotation == (0.0, 0.0, 0.0, 0.0): ob.rotation_quaternion = (1.0, 0.0, 0.0, 0.0)
|
||||
|
||||
return ob
|
||||
@@ -20,7 +20,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from .IOTools import unpackStream, readNullTerminatedString, readLengthPrefixedString, calculatePadding
|
||||
from .IOTools import unpackStream, packStream, readNullTerminatedString, writeNullTerminatedString, readLengthPrefixedString, writeLengthPrefixedString, calculatePadding
|
||||
|
||||
class A3DMaterial:
|
||||
def __init__(self):
|
||||
@@ -35,14 +35,25 @@ class A3DMaterial:
|
||||
|
||||
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
|
||||
|
||||
def write2(self, stream):
|
||||
writeNullTerminatedString(stream, self.name)
|
||||
colorR, colorG, colorB = self.color
|
||||
packStream("<3f", stream, colorR, colorG, colorB)
|
||||
writeNullTerminatedString(stream, self.diffuseMap)
|
||||
|
||||
def read3(self, stream):
|
||||
print(stream.tell())
|
||||
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}]")
|
||||
|
||||
def write3(self, stream):
|
||||
writeLengthPrefixedString(stream, self.name)
|
||||
colorR, colorG, colorB = self.color
|
||||
packStream("<3f", stream, colorR, colorG, colorB)
|
||||
writeLengthPrefixedString(stream, self.diffuseMap)
|
||||
|
||||
class A3DMesh:
|
||||
def __init__(self):
|
||||
self.name = ""
|
||||
@@ -71,7 +82,16 @@ class A3DMesh:
|
||||
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 write2(self, stream):
|
||||
packStream("<2I", stream, self.vertexCount, self.vertexBufferCount)
|
||||
for vertexBuffer in self.vertexBuffers:
|
||||
vertexBuffer.write2(stream)
|
||||
|
||||
packStream("<I", stream, self.submeshCount)
|
||||
for submesh in self.submeshes:
|
||||
submesh.write2(stream)
|
||||
|
||||
def read3(self, stream):
|
||||
# Read mesh info
|
||||
self.name = readLengthPrefixedString(stream)
|
||||
@@ -96,6 +116,24 @@ class A3DMesh:
|
||||
|
||||
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
|
||||
|
||||
def write3(self, stream):
|
||||
writeLengthPrefixedString(stream, self.name)
|
||||
bboxMaxX, bboxMaxY, bboxMaxZ = self.bboxMax
|
||||
packStream("<3f", stream, bboxMaxX, bboxMaxY, bboxMaxZ)
|
||||
bboxMinX, bboxMinY, bboxMinZ = self.bboxMin
|
||||
packStream("<3f", stream, bboxMinX, bboxMinY, bboxMinZ)
|
||||
packStream("<f", stream, 0.0) # XXX: Unknown float value!
|
||||
|
||||
# Write vertex buffers
|
||||
packStream("<2I", stream, self.vertexCount, self.vertexBufferCount)
|
||||
for vertexBuffer in self.vertexBuffers:
|
||||
vertexBuffer.write2(stream)
|
||||
|
||||
# Write submeshes
|
||||
packStream("<I", stream, self.submeshCount)
|
||||
for submesh in self.submeshes:
|
||||
submesh.write3(stream)
|
||||
|
||||
A3D_VERTEXTYPE_COORDINATE = 1
|
||||
A3D_VERTEXTYPE_UV1 = 2
|
||||
A3D_VERTEXTYPE_NORMAL1 = 3
|
||||
@@ -127,6 +165,12 @@ class A3DVertexBuffer:
|
||||
|
||||
print(f"[A3DVertexBuffer data: {len(self.data)} buffer type: {self.bufferType}]")
|
||||
|
||||
def write2(self, stream):
|
||||
packStream("<I", stream, self.bufferType)
|
||||
for vertex in self.data:
|
||||
for vertexElement in vertex:
|
||||
packStream("<f", stream, vertexElement)
|
||||
|
||||
class A3DSubmesh:
|
||||
def __init__(self):
|
||||
self.indices = []
|
||||
@@ -136,25 +180,43 @@ class A3DSubmesh:
|
||||
self.indexCount = 0
|
||||
|
||||
def read2(self, stream):
|
||||
self.indexCount, = unpackStream("<I", stream) # This is just the face count so multiply it by 3
|
||||
self.indexCount *= 3
|
||||
faceCount, = unpackStream("<I", stream)
|
||||
self.indexCount = faceCount * 3
|
||||
self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
|
||||
self.smoothingGroups = list(unpackStream(f"<{self.indexCount//3}I", stream))
|
||||
self.materialID, = unpackStream("<H", stream)
|
||||
|
||||
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
|
||||
|
||||
def write2(self, stream):
|
||||
faceCount = self.indexCount // 3
|
||||
packStream("<I", stream, faceCount)
|
||||
for index in self.indices:
|
||||
packStream("<H", stream, index)
|
||||
for smoothingGroup in self.smoothingGroups:
|
||||
packStream("<I", stream, smoothingGroup)
|
||||
packStream("<H", stream, self.materialID)
|
||||
|
||||
def read3(self, stream):
|
||||
# Read indices
|
||||
self.indexCount, = unpackStream("<I", stream)
|
||||
self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(self.indexCount*2) # Each index is 2 bytes
|
||||
stream.read(padding)
|
||||
paddingSize = calculatePadding(self.indexCount*2) # Each index is 2 bytes
|
||||
stream.read(paddingSize)
|
||||
|
||||
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
|
||||
|
||||
def write3(self, stream):
|
||||
packStream("<I", stream, self.indexCount)
|
||||
for index in self.indices:
|
||||
packStream("<H", stream, index)
|
||||
|
||||
# Padding
|
||||
paddingSize = calculatePadding(self.indexCount*2) # Each index is 2 bytes
|
||||
stream.write(b"\x00" * paddingSize)
|
||||
|
||||
class A3DTransform:
|
||||
def __init__(self):
|
||||
self.name = ""
|
||||
@@ -169,6 +231,14 @@ class A3DTransform:
|
||||
|
||||
print(f"[A3DTransform position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
|
||||
|
||||
def write2(self, stream):
|
||||
positionX, positionY, positionZ = self.position
|
||||
packStream("<3f", stream, positionX, positionY, positionZ)
|
||||
rotationX, rotationY, rotationZ, rotationW = self.rotation
|
||||
packStream("<4f", stream, rotationX, rotationY, rotationZ, rotationW)
|
||||
scaleX, scaleY, scaleZ = self.scale
|
||||
packStream("<3f", stream, scaleX, scaleY, scaleZ)
|
||||
|
||||
def read3(self, stream):
|
||||
self.name = readLengthPrefixedString(stream)
|
||||
self.position = unpackStream("<3f", stream)
|
||||
@@ -177,6 +247,15 @@ class A3DTransform:
|
||||
|
||||
print(f"[A3DTransform name: {self.name} position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
|
||||
|
||||
def write3(self, stream):
|
||||
writeLengthPrefixedString(stream, self.name)
|
||||
positionX, positionY, positionZ = self.position
|
||||
packStream("<3f", stream, positionX, positionY, positionZ)
|
||||
rotationX, rotationY, rotationZ, rotationW = self.rotation
|
||||
packStream("<4f", stream, rotationX, rotationY, rotationZ, rotationW)
|
||||
scaleX, scaleY, scaleZ = self.scale
|
||||
packStream("<3f", stream, scaleX, scaleY, scaleZ)
|
||||
|
||||
class A3DObject:
|
||||
def __init__(self):
|
||||
self.name = ""
|
||||
@@ -192,6 +271,10 @@ class A3DObject:
|
||||
|
||||
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
|
||||
|
||||
def write2(self, stream):
|
||||
writeNullTerminatedString(stream, self.name)
|
||||
packStream("<2I", stream, self.meshID, self.transformID)
|
||||
|
||||
def read3(self, stream):
|
||||
self.meshID, self.transformID, self.materialCount = unpackStream("<3I", stream)
|
||||
|
||||
@@ -200,4 +283,9 @@ class A3DObject:
|
||||
materialID, = unpackStream("<i", stream)
|
||||
self.materialIDs.append(materialID)
|
||||
|
||||
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
|
||||
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
|
||||
|
||||
def write3(self, stream):
|
||||
packStream("<3I", stream, self.meshID, self.transformID, self.materialCount)
|
||||
for materialID in self.materialIDs:
|
||||
packStream("<i", stream, materialID)
|
||||
|
||||
163
io_scene_a3d/AlternativaProtocol.py
Normal 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
@@ -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)
|
||||
477
io_scene_a3d/BattleMapBlenderImporter.py
Normal file
@@ -0,0 +1,477 @@
|
||||
'''
|
||||
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, mapDirectory, lightmapData, propLibrarySourcePath, map_scale_factor=0.01, import_static_geom=True, import_collision_geom=False, import_spawn_points=False, import_lightmapdata=False):
|
||||
self.mapData = mapData
|
||||
self.mapDirectory = mapDirectory
|
||||
self.lightmapData = lightmapData
|
||||
self.propLibrarySourcePath = propLibrarySourcePath
|
||||
self.map_scale_factor = map_scale_factor
|
||||
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 = {}
|
||||
self.modelsA3D = {}
|
||||
|
||||
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 always marked as None? This is not true for the ny2024 remaster prop lib though
|
||||
libraryName = "Remaster"
|
||||
elif libraryName == "":
|
||||
# This is only true for a material that is using the atlas in case of models.a3d
|
||||
return None
|
||||
|
||||
propLibrary = self.getPropLibrary(libraryName)
|
||||
texture = propLibrary.getTexture(f"{textureName}.webp")
|
||||
return texture
|
||||
|
||||
'''
|
||||
Blender data builders
|
||||
'''
|
||||
def getPropFromModelsA3D(self, propName):
|
||||
if len(self.modelsA3D) == 0:
|
||||
# Load models.a3d
|
||||
modelData = A3D()
|
||||
try:
|
||||
with open(f"{self.mapDirectory}/models.a3d", "rb") as f: modelData.read(f)
|
||||
except: return None
|
||||
modelImporter = A3DBlenderImporter(modelData, None, reset_empty_transform=False, try_import_textures=False)
|
||||
modelObjects = modelImporter.importData()
|
||||
|
||||
# Create props
|
||||
for ob in modelObjects:
|
||||
prop = Prop()
|
||||
prop.createFromObject(ob)
|
||||
self.modelsA3D[ob.name] = prop
|
||||
|
||||
return self.modelsA3D[propName]
|
||||
|
||||
def getBlenderProp(self, propData):
|
||||
prop = None
|
||||
if propData.libraryName == "":
|
||||
# Load prop from models.a3d first, we prefer it over the library where possible
|
||||
prop = self.getPropFromModelsA3D(propData.name)
|
||||
if prop == None:
|
||||
# Load prop through libraries if we can't find it in models.a3d
|
||||
propLibrary = self.getPropLibrary(propData.libraryName)
|
||||
prop = propLibrary.getProp(propData.name, propData.groupName)
|
||||
propOB = prop.mainObject.copy() # We want to use a copy of the prop object
|
||||
|
||||
# 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.get("mesh")
|
||||
spriteInfo = propInfo.get("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 createFromObject(self, ob):
|
||||
self.objects.append(ob)
|
||||
self.mainObject = ob
|
||||
|
||||
def loadModel(self, modelPath):
|
||||
fileExtension = modelPath.split(".")[-1].lower()
|
||||
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
|
||||
51
io_scene_a3d/BlenderMaterialUtils.py
Normal 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)
|
||||
@@ -20,20 +20,29 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from struct import unpack, calcsize
|
||||
from struct import unpack, pack, calcsize
|
||||
|
||||
def unpackStream(format, stream):
|
||||
size = calcsize(format)
|
||||
data = stream.read(size)
|
||||
return unpack(format, data)
|
||||
|
||||
def packStream(format, stream, *data):
|
||||
packedData = pack(format, *data)
|
||||
stream.write(packedData)
|
||||
|
||||
def readNullTerminatedString(stream):
|
||||
string = b""
|
||||
char = stream.read(1)
|
||||
while char != b"\x00":
|
||||
string += char
|
||||
char = stream.read(1)
|
||||
return string.decode("utf8", errors="ignore")
|
||||
return string.decode("utf-8", errors="ignore")
|
||||
|
||||
def writeNullTerminatedString(stream, string):
|
||||
string = string.encode("utf-8")
|
||||
stream.write(string)
|
||||
stream.write(b"\x00")
|
||||
|
||||
def calculatePadding(length):
|
||||
# (it basically works with rounding)
|
||||
@@ -47,4 +56,13 @@ def readLengthPrefixedString(stream):
|
||||
paddingSize = calculatePadding(length)
|
||||
stream.read(paddingSize)
|
||||
|
||||
return string.decode("utf8", errors="ignore")
|
||||
return string.decode("utf8", errors="ignore")
|
||||
|
||||
def writeLengthPrefixedString(stream, string):
|
||||
string = string.encode("utf-8")
|
||||
|
||||
packStream("<I", stream, len(string))
|
||||
stream.write(string)
|
||||
|
||||
paddingSize = calculatePadding(len(string))
|
||||
stream.write(b"\x00" * paddingSize)
|
||||
113
io_scene_a3d/LightmapData.py
Normal 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}]")
|
||||
@@ -1,19 +1,51 @@
|
||||
import bmesh
|
||||
'''
|
||||
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.types import Operator
|
||||
from bpy.props import StringProperty, BoolProperty
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
|
||||
from bpy.types import Operator, OperatorFileListElement, AddonPreferences
|
||||
from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty, EnumProperty
|
||||
from bpy_extras.io_utils import ImportHelper, ExportHelper
|
||||
|
||||
from .A3D import A3D
|
||||
from .A3DObjects import (
|
||||
A3D_VERTEXTYPE_COORDINATE,
|
||||
A3D_VERTEXTYPE_UV1,
|
||||
A3D_VERTEXTYPE_NORMAL1,
|
||||
A3D_VERTEXTYPE_UV2,
|
||||
A3D_VERTEXTYPE_COLOR,
|
||||
A3D_VERTEXTYPE_NORMAL2
|
||||
)
|
||||
from .A3DBlenderImporter import A3DBlenderImporter
|
||||
from .A3DBlenderExporter import A3DBlenderExporter
|
||||
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
|
||||
@@ -22,162 +54,206 @@ 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
|
||||
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)
|
||||
|
||||
# Read the file
|
||||
print(f"Reading A3D data from {filepath}")
|
||||
# Import data into blender
|
||||
modelImporter = A3DBlenderImporter(modelData, self.directory, self.reset_empty_transform, self.try_import_textures)
|
||||
objects += modelImporter.importData()
|
||||
|
||||
# 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)
|
||||
|
||||
importEndTime = time()
|
||||
self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
class ExportA3D(Operator, ExportHelper):
|
||||
bl_idname = "export_scene.alternativa"
|
||||
bl_label = "Export A3D"
|
||||
bl_description = "Export an A3D model"
|
||||
bl_options = {'PRESET', 'UNDO'}
|
||||
|
||||
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
|
||||
filename_ext: StringProperty(default=".a3d", options={'HIDDEN'})
|
||||
|
||||
a3d_version: EnumProperty(
|
||||
items=(
|
||||
("2", "A3D2", "Version 2 files are used to store map geometry like props and simple models like drones and particle effects"),
|
||||
("3", "A3D3", "Version 3 files are used to store tank turret and hull models")
|
||||
),
|
||||
description="A3D file version",
|
||||
default="2",
|
||||
name="version"
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
export_panel_options_a3d(self.layout, self)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
|
||||
def execute(self, context):
|
||||
print(f"Exporting blender data to {self.filepath}")
|
||||
|
||||
modelData = A3D()
|
||||
with open(filepath, "rb") as file:
|
||||
modelData.read(file)
|
||||
|
||||
modelExporter = A3DBlenderExporter(modelData, bpy.context.selected_objects, version=int(self.a3d_version))
|
||||
modelExporter.exportData()
|
||||
|
||||
# Write file
|
||||
with open(self.filepath, "wb") as file:
|
||||
modelData.write(file, version=int(self.a3d_version))
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
class ImportBattleMap(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.tanki_battlemap"
|
||||
bl_label = "Import map"
|
||||
bl_description = "Import a BIN format Tanki Online map file"
|
||||
bl_options = {'PRESET', 'UNDO'}
|
||||
|
||||
filter_glob: StringProperty(default="*.bin", options={'HIDDEN'})
|
||||
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
|
||||
|
||||
# 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)
|
||||
|
||||
def draw(self, context):
|
||||
import_panel_options_battlemap(self.layout, self)
|
||||
|
||||
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()
|
||||
|
||||
# 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
|
||||
|
||||
# read map data
|
||||
mapData = BattleMap()
|
||||
with open(self.filepath, "rb") as file:
|
||||
mapData.read(file)
|
||||
|
||||
# Import data into blender
|
||||
print("Importing mesh data into blender")
|
||||
# Create materials
|
||||
materials = []
|
||||
for material in modelData.materials:
|
||||
ma = bpy.data.materials.new(material.name)
|
||||
maWrapper = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||
maWrapper.base_color = material.color
|
||||
maWrapper.roughness = 1.0
|
||||
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, self.directory, lightmapData, preferences.propLibrarySourcePath, self.map_scale_factor, self.import_static_geom, self.import_collision_geom, self.import_spawn_points, self.import_lightmapdata)
|
||||
objects = mapImporter.importData()
|
||||
|
||||
materials.append(ma)
|
||||
# Build meshes
|
||||
meshes = []
|
||||
for mesh in modelData.meshes:
|
||||
me = bpy.data.meshes.new(mesh.name)
|
||||
|
||||
# Gather all vertex data
|
||||
coordinates = []
|
||||
uv1 = []
|
||||
normal1 = []
|
||||
uv2 = []
|
||||
colors = []
|
||||
normal2 = []
|
||||
for vertexBuffer in mesh.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
|
||||
blenderVertexIndices = []
|
||||
blenderVertices = []
|
||||
blenderUV1s = []
|
||||
blenderUV2s = []
|
||||
for submesh in mesh.submeshes:
|
||||
polygonCount = len(submesh.indices) // 3
|
||||
me.vertices.add(polygonCount*3)
|
||||
me.loops.add(polygonCount*3)
|
||||
me.polygons.add(polygonCount)
|
||||
|
||||
for indexI in range(submesh.indexCount):
|
||||
index = submesh.indices[indexI]
|
||||
blenderVertexIndices.append(indexI)
|
||||
blenderVertices += list(coordinates[index])
|
||||
blenderUV1s.append(uv1[index])
|
||||
#blenderUV2s += uv2[index]
|
||||
me.vertices.foreach_set("co", blenderVertices)
|
||||
me.polygons.foreach_set("loop_start", range(0, len(blenderVertices)//3, 3))
|
||||
me.loops.foreach_set("vertex_index", blenderVertexIndices)
|
||||
|
||||
# UVs
|
||||
if len(uv1) != 0:
|
||||
uvData = me.uv_layers.new(name="UV1").data
|
||||
for polygonI, po in enumerate(me.polygons):
|
||||
indexI = polygonI * 3
|
||||
uvData[po.loop_start].uv = blenderUV1s[blenderVertexIndices[indexI]]
|
||||
uvData[po.loop_start+1].uv = blenderUV1s[blenderVertexIndices[indexI+1]]
|
||||
uvData[po.loop_start+2].uv = blenderUV1s[blenderVertexIndices[indexI+2]]
|
||||
|
||||
# Apply materials (version 2)
|
||||
faceIndexBase = 0
|
||||
for submeshI, submesh in enumerate(mesh.submeshes):
|
||||
if submesh.materialID == None:
|
||||
continue
|
||||
me.materials.append(materials[submesh.materialID])
|
||||
for faceI in range(submesh.indexCount//3):
|
||||
me.polygons[faceI+faceIndexBase].material_index = submeshI
|
||||
faceIndexBase += submesh.indexCount//3
|
||||
|
||||
# Finalise
|
||||
me.validate()
|
||||
me.update()
|
||||
meshes.append(me)
|
||||
# Create objects
|
||||
for objec in modelData.objects:
|
||||
me = meshes[objec.meshID]
|
||||
mesh = modelData.meshes[objec.meshID]
|
||||
transform = modelData.transforms[objec.transformID]
|
||||
|
||||
# Select a name for the blender object
|
||||
name = ""
|
||||
if objec.name != "":
|
||||
name = objec.name
|
||||
elif mesh.name != "":
|
||||
name = mesh.name
|
||||
else:
|
||||
name = transform.name
|
||||
|
||||
# Create the object
|
||||
ob = bpy.data.objects.new(name, me)
|
||||
bpy.context.collection.objects.link(ob)
|
||||
|
||||
# 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)
|
||||
|
||||
# Apply materials (version 3)
|
||||
for materialID in objec.materialIDs:
|
||||
print(materialID)
|
||||
if materialID == -1:
|
||||
continue
|
||||
me.materials.append(materials[materialID])
|
||||
# Set the default material to the first one we added
|
||||
for polygon in me.polygons:
|
||||
polygon.material_index = 0
|
||||
# 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")
|
||||
|
||||
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 export_panel_options_a3d(layout, operator):
|
||||
header, body = layout.panel("alternativa_import_options", default_closed=False)
|
||||
header.label(text="Options")
|
||||
if body:
|
||||
body.prop(operator, "a3d_version")
|
||||
|
||||
def import_panel_options_battlemap(layout, operator):
|
||||
header, body = layout.panel("tanki_battlemap_import_options", default_closed=False)
|
||||
header.label(text="Options")
|
||||
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(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
|
||||
|
||||
def menu_func_export_a3d(self, context):
|
||||
self.layout.operator(ExportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)")
|
||||
|
||||
def menu_func_import_battlemap(self, context):
|
||||
self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)")
|
||||
|
||||
'''
|
||||
Registration
|
||||
'''
|
||||
classes = [
|
||||
ImportA3D
|
||||
Preferences,
|
||||
ImportA3D,
|
||||
ExportA3D,
|
||||
ImportBattleMap
|
||||
]
|
||||
|
||||
def register():
|
||||
for c in classes:
|
||||
bpy.utils.register_class(c)
|
||||
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d)
|
||||
bpy.types.TOPBAR_MT_file_export.append(menu_func_export_a3d)
|
||||
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap)
|
||||
|
||||
def unregister():
|
||||
for c in classes:
|
||||
bpy.utils.unregister_class(c)
|
||||
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d)
|
||||
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export_a3d)
|
||||
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||