Compare commits
61 Commits
1.0.0-init
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
11fdb83627 | ||
|
|
e4e395b6e1 | ||
|
|
643d23d6e7 | ||
|
|
696a65e5a2 | ||
|
|
e8fd653c80 | ||
|
|
019b0c54e8 | ||
|
|
eb9e774592 | ||
|
|
647c665566 |
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Build and cache
|
||||
__pycache__/
|
||||
*.zip
|
||||
|
||||
# Editor files
|
||||
.venv/
|
||||
.vscode/
|
||||
51
README.md
@@ -1,7 +1,50 @@
|
||||
# io_scene_a3d
|
||||
Blender plugin to load A3D 3.2 and 3.3 models (3.1 not supported), 3.2 is most complete 3.3 is not so complete; this code will eventually be merged into the alternativa3d_tools github repo.
|
||||
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.
|
||||
|
||||
The code can read all the A3D3 data but not all of it is imported yet, some material data + transforms + some vertex data and the codebase could use a cleanup.
|
||||
## 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.
|
||||
|
||||
- https://github.com/davidejones/alternativa3d_tools/issues/9
|
||||
- https://github.com/davidejones/alternativa3d_tools
|
||||
## 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.
|
||||
|
||||
BIN
images/demo1.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
images/demo2.png
Normal file
|
After Width: | Height: | Size: 1022 KiB |
BIN
images/demo3.png
Normal file
|
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 |
426
io_scene_a3d/A3D.py
Normal file
@@ -0,0 +1,426 @@
|
||||
'''
|
||||
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from .IOTools import unpackStream, packStream, readNullTerminatedString, calculatePadding
|
||||
from . import A3DObjects
|
||||
|
||||
'''
|
||||
A3D constants
|
||||
'''
|
||||
A3D_SIGNATURE = b"A3D\0"
|
||||
A3D_ROOTBLOCK_SIGNATURE = 1
|
||||
A3D_MATERIALBLOCK_SIGNATURE = 4
|
||||
A3D_MESHBLOCK_SIGNATURE = 2
|
||||
A3D_TRANSFORMBLOCK_SIGNATURE = 3
|
||||
A3D_OBJECTBLOCK_SIGNATURE = 5
|
||||
|
||||
'''
|
||||
A3D model object
|
||||
'''
|
||||
class A3D:
|
||||
def __init__(self):
|
||||
self.version = 0
|
||||
|
||||
self.materials = []
|
||||
self.meshes = []
|
||||
self.transforms = []
|
||||
self.transformParentIDs = []
|
||||
self.objects = []
|
||||
|
||||
'''
|
||||
Main IO
|
||||
'''
|
||||
def read(self, stream):
|
||||
# Check signature
|
||||
signature = stream.read(4)
|
||||
if signature != A3D_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid A3D signature: {signature}")
|
||||
|
||||
# Read file version and read version specific data
|
||||
self.version, _ = unpackStream("<2H", stream) # Likely major.minor version code
|
||||
print(f"Reading A3D version {self.version}")
|
||||
|
||||
if self.version == 1:
|
||||
self.readRootBlock1(stream)
|
||||
elif self.version == 2:
|
||||
self.readRootBlock2(stream)
|
||||
elif self.version == 3:
|
||||
self.readRootBlock3(stream)
|
||||
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
|
||||
'''
|
||||
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)
|
||||
if signature != A3D_ROOTBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid root data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading root block")
|
||||
self.readMaterialBlock2(stream)
|
||||
self.readMeshBlock2(stream)
|
||||
self.readTransformBlock2(stream)
|
||||
self.readObjectBlock2(stream)
|
||||
|
||||
def 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)
|
||||
if signature != A3D_ROOTBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid root data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
self.readMaterialBlock3(stream)
|
||||
self.readMeshBlock3(stream)
|
||||
self.readTransformBlock3(stream)
|
||||
self.readObjectBlock3(stream)
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
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
|
||||
'''
|
||||
def readMaterialBlock2(self, stream):
|
||||
# Verify signature
|
||||
signature, _, materialCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_MATERIALBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid material data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading material block with {materialCount} materials")
|
||||
for _ in range(materialCount):
|
||||
material = A3DObjects.A3DMaterial()
|
||||
material.read2(stream)
|
||||
self.materials.append(material)
|
||||
|
||||
def 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)
|
||||
if signature != A3D_MATERIALBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid material data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading material block with {materialCount} materials and length {length}")
|
||||
for _ in range(materialCount):
|
||||
material = A3DObjects.A3DMaterial()
|
||||
material.read3(stream)
|
||||
self.materials.append(material)
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
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
|
||||
'''
|
||||
def readMeshBlock2(self, stream):
|
||||
# Verify signature
|
||||
signature, _, meshCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_MESHBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid mesh data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading mesh block with {meshCount} meshes")
|
||||
for _ in range(meshCount):
|
||||
mesh = A3DObjects.A3DMesh()
|
||||
mesh.read2(stream)
|
||||
self.meshes.append(mesh)
|
||||
|
||||
def 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)
|
||||
if signature != A3D_MESHBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid mesh data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading mesh block with {meshCount} meshes and length {length}")
|
||||
for _ in range(meshCount):
|
||||
mesh = A3DObjects.A3DMesh()
|
||||
mesh.read3(stream)
|
||||
self.meshes.append(mesh)
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
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
|
||||
'''
|
||||
def readTransformBlock2(self, stream):
|
||||
# Verify signature
|
||||
signature, _, transformCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid transform data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading transform block with {transformCount} transforms")
|
||||
for _ in range(transformCount):
|
||||
transform = A3DObjects.A3DTransform()
|
||||
transform.read2(stream)
|
||||
self.transforms.append(transform)
|
||||
# Read parent ids
|
||||
for _ in range(transformCount):
|
||||
parentID, = unpackStream("<i", stream)
|
||||
self.transformParentIDs.append(parentID)
|
||||
|
||||
def 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:
|
||||
raise RuntimeError(f"Invalid transform data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading transform block with {transformCount} transforms and length {length}")
|
||||
for _ in range(transformCount):
|
||||
transform = A3DObjects.A3DTransform()
|
||||
transform.read3(stream)
|
||||
self.transforms.append(transform)
|
||||
# Read parent ids
|
||||
for _ in range(transformCount):
|
||||
parentID, = unpackStream("<i", stream)
|
||||
self.transformParentIDs.append(parentID)
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
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
|
||||
'''
|
||||
def readObjectBlock2(self, stream):
|
||||
# Verify signature
|
||||
signature, _, objectCount = unpackStream("<3I", stream)
|
||||
if signature != A3D_OBJECTBLOCK_SIGNATURE:
|
||||
raise RuntimeError(f"Invalid object data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading object block with {objectCount} objects")
|
||||
for _ in range(objectCount):
|
||||
objec = A3DObjects.A3DObject()
|
||||
objec.read2(stream)
|
||||
self.objects.append(objec)
|
||||
|
||||
def 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:
|
||||
raise RuntimeError(f"Invalid object data block signature: {signature}")
|
||||
|
||||
# Read data
|
||||
print(f"Reading object block with {objectCount} objects and length {length}")
|
||||
for _ in range(objectCount):
|
||||
objec = A3DObjects.A3DObject()
|
||||
objec.read3(stream)
|
||||
self.objects.append(objec)
|
||||
|
||||
# Padding
|
||||
padding = calculatePadding(length)
|
||||
stream.read(padding)
|
||||
|
||||
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)
|
||||
@@ -1,40 +0,0 @@
|
||||
'''
|
||||
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
'''
|
||||
|
||||
class A3D3Mesh:
|
||||
def __init__(self, coordinates, uv1, normals, uv2, colors, unknown, submeshes):
|
||||
# Vertex data
|
||||
self.coordinates = coordinates
|
||||
self.uv1 = uv1
|
||||
self.normals = normals
|
||||
self.uv2 = uv2
|
||||
self.colors = colors
|
||||
self.unknown = unknown
|
||||
|
||||
self.submeshes = submeshes
|
||||
self.faces = [] # Aggregate of all submesh face data, easier for blender importing
|
||||
|
||||
# Object data
|
||||
self.name = ""
|
||||
self.transform = None
|
||||
|
||||
class A3D3Submesh:
|
||||
def __init__(self, faces, smoothingGroups, material):
|
||||
self.faces = faces
|
||||
self.smoothingGroups = smoothingGroups
|
||||
self.material = material
|
||||
|
||||
class A3D3Transform:
|
||||
def __init__(self, position, rotation, scale, name):
|
||||
self.position = position
|
||||
self.rotation = rotation
|
||||
self.scale = scale
|
||||
self.name = name
|
||||
self.parentID = 0
|
||||
@@ -1,157 +0,0 @@
|
||||
'''
|
||||
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
'''
|
||||
|
||||
from .A3DIOTools import unpackStream, readNullTerminatedString
|
||||
from .A3D3Shared import A3D3Mesh, A3D3Submesh, A3D3Transform
|
||||
|
||||
'''
|
||||
A3D version 3 type 2
|
||||
'''
|
||||
class A3D3_2:
|
||||
def __init__(self):
|
||||
# Object data
|
||||
self.materialNames = [] # Used to lookup names from materialID
|
||||
self.materials = {}
|
||||
self.meshes = []
|
||||
self.transforms = []
|
||||
|
||||
'''
|
||||
IO
|
||||
'''
|
||||
def readSubmesh(self, stream):
|
||||
print("Reading submesh")
|
||||
|
||||
faceCount, = unpackStream("<I", stream)
|
||||
faces = []
|
||||
for _ in range(faceCount):
|
||||
face = unpackStream("<3H", stream)
|
||||
faces.append(face)
|
||||
smoothGroups = []
|
||||
for _ in range(faceCount):
|
||||
smoothGroup, = unpackStream("<I", stream)
|
||||
smoothGroups.append(smoothGroup)
|
||||
materialID, = unpackStream("<H", stream)
|
||||
|
||||
material = self.materialNames[materialID]
|
||||
submesh = A3D3Submesh(faces, smoothGroups, material)
|
||||
return submesh
|
||||
|
||||
def readVertices(self, vertexCount, floatCount, stream):
|
||||
vertices = []
|
||||
for _ in range(vertexCount):
|
||||
vertex = unpackStream(f"{floatCount}f", stream)
|
||||
vertices.append(vertex)
|
||||
return vertices
|
||||
|
||||
def readMaterialBlock(self, stream):
|
||||
print("Reading material block")
|
||||
marker, _, materialCount = unpackStream("<3I", stream)
|
||||
if marker != 4:
|
||||
raise RuntimeError(f"Invalid material block marker: {marker}")
|
||||
|
||||
for _ in range(materialCount):
|
||||
materialName = readNullTerminatedString(stream)
|
||||
_ = unpackStream("3f", stream)
|
||||
diffuseMap = readNullTerminatedString(stream)
|
||||
|
||||
self.materialNames.append(materialName)
|
||||
self.materials[materialName] = diffuseMap
|
||||
|
||||
def readMeshBlock(self, stream):
|
||||
print("Reading mesh block")
|
||||
marker, _, meshCount = unpackStream("<3I", stream)
|
||||
if marker != 2:
|
||||
raise RuntimeError(f"Invalid mesh block marker: {marker}")
|
||||
|
||||
for meshI in range(meshCount):
|
||||
print(f"Reading mesh {meshI}")
|
||||
|
||||
# Vertices
|
||||
coordinates = []
|
||||
uv1 = []
|
||||
normals = []
|
||||
uv2 = []
|
||||
colors = []
|
||||
unknown = []
|
||||
|
||||
submeshes = []
|
||||
|
||||
# Read vertex buffers
|
||||
vertexCount, vertexBufferCount = unpackStream("<2I", stream)
|
||||
for vertexBufferI in range(vertexBufferCount):
|
||||
print(f"Reading vertex buffer {vertexBufferI} with {vertexCount} vertices")
|
||||
|
||||
bufferType, = unpackStream("<I", stream)
|
||||
if bufferType == 1:
|
||||
coordinates = self.readVertices(vertexCount, 3, stream)
|
||||
elif bufferType == 2:
|
||||
uv1 = self.readVertices(vertexCount, 2, stream)
|
||||
elif bufferType == 3:
|
||||
normals = self.readVertices(vertexCount, 3, stream)
|
||||
elif bufferType == 4:
|
||||
uv2 = self.readVertices(vertexCount, 2, stream)
|
||||
elif bufferType == 5:
|
||||
colors = self.readVertices(vertexCount, 4, stream)
|
||||
elif bufferType == 6:
|
||||
unknown = self.readVertices(vertexCount, 3, stream)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown vertex buffer type {bufferType}")
|
||||
|
||||
# Read submeshes
|
||||
submeshCount, = unpackStream("<I", stream)
|
||||
for _ in range(submeshCount):
|
||||
submesh = self.readSubmesh(stream)
|
||||
submeshes.append(submesh)
|
||||
|
||||
mesh = A3D3Mesh(coordinates, uv1, normals, uv2, colors, unknown, submeshes)
|
||||
mesh.faces += submesh.faces
|
||||
self.meshes.append(mesh)
|
||||
|
||||
def readTransformBlock(self, stream):
|
||||
print("Reading transform block")
|
||||
marker, _, transformCount = unpackStream("<3I", stream)
|
||||
if marker != 3:
|
||||
raise RuntimeError(f"Invalid transform block marker: {marker}")
|
||||
|
||||
for _ in range(transformCount):
|
||||
position = unpackStream("<3f", stream)
|
||||
rotation = unpackStream("<4f", stream)
|
||||
scale = unpackStream("<3f", stream)
|
||||
|
||||
transform = A3D3Transform(position, rotation, scale, "")
|
||||
self.transforms.append(transform)
|
||||
for transformI in range(transformCount):
|
||||
transformID, = unpackStream("<I", stream)
|
||||
self.transforms[transformI].id = transformID
|
||||
|
||||
# Heirarchy data
|
||||
def readObjectBlock(self, stream):
|
||||
print("Reading object block")
|
||||
marker, _, objectCount = unpackStream("<3I", stream)
|
||||
if marker != 5:
|
||||
raise RuntimeError(f"Invalid object block marker: {marker}")
|
||||
|
||||
for _ in range(objectCount):
|
||||
objectName = readNullTerminatedString(stream)
|
||||
meshID, transformID = unpackStream("<2I", stream)
|
||||
|
||||
self.meshes[meshID].transform = self.transforms[transformID]
|
||||
self.meshes[meshID].name = objectName
|
||||
|
||||
'''
|
||||
Drivers
|
||||
'''
|
||||
def read(self, stream):
|
||||
print("Reading A3D3 type 2")
|
||||
|
||||
self.readMaterialBlock(stream)
|
||||
self.readMeshBlock(stream)
|
||||
self.readTransformBlock(stream)
|
||||
self.readObjectBlock(stream)
|
||||
@@ -1,157 +0,0 @@
|
||||
'''
|
||||
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
'''
|
||||
|
||||
from .A3DIOTools import unpackStream, readString, calculatePadding
|
||||
from .A3D3Shared import A3D3Mesh, A3D3Submesh, A3D3Transform
|
||||
|
||||
'''
|
||||
A3D version 3 type 3
|
||||
'''
|
||||
class A3D3_3:
|
||||
def __init__(self):
|
||||
self.materials = {}
|
||||
self.materialNames = []
|
||||
self.meshes = []
|
||||
self.transforms = []
|
||||
|
||||
def readSubmesh(self, stream):
|
||||
print("Reading submesh")
|
||||
|
||||
indexCount, = unpackStream("<I", stream)
|
||||
faces = []
|
||||
for _ in range(indexCount//3):
|
||||
face = unpackStream("<3H", stream)
|
||||
faces.append(face)
|
||||
|
||||
paddingSize = calculatePadding(indexCount*2)
|
||||
stream.read(paddingSize)
|
||||
|
||||
submesh = A3D3Submesh(faces, [], 0) # XXX: Maybe this should be `None` instead of 0?
|
||||
return submesh
|
||||
|
||||
def readVertices(self, vertexCount, floatCount, stream):
|
||||
vertices = []
|
||||
for _ in range(vertexCount):
|
||||
vertex = unpackStream(f"{floatCount}f", stream)
|
||||
vertices.append(vertex)
|
||||
return vertices
|
||||
|
||||
def readMaterialBlock(self, stream):
|
||||
print("Reading material block")
|
||||
marker, _, materialCount = unpackStream("<3I", stream)
|
||||
if marker != 4:
|
||||
raise RuntimeError(f"Invalid material block marker: {marker}")
|
||||
|
||||
for _ in range(materialCount):
|
||||
materialName = readString(stream)
|
||||
floats = unpackStream("<3f", stream)
|
||||
|
||||
diffuseMap = readString(stream)
|
||||
print(f"{materialName} {floats} {diffuseMap}")
|
||||
|
||||
self.materialNames.append(materialName)
|
||||
self.materials[materialName] = diffuseMap
|
||||
|
||||
def readMeshBlock(self, stream):
|
||||
print("Reading mesh block")
|
||||
marker, _, meshCount = unpackStream("<3I", stream)
|
||||
if marker != 2:
|
||||
raise RuntimeError(f"Invalid mesh block marker: {marker}")
|
||||
|
||||
for meshI in range(meshCount):
|
||||
print(f"Reading mesh {meshI} @ {stream.tell()}")
|
||||
|
||||
# Vertices
|
||||
coordinates = []
|
||||
uv1 = []
|
||||
normals = []
|
||||
uv2 = []
|
||||
colors = []
|
||||
unknown = []
|
||||
|
||||
submeshes = []
|
||||
|
||||
meshName = readString(stream)
|
||||
unknownFloats = unpackStream("7f", stream)
|
||||
print(f"{meshName} {unknownFloats}")
|
||||
|
||||
# Read vertex buffers
|
||||
vertexCount, vertexBufferCount = unpackStream("<2I", stream)
|
||||
for vertexBufferI in range(vertexBufferCount):
|
||||
print(f"Reading vertex buffer {vertexBufferI} with {vertexCount} vertices")
|
||||
|
||||
bufferType, = unpackStream("<I", stream)
|
||||
if bufferType == 1:
|
||||
coordinates = self.readVertices(vertexCount, 3, stream)
|
||||
elif bufferType == 2:
|
||||
uv1 = self.readVertices(vertexCount, 2, stream)
|
||||
elif bufferType == 3:
|
||||
normals = self.readVertices(vertexCount, 3, stream)
|
||||
elif bufferType == 4:
|
||||
uv2 = self.readVertices(vertexCount, 2, stream)
|
||||
elif bufferType == 5:
|
||||
colors = self.readVertices(vertexCount, 4, stream)
|
||||
elif bufferType == 6:
|
||||
unknown = self.readVertices(vertexCount, 3, stream)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown vertex buffer type {bufferType}")
|
||||
|
||||
# Read submeshes
|
||||
submeshCount, = unpackStream("<I", stream)
|
||||
for _ in range(submeshCount):
|
||||
submesh = self.readSubmesh(stream)
|
||||
submeshes.append(submesh)
|
||||
|
||||
mesh = A3D3Mesh(coordinates, uv1, normals, uv2, colors, unknown, submeshes)
|
||||
mesh.faces += submesh.faces
|
||||
self.meshes.append(mesh)
|
||||
|
||||
def readTransformBlock(self, stream):
|
||||
print("Reading transform block")
|
||||
marker, _, transformCount = unpackStream("<3I", stream)
|
||||
if marker != 3:
|
||||
raise RuntimeError(f"Invalid transform block marker: {marker}")
|
||||
|
||||
for _ in range(transformCount):
|
||||
name = readString(stream)
|
||||
position = unpackStream("<3f", stream)
|
||||
rotation = unpackStream("<4f", stream)
|
||||
scale = unpackStream("<3f", stream)
|
||||
|
||||
print(f"{name} {position} {rotation} {scale}")
|
||||
transform = A3D3Transform(position, rotation, scale, name)
|
||||
self.transforms.append(transform)
|
||||
for transformI in range(transformCount):
|
||||
transformID, = unpackStream("<I", stream)
|
||||
self.transforms[transformI].id = transformID
|
||||
|
||||
# Heirarchy data
|
||||
def readObjectBlock(self, stream):
|
||||
print("Reading object block")
|
||||
marker, _, objectCount = unpackStream("<3I", stream)
|
||||
if marker != 5:
|
||||
raise RuntimeError(f"Invalid object block marker: {marker}")
|
||||
|
||||
for _ in range(objectCount):
|
||||
meshID, transformID, materialCount = unpackStream("<3I", stream)
|
||||
for materialI in range(materialCount):
|
||||
materialID, = unpackStream("<i", stream)
|
||||
if materialID >= 0:
|
||||
self.meshes[meshID].submeshes[materialI].material = self.materialNames[materialID]
|
||||
|
||||
self.meshes[meshID].transform = self.transforms[transformID]
|
||||
|
||||
def read(self, stream):
|
||||
print("Reading A3D3 type 3")
|
||||
|
||||
self.readMaterialBlock(stream)
|
||||
self.readMeshBlock(stream)
|
||||
self.readTransformBlock(stream)
|
||||
self.readObjectBlock(stream)
|
||||
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
|
||||
@@ -1,38 +0,0 @@
|
||||
'''
|
||||
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
'''
|
||||
|
||||
from struct import unpack, calcsize
|
||||
|
||||
def unpackStream(format, stream):
|
||||
size = calcsize(format)
|
||||
data = stream.read(size)
|
||||
return unpack(format, data)
|
||||
|
||||
def readNullTerminatedString(stream):
|
||||
string = b""
|
||||
char = stream.read(1)
|
||||
while char != b"\x00":
|
||||
string += char
|
||||
char = stream.read(1)
|
||||
return string.decode("utf8")
|
||||
|
||||
def calculatePadding(length):
|
||||
# (it basically works with rounding)
|
||||
paddingSize = (((length + 3) // 4) * 4) - length
|
||||
return paddingSize
|
||||
|
||||
def readString(stream):
|
||||
length, = unpackStream("<I", stream)
|
||||
string = stream.read(length)
|
||||
|
||||
paddingSize = calculatePadding(length)
|
||||
stream.read(paddingSize)
|
||||
|
||||
return string.decode("utf8")
|
||||
291
io_scene_a3d/A3DObjects.py
Normal file
@@ -0,0 +1,291 @@
|
||||
'''
|
||||
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from .IOTools import unpackStream, packStream, readNullTerminatedString, writeNullTerminatedString, readLengthPrefixedString, writeLengthPrefixedString, calculatePadding
|
||||
|
||||
class A3DMaterial:
|
||||
def __init__(self):
|
||||
self.name = ""
|
||||
self.color = (0.0, 0.0, 0.0)
|
||||
self.diffuseMap = ""
|
||||
|
||||
def read2(self, stream):
|
||||
self.name = readNullTerminatedString(stream)
|
||||
self.color = unpackStream("<3f", stream)
|
||||
self.diffuseMap = readNullTerminatedString(stream)
|
||||
|
||||
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
|
||||
|
||||
def write2(self, stream):
|
||||
writeNullTerminatedString(stream, self.name)
|
||||
colorR, colorG, colorB = self.color
|
||||
packStream("<3f", stream, colorR, colorG, colorB)
|
||||
writeNullTerminatedString(stream, self.diffuseMap)
|
||||
|
||||
def read3(self, stream):
|
||||
self.name = readLengthPrefixedString(stream)
|
||||
self.color = unpackStream("<3f", stream)
|
||||
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 = ""
|
||||
self.bboxMax = None
|
||||
self.bboxMin = None
|
||||
self.vertexBuffers = []
|
||||
self.submeshes = []
|
||||
|
||||
self.vertexCount = 0
|
||||
self.vertexBufferCount = 0
|
||||
self.submeshCount = 0
|
||||
|
||||
def read2(self, stream):
|
||||
# Read vertex buffers
|
||||
self.vertexCount, self.vertexBufferCount = unpackStream("<2I", stream)
|
||||
for _ in range(self.vertexBufferCount):
|
||||
vertexBuffer = A3DVertexBuffer()
|
||||
vertexBuffer.read2(self.vertexCount, stream)
|
||||
self.vertexBuffers.append(vertexBuffer)
|
||||
|
||||
# Read submeshes
|
||||
self.submeshCount, = unpackStream("<I", stream)
|
||||
for _ in range(self.submeshCount):
|
||||
submesh = A3DSubmesh()
|
||||
submesh.read2(stream)
|
||||
self.submeshes.append(submesh)
|
||||
|
||||
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
|
||||
|
||||
def 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)
|
||||
# XXX: bbox order maybe incorrect, check this (might be min then max and not max then min)
|
||||
self.bboxMax = unpackStream("<3f", stream)
|
||||
self.bboxMin = unpackStream("<3f", stream)
|
||||
stream.read(4) # XXX: Unknown float value
|
||||
|
||||
# Read vertex buffers
|
||||
self.vertexCount, self.vertexBufferCount = unpackStream("<2I", stream)
|
||||
for _ in range(self.vertexBufferCount):
|
||||
vertexBuffer = A3DVertexBuffer()
|
||||
vertexBuffer.read2(self.vertexCount, stream)
|
||||
self.vertexBuffers.append(vertexBuffer)
|
||||
|
||||
# Read submeshes
|
||||
self.submeshCount, = unpackStream("<I", stream)
|
||||
for _ in range(self.submeshCount):
|
||||
submesh = A3DSubmesh()
|
||||
submesh.read3(stream)
|
||||
self.submeshes.append(submesh)
|
||||
|
||||
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
|
||||
|
||||
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
|
||||
A3D_VERTEXTYPE_UV2 = 4
|
||||
A3D_VERTEXTYPE_COLOR = 5
|
||||
A3D_VERTEXTYPE_NORMAL2 = 6
|
||||
# LUT for vertex buffer types -> vertex size
|
||||
A3DVertexSize = {
|
||||
A3D_VERTEXTYPE_COORDINATE: 3,
|
||||
A3D_VERTEXTYPE_UV1: 2,
|
||||
A3D_VERTEXTYPE_NORMAL1: 3,
|
||||
A3D_VERTEXTYPE_UV2: 2,
|
||||
A3D_VERTEXTYPE_COLOR: 4,
|
||||
A3D_VERTEXTYPE_NORMAL2: 3
|
||||
}
|
||||
class A3DVertexBuffer:
|
||||
def __init__(self):
|
||||
self.data = []
|
||||
self.bufferType = None
|
||||
|
||||
def read2(self, vertexCount, stream):
|
||||
self.bufferType, = unpackStream("<I", stream)
|
||||
if not (self.bufferType in A3DVertexSize.keys()):
|
||||
raise RuntimeError(f"Unknown vertex buffer type: {self.bufferType}")
|
||||
for _ in range(vertexCount):
|
||||
vertexSize = A3DVertexSize[self.bufferType]
|
||||
vertex = unpackStream(f"<{vertexSize}f", stream)
|
||||
self.data.append(vertex)
|
||||
|
||||
print(f"[A3DVertexBuffer data: {len(self.data)} buffer type: {self.bufferType}]")
|
||||
|
||||
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 = []
|
||||
self.smoothingGroups = []
|
||||
self.materialID = None
|
||||
|
||||
self.indexCount = 0
|
||||
|
||||
def read2(self, stream):
|
||||
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
|
||||
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 = ""
|
||||
self.position = (0.0, 0.0, 0.0)
|
||||
self.rotation = (0.0, 0.0, 0.0, 0.0)
|
||||
self.scale = (0.0, 0.0, 0.0)
|
||||
|
||||
def read2(self, stream):
|
||||
self.position = unpackStream("<3f", stream)
|
||||
self.rotation = unpackStream("<4f", stream)
|
||||
self.scale = unpackStream("<3f", stream)
|
||||
|
||||
print(f"[A3DTransform position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
|
||||
|
||||
def 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)
|
||||
self.rotation = unpackStream("<4f", stream)
|
||||
self.scale = unpackStream("<3f", stream)
|
||||
|
||||
print(f"[A3DTransform name: {self.name} position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
|
||||
|
||||
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 = ""
|
||||
self.meshID = None
|
||||
self.transformID = None
|
||||
self.materialIDs = []
|
||||
|
||||
self.materialCount = 0
|
||||
|
||||
def read2(self, stream):
|
||||
self.name = readNullTerminatedString(stream)
|
||||
self.meshID, self.transformID = unpackStream("<2I", stream)
|
||||
|
||||
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
|
||||
|
||||
def 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)
|
||||
|
||||
# Read material IDs
|
||||
for _ in range(self.materialCount):
|
||||
materialID, = unpackStream("<i", stream)
|
||||
self.materialIDs.append(materialID)
|
||||
|
||||
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
|
||||
|
||||
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)
|
||||
445
io_scene_a3d/BattleMapBlenderImporter.py
Normal file
@@ -0,0 +1,445 @@
|
||||
'''
|
||||
Copyright (c) 2025 Pyogenics <https://github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from json import load
|
||||
|
||||
import bpy
|
||||
from bpy_extras.image_utils import load_image
|
||||
from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
|
||||
import bmesh
|
||||
from mathutils import Matrix
|
||||
|
||||
from .A3D import A3D
|
||||
from .A3DBlenderImporter import A3DBlenderImporter
|
||||
from .BlenderMaterialUtils import addImageTextureToMaterial, decodeIntColorToTuple
|
||||
|
||||
class BattleMapBlenderImporter:
|
||||
# Allows subsequent map loads to be faster
|
||||
libraryCache = {}
|
||||
|
||||
def __init__(self, mapData, lightmapData, propLibrarySourcePath, map_scale_factor=0.01, import_static_geom=True, import_collision_geom=False, import_spawn_points=False, import_lightmapdata=False):
|
||||
self.mapData = mapData
|
||||
self.lightmapData = lightmapData
|
||||
self.propLibrarySourcePath = propLibrarySourcePath
|
||||
self.map_scale_factor = map_scale_factor
|
||||
self.import_static_geom = import_static_geom
|
||||
self.import_collision_geom = import_collision_geom
|
||||
self.import_spawn_points = import_spawn_points
|
||||
self.import_lightmapdata = import_lightmapdata
|
||||
|
||||
# Cache for collision meshes, don't cache triangles because they are set using unique vertices
|
||||
self.collisionPlaneMesh = None
|
||||
self.collisionBoxMesh = None
|
||||
|
||||
self.materials = {}
|
||||
|
||||
def importData(self):
|
||||
print("Importing BattleMap data into blender")
|
||||
|
||||
# Process materials
|
||||
for materialData in self.mapData.materials:
|
||||
ma = self.createBlenderMaterial(materialData)
|
||||
self.materials[materialData.ID] = ma
|
||||
|
||||
# Static geometry
|
||||
propObjects = []
|
||||
if self.import_static_geom:
|
||||
# Load props
|
||||
for propData in self.mapData.staticGeometry:
|
||||
ob = self.getBlenderProp(propData)
|
||||
propObjects.append(ob)
|
||||
print(f"Loaded {len(propObjects)} prop objects")
|
||||
|
||||
# Collision geometry
|
||||
collisionObjects = []
|
||||
if self.import_collision_geom:
|
||||
# Create collision meshes
|
||||
self.collisionPlaneMesh = bpy.data.meshes.new("collisionPlane")
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1.0)
|
||||
bm.to_mesh(self.collisionPlaneMesh)
|
||||
bm.free()
|
||||
|
||||
self.collisionBoxMesh = bpy.data.meshes.new("collisionBox")
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_cube(bm)
|
||||
bm.to_mesh(self.collisionBoxMesh)
|
||||
bm.free()
|
||||
|
||||
# Load collision meshes
|
||||
collisionTriangles = self.mapData.collisionGeometry.triangles + self.mapData.collisionGeometryOutsideGamingZone.triangles
|
||||
collisionTriangleObjects = self.createBlenderCollisionTriangles(collisionTriangles)
|
||||
collisionPlanes = self.mapData.collisionGeometry.planes + self.mapData.collisionGeometryOutsideGamingZone.planes
|
||||
collisionPlaneObjects = self.createBlenderCollisionPlanes(collisionPlanes)
|
||||
collisionBoxes = self.mapData.collisionGeometry.boxes + self.mapData.collisionGeometryOutsideGamingZone.boxes
|
||||
collisionBoxObjects = self.createBlenderCollisionBoxes(collisionBoxes)
|
||||
|
||||
collisionObjects += collisionTriangleObjects
|
||||
collisionObjects += collisionPlaneObjects
|
||||
collisionObjects += collisionBoxObjects
|
||||
print(f"Loaded {len(collisionObjects)} collision objects")
|
||||
|
||||
# Spawn points
|
||||
spawnPointObjects = []
|
||||
if self.import_spawn_points:
|
||||
# Create spawn points
|
||||
for spawnPointData in self.mapData.spawnPoints:
|
||||
ob = self.createBlenderSpawnPoint(spawnPointData)
|
||||
spawnPointObjects.append(ob)
|
||||
print(f"Loaded {len(spawnPointObjects)} spawn points")
|
||||
|
||||
# Create container object to store all our objects
|
||||
objects = propObjects + collisionObjects + spawnPointObjects
|
||||
mapOB = bpy.data.objects.new("BattleMap", None)
|
||||
mapOB.empty_display_size = 100 # Alternativa use a x100 scale
|
||||
mapOB.scale = (self.map_scale_factor, self.map_scale_factor, self.map_scale_factor)
|
||||
objects.append(mapOB)
|
||||
|
||||
# Create empty objects to group each type of object
|
||||
if self.import_static_geom:
|
||||
groupOB = bpy.data.objects.new("StaticGeometry", None)
|
||||
groupOB.parent = mapOB
|
||||
objects.append(groupOB)
|
||||
for ob in propObjects:
|
||||
ob.parent = groupOB
|
||||
if self.import_collision_geom:
|
||||
groupOB = bpy.data.objects.new("CollisionGeometry", None)
|
||||
groupOB.parent = mapOB
|
||||
objects.append(groupOB)
|
||||
for ob in collisionObjects:
|
||||
ob.parent = groupOB
|
||||
if self.import_spawn_points:
|
||||
groupOB = bpy.data.objects.new("SpawnPoints", None)
|
||||
groupOB.parent = mapOB
|
||||
objects.append(groupOB)
|
||||
for ob in spawnPointObjects:
|
||||
ob.parent = groupOB
|
||||
|
||||
# Lighting data
|
||||
if self.import_lightmapdata:
|
||||
# Create a sun light object
|
||||
li = bpy.data.lights.new("DirectionalLight", "SUN")
|
||||
li.color = decodeIntColorToTuple(self.lightmapData.lightColour)
|
||||
|
||||
ob = bpy.data.objects.new(li.name, li)
|
||||
ob.location = (0.0, 0.0, 1000.0) # Just place it like 10 meters off the ground (in alternativa units)
|
||||
lightAngleX, lightAngleZ = self.lightmapData.lightAngle
|
||||
ob.rotation_mode = "XYZ"
|
||||
ob.rotation_euler = (lightAngleX, 0.0, lightAngleZ)
|
||||
|
||||
ob.parent = mapOB
|
||||
objects.append(ob)
|
||||
|
||||
# Set ambient world light
|
||||
scene = bpy.context.scene
|
||||
if scene.world == None:
|
||||
wd = bpy.data.worlds.new("map")
|
||||
scene.world = wd
|
||||
world = scene.world
|
||||
world.use_nodes = False
|
||||
world.color = decodeIntColorToTuple(self.lightmapData.ambientLightColour)
|
||||
|
||||
return objects
|
||||
|
||||
def getPropLibrary(self, libraryName):
|
||||
# First check if we've already loaded the required prop library
|
||||
if not libraryName in self.libraryCache:
|
||||
# Load the proplib
|
||||
libraryPath = f"{self.propLibrarySourcePath}/{libraryName}"
|
||||
library = PropLibrary(libraryPath)
|
||||
self.libraryCache[libraryName] = library
|
||||
|
||||
return self.libraryCache[libraryName]
|
||||
|
||||
def tryLoadTexture(self, textureName, libraryName):
|
||||
if libraryName == None:
|
||||
# For some reason Remaster proplib is alwaus marked as None? This is not true for the ny2024 remaster prop lib though
|
||||
libraryName = "Remaster"
|
||||
|
||||
propLibrary = self.getPropLibrary(libraryName)
|
||||
texture = propLibrary.getTexture(f"{textureName}.webp")
|
||||
return texture
|
||||
|
||||
'''
|
||||
Blender data builders
|
||||
'''
|
||||
def getBlenderProp(self, propData):
|
||||
# Load prop
|
||||
propLibrary = self.getPropLibrary(propData.libraryName)
|
||||
prop = propLibrary.getProp(propData.name, propData.groupName)
|
||||
propOB = prop.mainObject.copy() # We want to use a copy of the prop object
|
||||
|
||||
# Assign data
|
||||
propOB.name = f"{propData.name}_{propData.ID}"
|
||||
propOB.location = propData.position
|
||||
propOB.rotation_mode = "XYZ"
|
||||
propRotation = propData.rotation
|
||||
if propRotation == None:
|
||||
propRotation = (0.0, 0.0, 0.0)
|
||||
propOB.rotation_euler = propRotation
|
||||
propScale = propData.scale
|
||||
if propScale == None:
|
||||
propScale = (1.0, 1.0, 1.0)
|
||||
propOB.scale = propScale
|
||||
|
||||
# Lighting info
|
||||
if self.import_lightmapdata:
|
||||
lightingMapObject = None
|
||||
for mapObject in self.lightmapData.mapObjects:
|
||||
if mapObject.index == propData.ID:
|
||||
lightingMapObject = mapObject
|
||||
break
|
||||
if lightingMapObject != None:
|
||||
#XXX: do something with lightingMapObject.recieveShadows??
|
||||
propOB.visible_shadow = lightingMapObject.castShadows
|
||||
|
||||
# Material
|
||||
ma = self.materials[propData.materialID]
|
||||
if len(propOB.data.materials) != 0:
|
||||
# Create a duplicate mesh object if it needs a different material, XXX: could probably cache these to reuse datablocks
|
||||
if propOB.data.materials[0] != ma:
|
||||
propOB.data = propOB.data.copy()
|
||||
propOB.data.materials[0] = ma
|
||||
|
||||
return propOB
|
||||
|
||||
def createBlenderCollisionTriangles(self, collisionTriangles):
|
||||
objects = []
|
||||
for collisionTriangle in collisionTriangles:
|
||||
# Create the mesh
|
||||
me = bpy.data.meshes.new("collisionTriangle")
|
||||
|
||||
# Create array for coordinate data, blender doesn't like tuples
|
||||
vertices = []
|
||||
vertices += collisionTriangle.v0
|
||||
vertices += collisionTriangle.v1
|
||||
vertices += collisionTriangle.v2
|
||||
|
||||
# Assign coordinates
|
||||
me.vertices.add(3)
|
||||
me.vertices.foreach_set("co", vertices)
|
||||
me.loops.add(3)
|
||||
me.loops.foreach_set("vertex_index", [0, 1, 2])
|
||||
me.polygons.add(1)
|
||||
me.polygons.foreach_set("loop_start", [0])
|
||||
|
||||
me.validate()
|
||||
me.update()
|
||||
|
||||
# Create object
|
||||
ob = bpy.data.objects.new("collisionTriangle", me)
|
||||
ob.location = collisionTriangle.position
|
||||
ob.rotation_mode = "XYZ"
|
||||
ob.rotation_euler = collisionTriangle.rotation
|
||||
#print(collisionTriangle.length) # XXX: how to handle collisionTriangle.length?
|
||||
|
||||
objects.append(ob)
|
||||
|
||||
return objects
|
||||
|
||||
def createBlenderCollisionPlanes(self, collisionPlanes):
|
||||
objects = []
|
||||
for collisionPlane in collisionPlanes:
|
||||
# Create object
|
||||
ob = bpy.data.objects.new("collisionPlane", self.collisionPlaneMesh)
|
||||
ob.location = collisionPlane.position
|
||||
ob.rotation_mode = "XYZ"
|
||||
ob.rotation_euler = collisionPlane.rotation
|
||||
ob.scale = (collisionPlane.width*0.5, collisionPlane.length*0.5, 1.0) # Unsure why they double the width and length, could be because of central origin?
|
||||
|
||||
objects.append(ob)
|
||||
|
||||
return objects
|
||||
|
||||
def createBlenderCollisionBoxes(self, collisionBoxes):
|
||||
objects = []
|
||||
for collisionBox in collisionBoxes:
|
||||
# Create object
|
||||
ob = bpy.data.objects.new("collisionBox", self.collisionBoxMesh)
|
||||
ob.location = collisionBox.position
|
||||
ob.rotation_mode = "XYZ"
|
||||
ob.rotation_euler = collisionBox.rotation
|
||||
ob.scale = collisionBox.size
|
||||
|
||||
objects.append(ob)
|
||||
|
||||
return objects
|
||||
|
||||
def createBlenderSpawnPoint(self, spawnPointData):
|
||||
#TODO: implement spawn type name lookup
|
||||
ob = bpy.data.objects.new(f"SpawnPoint_{spawnPointData.type}", None)
|
||||
ob.empty_display_type = "ARROWS"
|
||||
ob.empty_display_size = 100 # The map will be at 100x scale so it's a good idea to match that here
|
||||
ob.location = spawnPointData.position
|
||||
ob.rotation_mode = "XYZ"
|
||||
ob.rotation_euler = spawnPointData.rotation
|
||||
|
||||
return ob
|
||||
|
||||
def createBlenderMaterial(self, materialData):
|
||||
ma = bpy.data.materials.new(f"{materialData.ID}_{materialData.name}")
|
||||
|
||||
# Shader specific logic
|
||||
if materialData.shader == "TankiOnline/SingleTextureShader" or materialData.shader == "TankiOnline/SingleTextureShaderWinter":
|
||||
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||
bsdf.roughness_set(1.0)
|
||||
bsdf.ior_set(1.0)
|
||||
|
||||
# Try load texture
|
||||
textureParameter = materialData.textureParameters[0]
|
||||
texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName)
|
||||
|
||||
addImageTextureToMaterial(texture, ma.node_tree)
|
||||
elif materialData.shader == "TankiOnline/SpriteShader":
|
||||
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||
bsdf.roughness_set(1.0)
|
||||
bsdf.ior_set(1.0)
|
||||
|
||||
# Try load texture
|
||||
textureParameter = materialData.textureParameters[0]
|
||||
texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName)
|
||||
|
||||
addImageTextureToMaterial(texture, ma.node_tree, linkAlpha=True)
|
||||
elif materialData.shader == "TankiOnline/Terrain":
|
||||
# XXX: still need to figure out how to do the terrain properly, all manual attempts have yielded mixed results
|
||||
bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
|
||||
bsdf.roughness_set(1.0)
|
||||
bsdf.ior_set(1.0)
|
||||
bsdf.base_color_set((0.0, 0.0, 0.0))
|
||||
else:
|
||||
pass # Unknown shader
|
||||
|
||||
return ma
|
||||
|
||||
class PropLibrary:
|
||||
propGroups = {}
|
||||
def __init__(self, directory):
|
||||
self.directory = directory
|
||||
self.libraryInfo = {}
|
||||
|
||||
# Load library info
|
||||
with open(f"{self.directory}/library.json", "r") as file: self.libraryInfo = load(file)
|
||||
print(f"Loaded prop library: " + self.libraryInfo["name"])
|
||||
|
||||
def getProp(self, propName, groupName):
|
||||
# Create the prop group if it's not already loaded
|
||||
if not groupName in self.propGroups:
|
||||
self.propGroups[groupName] = {}
|
||||
|
||||
# Load the prop if it's not already loaded
|
||||
if not propName in self.propGroups[groupName]:
|
||||
# Find the prop group
|
||||
groupInfo = None
|
||||
for group in self.libraryInfo["groups"]:
|
||||
if group["name"] == groupName:
|
||||
groupInfo = group
|
||||
break
|
||||
if groupInfo == None:
|
||||
raise RuntimeError(f"Unable to find prop group with name {groupName} in " + self.libraryInfo["name"])
|
||||
|
||||
# Find the prop
|
||||
propInfo = None
|
||||
for prop in groupInfo["props"]:
|
||||
if prop["name"] == propName:
|
||||
propInfo = prop
|
||||
break
|
||||
if propInfo == None:
|
||||
raise RuntimeError(f"Unable to find prop with name {propName} in {groupName} from " + self.libraryInfo["name"])
|
||||
|
||||
# Create the prop
|
||||
prop = Prop()
|
||||
meshInfo = propInfo.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 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)
|
||||
68
io_scene_a3d/IOTools.py
Normal file
@@ -0,0 +1,68 @@
|
||||
'''
|
||||
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
from struct import unpack, 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("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)
|
||||
paddingSize = (((length + 3) // 4) * 4) - length
|
||||
return paddingSize
|
||||
|
||||
def readLengthPrefixedString(stream):
|
||||
length, = unpackStream("<I", stream)
|
||||
string = stream.read(length)
|
||||
|
||||
paddingSize = calculatePadding(length)
|
||||
stream.read(paddingSize)
|
||||
|
||||
return string.decode("utf8", errors="ignore")
|
||||
|
||||
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,196 +1,259 @@
|
||||
'''
|
||||
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
|
||||
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
'''
|
||||
|
||||
bl_info = {
|
||||
"name": "Modern A3D",
|
||||
"description": "Support for modern a3d models",
|
||||
"author": "Pyogenics, https://www.github.com/Pyogenics",
|
||||
"version": (1, 0, 0),
|
||||
"blender": (4, 0, 0),
|
||||
"location": "File > Import-Export",
|
||||
"category": "Import-Export"
|
||||
}
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
from bpy.types import Operator
|
||||
from bpy.props import StringProperty
|
||||
from bpy_extras.io_utils import ImportHelper
|
||||
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 .A3D3_2 import A3D3_2
|
||||
from .A3D3_3 import A3D3_3
|
||||
from .A3DIOTools import unpackStream
|
||||
from .A3D import A3D
|
||||
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
|
||||
'''
|
||||
class ImportA3DModern(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.a3dmodern"
|
||||
class ImportA3D(Operator, ImportHelper):
|
||||
bl_idname = "import_scene.alternativa"
|
||||
bl_label = "Import A3D"
|
||||
bl_description = "Import an A3D model"
|
||||
bl_options = {'PRESET', 'UNDO'}
|
||||
|
||||
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
|
||||
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
|
||||
files: CollectionProperty(type=OperatorFileListElement, options={'HIDDEN', 'SKIP_SAVE'})
|
||||
|
||||
# User options
|
||||
create_collection: BoolProperty(name="Create collection", description="Create a collection to hold all the model objects", default=False)
|
||||
try_import_textures: BoolProperty(name="Search for textures", description="Automatically search for lightmap, track and wheel textures and attempt to apply them", default=True)
|
||||
reset_empty_transform: BoolProperty(name="Reset empty transforms", description="Reset rotation and scale if it is set to 0, more useful for version 2 models like props", default=True)
|
||||
|
||||
def draw(self, context):
|
||||
import_panel_options_a3d(self.layout, self)
|
||||
|
||||
def invoke(self, context, event):
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
def execute(self, context):
|
||||
filepath = self.filepath
|
||||
print(f"Importing A3D scene from {filepath}")
|
||||
importStartTime = time()
|
||||
|
||||
objects = []
|
||||
for file in self.files:
|
||||
filepath = f"{self.directory}/{file.name}"
|
||||
# Read the file
|
||||
print(f"Reading A3D data from {filepath}")
|
||||
modelData = A3D()
|
||||
with open(filepath, "rb") as file:
|
||||
modelData.read(file)
|
||||
|
||||
with open(filepath, "rb") as file:
|
||||
signature = file.read(4)
|
||||
if signature != b"A3D\0":
|
||||
raise RuntimeError(f"Invalid A3D signature: {signature}")
|
||||
|
||||
variant, _, rootBlockMarker, _ = unpackStream("<2H2I", file)
|
||||
if rootBlockMarker != 1:
|
||||
raise RuntimeError(f"Invalid root block marker: {rootBlockMarker}")
|
||||
|
||||
if variant == 3:
|
||||
a3d = A3D3_3()
|
||||
a3d.read(file)
|
||||
# Import data into blender
|
||||
modelImporter = A3DBlenderImporter(modelData, self.directory, self.reset_empty_transform, self.try_import_textures)
|
||||
objects += modelImporter.importData()
|
||||
|
||||
for mesh in a3d.meshes:
|
||||
blenderMesh = self.createBlenderMeshMin(mesh)
|
||||
blenderObject = bpy.data.objects.new(mesh.name, blenderMesh)
|
||||
bpy.context.collection.objects.link(blenderObject)
|
||||
elif variant == 2:
|
||||
a3d = A3D3_2()
|
||||
a3d.read(file)
|
||||
# Link objects to collection
|
||||
collection = bpy.context.collection
|
||||
if self.create_collection:
|
||||
collection = bpy.data.collections.new("Collection")
|
||||
bpy.context.collection.children.link(collection)
|
||||
for obI, ob in enumerate(objects):
|
||||
collection.objects.link(ob)
|
||||
|
||||
# Create our materials
|
||||
materials = {}
|
||||
for materialName in a3d.materialNames:
|
||||
materials[materialName] = bpy.data.materials.new(materialName)
|
||||
|
||||
a3dMesh = a3d.meshes[0]
|
||||
blenderMesh = self.createBlenderMesh(a3dMesh, materials)
|
||||
blenderObject = bpy.data.objects.new(a3dMesh.name, blenderMesh)
|
||||
bpy.context.collection.objects.link(blenderObject)
|
||||
elif variant == 1:
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
|
||||
self.report({"INFO"}, f"Loaded A3D")
|
||||
importEndTime = time()
|
||||
self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s")
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
def createBlenderMeshMin(self, mesh):
|
||||
me = bpy.data.meshes.new(mesh.name)
|
||||
bm = bmesh.new()
|
||||
class ExportA3D(Operator, ExportHelper):
|
||||
bl_idname = "export_scene.alternativa"
|
||||
bl_label = "Export A3D"
|
||||
bl_description = "Export an A3D model"
|
||||
bl_options = {'PRESET', 'UNDO'}
|
||||
|
||||
for coord in mesh.coordinates:
|
||||
bm.verts.new(coord)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.verts.index_update()
|
||||
for face in mesh.faces:
|
||||
v1, v2, v3 = face
|
||||
bm.faces.new([
|
||||
bm.verts[v1],
|
||||
bm.verts[v2],
|
||||
bm.verts[v3]
|
||||
])
|
||||
filter_glob: StringProperty(default="*.a3d", options={'HIDDEN'})
|
||||
filename_ext: StringProperty(default=".a3d", options={'HIDDEN'})
|
||||
|
||||
layers = []
|
||||
if len(mesh.uv1) != 0:
|
||||
layers.append(
|
||||
(bm.loops.layers.uv.new("UV1"), mesh.uv1)
|
||||
)
|
||||
print("has UV1")
|
||||
if len(mesh.uv2) != 0:
|
||||
layers.append(
|
||||
(bm.loops.layers.uv.new("UV2"), mesh.uv2)
|
||||
)
|
||||
print("has UV2")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
for uvLayer, uvData in layers: loop[uvLayer].uv = uvData[loop.vert.index]
|
||||
loop.vert.normal = mesh.normals[loop.vert.index]
|
||||
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"
|
||||
)
|
||||
|
||||
bm.to_mesh(me)
|
||||
me.update()
|
||||
def draw(self, context):
|
||||
export_panel_options_a3d(self.layout, self)
|
||||
|
||||
return me
|
||||
def invoke(self, context, event):
|
||||
return ExportHelper.invoke(self, context, event)
|
||||
|
||||
def execute(self, context):
|
||||
print(f"Exporting blender data to {self.filepath}")
|
||||
|
||||
def createBlenderMesh(self, mesh, materials):
|
||||
me = bpy.data.meshes.new(mesh.name)
|
||||
bm = bmesh.new()
|
||||
modelData = A3D()
|
||||
modelExporter = A3DBlenderExporter(modelData, bpy.context.selected_objects, version=int(self.a3d_version))
|
||||
modelExporter.exportData()
|
||||
|
||||
for coord in mesh.coordinates:
|
||||
bm.verts.new(coord)
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm.verts.index_update()
|
||||
for face in mesh.faces:
|
||||
v1, v2, v3 = face
|
||||
bm.faces.new([
|
||||
bm.verts[v1],
|
||||
bm.verts[v2],
|
||||
bm.verts[v3]
|
||||
])
|
||||
# Write file
|
||||
with open(self.filepath, "wb") as file:
|
||||
modelData.write(file, version=int(self.a3d_version))
|
||||
|
||||
layers = []
|
||||
if len(mesh.uv1) != 0:
|
||||
layers.append(
|
||||
(bm.loops.layers.uv.new("UV1"), mesh.uv1)
|
||||
)
|
||||
print("has UV1")
|
||||
if len(mesh.uv2) != 0:
|
||||
layers.append(
|
||||
(bm.loops.layers.uv.new("UV2"), mesh.uv2)
|
||||
)
|
||||
print("has UV2")
|
||||
for face in bm.faces:
|
||||
for loop in face.loops:
|
||||
for uvLayer, uvData in layers: loop[uvLayer].uv = uvData[loop.vert.index]
|
||||
loop.vert.normal = mesh.normals[loop.vert.index]
|
||||
return {"FINISHED"}
|
||||
|
||||
bm.to_mesh(me)
|
||||
me.update()
|
||||
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'}
|
||||
|
||||
# Materials
|
||||
for submesh in mesh.submeshes:
|
||||
material = materials[submesh.material]
|
||||
me.materials.append(material)
|
||||
materialI = len(me.materials) - 1
|
||||
for polygon in me.polygons:
|
||||
polygon.material_index = materialI
|
||||
filter_glob: StringProperty(default="*.bin", options={'HIDDEN'})
|
||||
directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'})
|
||||
|
||||
return me
|
||||
# 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
|
||||
preferences = context.preferences.addons[__package__].preferences
|
||||
if not isdir(preferences.propLibrarySourcePath):
|
||||
raise RuntimeError("Please set a valid prop library folder in addon properties!")
|
||||
mapImporter = BattleMapBlenderImporter(mapData, lightmapData, preferences.propLibrarySourcePath, self.map_scale_factor, self.import_static_geom, self.import_collision_geom, self.import_spawn_points, self.import_lightmapdata)
|
||||
objects = mapImporter.importData()
|
||||
|
||||
# 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(ImportA3DModern.bl_idname, text="A3D Modern")
|
||||
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)")
|
||||
|
||||
'''
|
||||
Register
|
||||
Registration
|
||||
'''
|
||||
classes = {
|
||||
ImportA3DModern
|
||||
}
|
||||
classes = [
|
||||
Preferences,
|
||||
ImportA3D,
|
||||
ExportA3D,
|
||||
ImportBattleMap
|
||||
]
|
||||
|
||||
def register():
|
||||
# Register classes
|
||||
for c in classes:
|
||||
bpy.utils.register_class(c)
|
||||
# File > Import-Export
|
||||
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d)
|
||||
# bpy.types.TOPBAR_MT_file_export.append(menu_func_export_dava)
|
||||
bpy.types.TOPBAR_MT_file_export.append(menu_func_export_a3d)
|
||||
bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap)
|
||||
|
||||
def unregister():
|
||||
# Unregister classes
|
||||
for c in classes:
|
||||
bpy.utils.unregister_class(c)
|
||||
# Remove `File > Import-Export`
|
||||
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d)
|
||||
# bpy.types.TOPBAR_MT_file_export.remove(menu_func_export_dava)
|
||||
bpy.types.TOPBAR_MT_file_export.remove(menu_func_export_a3d)
|
||||
bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap)
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
@@ -1,38 +0,0 @@
|
||||
'''
|
||||
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
'''
|
||||
|
||||
from .A3DIOTools import unpackStream
|
||||
from .A3D3_2 import A3D3_2
|
||||
from .A3D3_3 import A3D3_3
|
||||
|
||||
def readA3D(file):
|
||||
signature = file.read(4)
|
||||
if signature != b"A3D\0":
|
||||
raise RuntimeError(f"Invalid A3D signature: {signature}")
|
||||
|
||||
variant, _, rootBlockMarker, _ = unpackStream("<2H2I", file)
|
||||
if rootBlockMarker != 1:
|
||||
raise RuntimeError(f"Invalid root block marker: {rootBlockMarker}")
|
||||
|
||||
if variant == 3:
|
||||
a3d = A3D3_3()
|
||||
a3d.read(file)
|
||||
elif variant == 2:
|
||||
a3d = A3D3_2()
|
||||
a3d.read(file)
|
||||
elif variant == 1:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError(f"Unknown A3D variant: {variant}")
|
||||
|
||||
from sys import argv
|
||||
if __name__ == "__main__":
|
||||
with open(argv[1], "rb") as file:
|
||||
readA3D(file)
|
||||
36
io_scene_a3d/blender_manifest.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "alternativa3d_tanki_format"
|
||||
version = "1.0.0"
|
||||
name = "Alternativa3D file format (Tanki Online HTML5)"
|
||||
tagline = "Import-Export Alternativa3D 3D models used by Tanki Online HTML5"
|
||||
maintainer = "Pyogenics <https://github.com/Pyogenics>"
|
||||
type = "add-on"
|
||||
|
||||
website = "https://github.com/MapMakersAndProgrammers/io_scene_a3d"
|
||||
|
||||
tags = ["Import-Export"]
|
||||
|
||||
blender_version_min = "4.2.0"
|
||||
|
||||
license = [
|
||||
"SPDX:MIT",
|
||||
]
|
||||
copyright = [
|
||||
"2024 Pyogenics",
|
||||
]
|
||||
|
||||
# wheels = [
|
||||
# ]
|
||||
|
||||
[permissions]
|
||||
files = "Import-Export Alternativa3D 3D model files"
|
||||
|
||||
# [build]
|
||||
# # These are the default build excluded patterns.
|
||||
# # You only need to edit them if you want different options.
|
||||
# paths_exclude_pattern = [
|
||||
# "__pycache__/",
|
||||
# "/.git/",
|
||||
# "/*.zip",
|
||||
# ]
|
||||