8 Commits

Author SHA1 Message Date
Pyogenics
11fdb83627 Ignore invalid strings, some models use them for some reason 2024-12-09 13:53:29 +00:00
Pyogenics
e4e395b6e1 Initial blender importing 2024-12-02 18:19:23 +00:00
Pyogenics
643d23d6e7 Store array count fields instead of relying on len 2024-11-25 14:27:24 +00:00
Pyogenics
696a65e5a2 Add A3D version 3 support 2024-11-24 18:34:10 +00:00
Pyogenics
e8fd653c80 Add new A3D2 reading 2024-11-23 17:39:51 +00:00
Pyogenics
019b0c54e8 Add pycache to gitignore 2024-11-21 20:49:58 +00:00
Pyogenics
eb9e774592 Create fresh plugin using new extension system 2024-11-21 19:05:25 +00:00
Pyogenics
647c665566 Update readme 2024-10-26 23:14:13 +01:00
15 changed files with 680 additions and 585 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -1,7 +1,10 @@
# io_scene_a3d
Blender plugin to load A3D 3.2 and 3.3 models (3.1 not supported), 3.2 is most complete 3.3 is not so complete; this code will eventually be merged into the alternativa3d_tools github repo.
# WIP io_scene_a3d
Blender plugin to import the proprietary model format `A3D` used by the game [Tanki Online](https://tankionline.com/en/) from [Alternativa Games](https://alternativa.games/), it is not compatible with older the formats used by the flash based Alternativa3D engine (see [this plugin by Davide Jones](https://github.com/davidejones/alternativa3d_tools) instead).
The code can read all the A3D3 data but not all of it is imported yet, some material data + transforms + some vertex data and the codebase could use a cleanup.
## File format
Check the wiki for file format documentation.
- https://github.com/davidejones/alternativa3d_tools/issues/9
- https://github.com/davidejones/alternativa3d_tools
## Demo
![Terrain model showcase](./images/demo1.png)<br>
![UV and material surface showcase](./images/demo2.png)<br>
![Complex multi object model showcase](./images/demo3.png)

BIN
images/demo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

BIN
images/demo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

BIN
images/demo3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

245
io_scene_a3d/A3D.py Normal file
View File

@@ -0,0 +1,245 @@
'''
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from .IOTools import unpackStream, readNullTerminatedString, calculatePadding
from . import A3DObjects
'''
A3D constants
'''
A3D_SIGNATURE = b"A3D\0"
A3D_ROOTBLOCK_SIGNATURE = 1
A3D_MATERIALBLOCK_SIGNATURE = 4
A3D_MESHBLOCK_SIGNATURE = 2
A3D_TRANSFORMBLOCK_SIGNATURE = 3
A3D_OBJECTBLOCK_SIGNATURE = 5
'''
A3D model object
'''
class A3D:
def __init__(self):
self.materials = []
self.meshes = []
self.transforms = {}
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
version, _ = unpackStream("<2H", stream) # Likely major.minor version code
print(f"Reading A3D version {version}")
if version == 1:
self.readRootBlock1(stream)
elif version == 2:
self.readRootBlock2(stream)
elif version == 3:
self.readRootBlock3(stream)
'''
Root data blocks
'''
def readRootBlock1(self, stream):
raise RuntimeError("Version 1 files are not supported yet")
def readRootBlock2(self, stream):
# Verify signature
signature, _ = unpackStream("<2I", stream)
if signature != A3D_ROOTBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid root data block signature: {signature}")
# Read data
print(f"Reading root block")
self.readMaterialBlock2(stream)
self.readMeshBlock2(stream)
self.readTransformBlock2(stream)
self.readObjectBlock2(stream)
def readRootBlock3(self, stream):
# Verify signature
signature, length = unpackStream("<2I", stream)
if signature != A3D_ROOTBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid root data block signature: {signature}")
# Read data
self.readMaterialBlock3(stream)
self.readMeshBlock3(stream)
self.readTransformBlock3(stream)
self.readObjectBlock3(stream)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Material data blocks
'''
def readMaterialBlock2(self, stream):
# Verify signature
signature, _, materialCount = unpackStream("<3I", stream)
if signature != A3D_MATERIALBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid material data block signature: {signature}")
# Read data
print(f"Reading material block with {materialCount} materials")
for _ in range(materialCount):
material = A3DObjects.A3DMaterial()
material.read2(stream)
self.materials.append(material)
def readMaterialBlock3(self, stream):
# Verify signature
signature, length, materialCount = unpackStream("<3I", stream)
if signature != A3D_MATERIALBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid material data block signature: {signature}")
# Read data
print(f"Reading material block with {materialCount} materials and length {length}")
for _ in range(materialCount):
material = A3DObjects.A3DMaterial()
material.read3(stream)
self.materials.append(material)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Mesh data blocks
'''
def readMeshBlock2(self, stream):
# Verify signature
signature, _, meshCount = unpackStream("<3I", stream)
if signature != A3D_MESHBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid mesh data block signature: {signature}")
# Read data
print(f"Reading mesh block with {meshCount} meshes")
for _ in range(meshCount):
mesh = A3DObjects.A3DMesh()
mesh.read2(stream)
self.meshes.append(mesh)
def readMeshBlock3(self, stream):
# Verify signature
signature, length, meshCount = unpackStream("<3I", stream)
if signature != A3D_MESHBLOCK_SIGNATURE:
raise RuntimeError(f"Invalid mesh data block signature: {signature}")
# Read data
print(f"Reading mesh block with {meshCount} meshes and length {length}")
for _ in range(meshCount):
mesh = A3DObjects.A3DMesh()
mesh.read3(stream)
self.meshes.append(mesh)
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Transform data blocks
'''
def readTransformBlock2(self, stream):
# Verify signature
signature, _, transformCount = unpackStream("<3I", stream)
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
print(f"{stream.tell()}")
raise RuntimeError(f"Invalid transform data block signature: {signature}")
# Read data
print(f"Reading transform block with {transformCount} transforms")
transforms = []
for _ in range(transformCount):
transform = A3DObjects.A3DTransform()
transform.read2(stream)
transforms.append(transform)
# Read and assign transform ids
for transformI in range(transformCount):
transformID, = unpackStream("<I", stream)
self.transforms[transformID] = transforms[transformI]
def readTransformBlock3(self, stream):
# Verify signature
signature, length, transformCount = unpackStream("<3I", stream)
if signature != A3D_TRANSFORMBLOCK_SIGNATURE:
print(f"{stream.tell()}")
raise RuntimeError(f"Invalid transform data block signature: {signature}")
# Read data
print(f"Reading transform block with {transformCount} transforms and length {length}")
transforms = []
for _ in range(transformCount):
transform = A3DObjects.A3DTransform()
transform.read3(stream)
transforms.append(transform)
# Read and assign transform ids
for transformI in range(transformCount):
transformID, = unpackStream("<I", stream)
self.transforms[transformI] = transforms[transformI] #XXX: The IDs seem to be incorrect and instead map to index?
# Padding
padding = calculatePadding(length)
stream.read(padding)
'''
Object data blocks
'''
def readObjectBlock2(self, stream):
# Verify signature
signature, _, objectCount = unpackStream("<3I", stream)
if signature != A3D_OBJECTBLOCK_SIGNATURE:
print(f"{stream.tell()}")
raise RuntimeError(f"Invalid object data block signature: {signature}")
# Read data
print(f"Reading object block with {objectCount} objects")
for _ in range(objectCount):
objec = A3DObjects.A3DObject()
objec.read2(stream)
self.objects.append(objec)
def readObjectBlock3(self, stream):
# Verify signature
signature, length, objectCount = unpackStream("<3I", stream)
if signature != A3D_OBJECTBLOCK_SIGNATURE:
print(f"{stream.tell()}")
raise RuntimeError(f"Invalid object data block signature: {signature}")
# Read data
print(f"Reading object block with {objectCount} objects and length {length}")
for _ in range(objectCount):
objec = A3DObjects.A3DObject()
objec.read3(stream)
self.objects.append(objec)
# Padding
padding = calculatePadding(length)
stream.read(padding)

View File

@@ -1,40 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
'''
class A3D3Mesh:
def __init__(self, coordinates, uv1, normals, uv2, colors, unknown, submeshes):
# Vertex data
self.coordinates = coordinates
self.uv1 = uv1
self.normals = normals
self.uv2 = uv2
self.colors = colors
self.unknown = unknown
self.submeshes = submeshes
self.faces = [] # Aggregate of all submesh face data, easier for blender importing
# Object data
self.name = ""
self.transform = None
class A3D3Submesh:
def __init__(self, faces, smoothingGroups, material):
self.faces = faces
self.smoothingGroups = smoothingGroups
self.material = material
class A3D3Transform:
def __init__(self, position, rotation, scale, name):
self.position = position
self.rotation = rotation
self.scale = scale
self.name = name
self.parentID = 0

View File

@@ -1,157 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
'''
from .A3DIOTools import unpackStream, readNullTerminatedString
from .A3D3Shared import A3D3Mesh, A3D3Submesh, A3D3Transform
'''
A3D version 3 type 2
'''
class A3D3_2:
def __init__(self):
# Object data
self.materialNames = [] # Used to lookup names from materialID
self.materials = {}
self.meshes = []
self.transforms = []
'''
IO
'''
def readSubmesh(self, stream):
print("Reading submesh")
faceCount, = unpackStream("<I", stream)
faces = []
for _ in range(faceCount):
face = unpackStream("<3H", stream)
faces.append(face)
smoothGroups = []
for _ in range(faceCount):
smoothGroup, = unpackStream("<I", stream)
smoothGroups.append(smoothGroup)
materialID, = unpackStream("<H", stream)
material = self.materialNames[materialID]
submesh = A3D3Submesh(faces, smoothGroups, material)
return submesh
def readVertices(self, vertexCount, floatCount, stream):
vertices = []
for _ in range(vertexCount):
vertex = unpackStream(f"{floatCount}f", stream)
vertices.append(vertex)
return vertices
def readMaterialBlock(self, stream):
print("Reading material block")
marker, _, materialCount = unpackStream("<3I", stream)
if marker != 4:
raise RuntimeError(f"Invalid material block marker: {marker}")
for _ in range(materialCount):
materialName = readNullTerminatedString(stream)
_ = unpackStream("3f", stream)
diffuseMap = readNullTerminatedString(stream)
self.materialNames.append(materialName)
self.materials[materialName] = diffuseMap
def readMeshBlock(self, stream):
print("Reading mesh block")
marker, _, meshCount = unpackStream("<3I", stream)
if marker != 2:
raise RuntimeError(f"Invalid mesh block marker: {marker}")
for meshI in range(meshCount):
print(f"Reading mesh {meshI}")
# Vertices
coordinates = []
uv1 = []
normals = []
uv2 = []
colors = []
unknown = []
submeshes = []
# Read vertex buffers
vertexCount, vertexBufferCount = unpackStream("<2I", stream)
for vertexBufferI in range(vertexBufferCount):
print(f"Reading vertex buffer {vertexBufferI} with {vertexCount} vertices")
bufferType, = unpackStream("<I", stream)
if bufferType == 1:
coordinates = self.readVertices(vertexCount, 3, stream)
elif bufferType == 2:
uv1 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 3:
normals = self.readVertices(vertexCount, 3, stream)
elif bufferType == 4:
uv2 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 5:
colors = self.readVertices(vertexCount, 4, stream)
elif bufferType == 6:
unknown = self.readVertices(vertexCount, 3, stream)
else:
raise RuntimeError(f"Unknown vertex buffer type {bufferType}")
# Read submeshes
submeshCount, = unpackStream("<I", stream)
for _ in range(submeshCount):
submesh = self.readSubmesh(stream)
submeshes.append(submesh)
mesh = A3D3Mesh(coordinates, uv1, normals, uv2, colors, unknown, submeshes)
mesh.faces += submesh.faces
self.meshes.append(mesh)
def readTransformBlock(self, stream):
print("Reading transform block")
marker, _, transformCount = unpackStream("<3I", stream)
if marker != 3:
raise RuntimeError(f"Invalid transform block marker: {marker}")
for _ in range(transformCount):
position = unpackStream("<3f", stream)
rotation = unpackStream("<4f", stream)
scale = unpackStream("<3f", stream)
transform = A3D3Transform(position, rotation, scale, "")
self.transforms.append(transform)
for transformI in range(transformCount):
transformID, = unpackStream("<I", stream)
self.transforms[transformI].id = transformID
# Heirarchy data
def readObjectBlock(self, stream):
print("Reading object block")
marker, _, objectCount = unpackStream("<3I", stream)
if marker != 5:
raise RuntimeError(f"Invalid object block marker: {marker}")
for _ in range(objectCount):
objectName = readNullTerminatedString(stream)
meshID, transformID = unpackStream("<2I", stream)
self.meshes[meshID].transform = self.transforms[transformID]
self.meshes[meshID].name = objectName
'''
Drivers
'''
def read(self, stream):
print("Reading A3D3 type 2")
self.readMaterialBlock(stream)
self.readMeshBlock(stream)
self.readTransformBlock(stream)
self.readObjectBlock(stream)

View File

@@ -1,157 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
'''
from .A3DIOTools import unpackStream, readString, calculatePadding
from .A3D3Shared import A3D3Mesh, A3D3Submesh, A3D3Transform
'''
A3D version 3 type 3
'''
class A3D3_3:
def __init__(self):
self.materials = {}
self.materialNames = []
self.meshes = []
self.transforms = []
def readSubmesh(self, stream):
print("Reading submesh")
indexCount, = unpackStream("<I", stream)
faces = []
for _ in range(indexCount//3):
face = unpackStream("<3H", stream)
faces.append(face)
paddingSize = calculatePadding(indexCount*2)
stream.read(paddingSize)
submesh = A3D3Submesh(faces, [], 0) # XXX: Maybe this should be `None` instead of 0?
return submesh
def readVertices(self, vertexCount, floatCount, stream):
vertices = []
for _ in range(vertexCount):
vertex = unpackStream(f"{floatCount}f", stream)
vertices.append(vertex)
return vertices
def readMaterialBlock(self, stream):
print("Reading material block")
marker, _, materialCount = unpackStream("<3I", stream)
if marker != 4:
raise RuntimeError(f"Invalid material block marker: {marker}")
for _ in range(materialCount):
materialName = readString(stream)
floats = unpackStream("<3f", stream)
diffuseMap = readString(stream)
print(f"{materialName} {floats} {diffuseMap}")
self.materialNames.append(materialName)
self.materials[materialName] = diffuseMap
def readMeshBlock(self, stream):
print("Reading mesh block")
marker, _, meshCount = unpackStream("<3I", stream)
if marker != 2:
raise RuntimeError(f"Invalid mesh block marker: {marker}")
for meshI in range(meshCount):
print(f"Reading mesh {meshI} @ {stream.tell()}")
# Vertices
coordinates = []
uv1 = []
normals = []
uv2 = []
colors = []
unknown = []
submeshes = []
meshName = readString(stream)
unknownFloats = unpackStream("7f", stream)
print(f"{meshName} {unknownFloats}")
# Read vertex buffers
vertexCount, vertexBufferCount = unpackStream("<2I", stream)
for vertexBufferI in range(vertexBufferCount):
print(f"Reading vertex buffer {vertexBufferI} with {vertexCount} vertices")
bufferType, = unpackStream("<I", stream)
if bufferType == 1:
coordinates = self.readVertices(vertexCount, 3, stream)
elif bufferType == 2:
uv1 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 3:
normals = self.readVertices(vertexCount, 3, stream)
elif bufferType == 4:
uv2 = self.readVertices(vertexCount, 2, stream)
elif bufferType == 5:
colors = self.readVertices(vertexCount, 4, stream)
elif bufferType == 6:
unknown = self.readVertices(vertexCount, 3, stream)
else:
raise RuntimeError(f"Unknown vertex buffer type {bufferType}")
# Read submeshes
submeshCount, = unpackStream("<I", stream)
for _ in range(submeshCount):
submesh = self.readSubmesh(stream)
submeshes.append(submesh)
mesh = A3D3Mesh(coordinates, uv1, normals, uv2, colors, unknown, submeshes)
mesh.faces += submesh.faces
self.meshes.append(mesh)
def readTransformBlock(self, stream):
print("Reading transform block")
marker, _, transformCount = unpackStream("<3I", stream)
if marker != 3:
raise RuntimeError(f"Invalid transform block marker: {marker}")
for _ in range(transformCount):
name = readString(stream)
position = unpackStream("<3f", stream)
rotation = unpackStream("<4f", stream)
scale = unpackStream("<3f", stream)
print(f"{name} {position} {rotation} {scale}")
transform = A3D3Transform(position, rotation, scale, name)
self.transforms.append(transform)
for transformI in range(transformCount):
transformID, = unpackStream("<I", stream)
self.transforms[transformI].id = transformID
# Heirarchy data
def readObjectBlock(self, stream):
print("Reading object block")
marker, _, objectCount = unpackStream("<3I", stream)
if marker != 5:
raise RuntimeError(f"Invalid object block marker: {marker}")
for _ in range(objectCount):
meshID, transformID, materialCount = unpackStream("<3I", stream)
for materialI in range(materialCount):
materialID, = unpackStream("<i", stream)
if materialID >= 0:
self.meshes[meshID].submeshes[materialI].material = self.materialNames[materialID]
self.meshes[meshID].transform = self.transforms[transformID]
def read(self, stream):
print("Reading A3D3 type 3")
self.readMaterialBlock(stream)
self.readMeshBlock(stream)
self.readTransformBlock(stream)
self.readObjectBlock(stream)

View File

@@ -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")

203
io_scene_a3d/A3DObjects.py Normal file
View File

@@ -0,0 +1,203 @@
'''
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from .IOTools import unpackStream, readNullTerminatedString, readLengthPrefixedString, calculatePadding
class A3DMaterial:
def __init__(self):
self.name = ""
self.color = (0.0, 0.0, 0.0)
self.diffuseMap = ""
def read2(self, stream):
self.name = readNullTerminatedString(stream)
self.color = unpackStream("<3f", stream)
self.diffuseMap = readNullTerminatedString(stream)
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
def read3(self, stream):
print(stream.tell())
self.name = readLengthPrefixedString(stream)
self.color = unpackStream("<3f", stream)
self.diffuseMap = readLengthPrefixedString(stream)
print(f"[A3DMaterial name: {self.name} color: {self.color} diffuse map: {self.diffuseMap}]")
class A3DMesh:
def __init__(self):
self.name = ""
self.bboxMax = None
self.bboxMin = None
self.vertexBuffers = []
self.submeshes = []
self.vertexCount = 0
self.vertexBufferCount = 0
self.submeshCount = 0
def read2(self, stream):
# Read vertex buffers
self.vertexCount, self.vertexBufferCount = unpackStream("<2I", stream)
for _ in range(self.vertexBufferCount):
vertexBuffer = A3DVertexBuffer()
vertexBuffer.read2(self.vertexCount, stream)
self.vertexBuffers.append(vertexBuffer)
# Read submeshes
self.submeshCount, = unpackStream("<I", stream)
for _ in range(self.submeshCount):
submesh = A3DSubmesh()
submesh.read2(stream)
self.submeshes.append(submesh)
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
def read3(self, stream):
# Read mesh info
self.name = readLengthPrefixedString(stream)
# XXX: bbox order maybe incorrect, check this (might be min then max and not max then min)
self.bboxMax = unpackStream("<3f", stream)
self.bboxMin = unpackStream("<3f", stream)
stream.read(4) # XXX: Unknown float value
# Read vertex buffers
self.vertexCount, self.vertexBufferCount = unpackStream("<2I", stream)
for _ in range(self.vertexBufferCount):
vertexBuffer = A3DVertexBuffer()
vertexBuffer.read2(self.vertexCount, stream)
self.vertexBuffers.append(vertexBuffer)
# Read submeshes
self.submeshCount, = unpackStream("<I", stream)
for _ in range(self.submeshCount):
submesh = A3DSubmesh()
submesh.read3(stream)
self.submeshes.append(submesh)
print(f"[A3DMesh name: {self.name} bbox max: {self.bboxMax} bbox min: {self.bboxMin} vertex buffers: {len(self.vertexBuffers)} submeshes: {len(self.submeshes)}]")
A3D_VERTEXTYPE_COORDINATE = 1
A3D_VERTEXTYPE_UV1 = 2
A3D_VERTEXTYPE_NORMAL1 = 3
A3D_VERTEXTYPE_UV2 = 4
A3D_VERTEXTYPE_COLOR = 5
A3D_VERTEXTYPE_NORMAL2 = 6
# LUT for vertex buffer types -> vertex size
A3DVertexSize = {
A3D_VERTEXTYPE_COORDINATE: 3,
A3D_VERTEXTYPE_UV1: 2,
A3D_VERTEXTYPE_NORMAL1: 3,
A3D_VERTEXTYPE_UV2: 2,
A3D_VERTEXTYPE_COLOR: 4,
A3D_VERTEXTYPE_NORMAL2: 3
}
class A3DVertexBuffer:
def __init__(self):
self.data = []
self.bufferType = None
def read2(self, vertexCount, stream):
self.bufferType, = unpackStream("<I", stream)
if not (self.bufferType in A3DVertexSize.keys()):
raise RuntimeError(f"Unknown vertex buffer type: {self.bufferType}")
for _ in range(vertexCount):
vertexSize = A3DVertexSize[self.bufferType]
vertex = unpackStream(f"<{vertexSize}f", stream)
self.data.append(vertex)
print(f"[A3DVertexBuffer data: {len(self.data)} buffer type: {self.bufferType}]")
class A3DSubmesh:
def __init__(self):
self.indices = []
self.smoothingGroups = []
self.materialID = None
self.indexCount = 0
def read2(self, stream):
self.indexCount, = unpackStream("<I", stream) # This is just the face count so multiply it by 3
self.indexCount *= 3
self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
self.smoothingGroups = list(unpackStream(f"<{self.indexCount//3}I", stream))
self.materialID, = unpackStream("<H", stream)
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
def read3(self, stream):
# Read indices
self.indexCount, = unpackStream("<I", stream)
self.indices = list(unpackStream(f"<{self.indexCount}H", stream))
# Padding
padding = calculatePadding(self.indexCount*2) # Each index is 2 bytes
stream.read(padding)
print(f"[A3DSubmesh indices: {len(self.indices)} smoothing groups: {len(self.smoothingGroups)} materialID: {self.materialID}]")
class A3DTransform:
def __init__(self):
self.name = ""
self.position = (0.0, 0.0, 0.0)
self.rotation = (0.0, 0.0, 0.0, 0.0)
self.scale = (0.0, 0.0, 0.0)
def read2(self, stream):
self.position = unpackStream("<3f", stream)
self.rotation = unpackStream("<4f", stream)
self.scale = unpackStream("<3f", stream)
print(f"[A3DTransform position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
def read3(self, stream):
self.name = readLengthPrefixedString(stream)
self.position = unpackStream("<3f", stream)
self.rotation = unpackStream("<4f", stream)
self.scale = unpackStream("<3f", stream)
print(f"[A3DTransform name: {self.name} position: {self.position} rotation: {self.rotation} scale: {self.scale}]")
class A3DObject:
def __init__(self):
self.name = ""
self.meshID = None
self.transformID = None
self.materialIDs = []
self.materialCount = 0
def read2(self, stream):
self.name = readNullTerminatedString(stream)
self.meshID, self.transformID = unpackStream("<2I", stream)
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")
def read3(self, stream):
self.meshID, self.transformID, self.materialCount = unpackStream("<3I", stream)
# Read material IDs
for _ in range(self.materialCount):
materialID, = unpackStream("<i", stream)
self.materialIDs.append(materialID)
print(f"[A3DObject name: {self.name} meshID: {self.meshID} transformID: {self.transformID} materialIDs: {len(self.materialIDs)}]")

50
io_scene_a3d/IOTools.py Normal file
View File

@@ -0,0 +1,50 @@
'''
Copyright (c) 2024 Pyogenics <https://github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
'''
from struct import unpack, calcsize
def unpackStream(format, stream):
size = calcsize(format)
data = stream.read(size)
return unpack(format, data)
def readNullTerminatedString(stream):
string = b""
char = stream.read(1)
while char != b"\x00":
string += char
char = stream.read(1)
return string.decode("utf8", errors="ignore")
def calculatePadding(length):
# (it basically works with rounding)
paddingSize = (((length + 3) // 4) * 4) - length
return paddingSize
def readLengthPrefixedString(stream):
length, = unpackStream("<I", stream)
string = stream.read(length)
paddingSize = calculatePadding(length)
stream.read(paddingSize)
return string.decode("utf8", errors="ignore")

View File

@@ -1,38 +1,25 @@
'''
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.
'''
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.props import StringProperty, BoolProperty
from bpy_extras.io_utils import ImportHelper
from bpy_extras.node_shader_utils import PrincipledBSDFWrapper
from .A3D3_2 import A3D3_2
from .A3D3_3 import A3D3_3
from .A3DIOTools import unpackStream
from .A3D import A3D
from .A3DObjects import (
A3D_VERTEXTYPE_COORDINATE,
A3D_VERTEXTYPE_UV1,
A3D_VERTEXTYPE_NORMAL1,
A3D_VERTEXTYPE_UV2,
A3D_VERTEXTYPE_COLOR,
A3D_VERTEXTYPE_NORMAL2
)
'''
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"
@@ -43,154 +30,154 @@ class ImportA3DModern(Operator, ImportHelper):
def execute(self, context):
filepath = self.filepath
print(f"Importing A3D scene from {filepath}")
# Read the file
print(f"Reading A3D data from {filepath}")
modelData = A3D()
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)
modelData.read(file)
# Import data into blender
print("Importing mesh data into blender")
# Create materials
materials = []
for material in modelData.materials:
ma = bpy.data.materials.new(material.name)
maWrapper = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True)
maWrapper.base_color = material.color
maWrapper.roughness = 1.0
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)
materials.append(ma)
# Build meshes
meshes = []
for mesh in modelData.meshes:
me = bpy.data.meshes.new(mesh.name)
# Create our materials
materials = {}
for materialName in a3d.materialNames:
materials[materialName] = bpy.data.materials.new(materialName)
# Gather all vertex data
coordinates = []
uv1 = []
normal1 = []
uv2 = []
colors = []
normal2 = []
for vertexBuffer in mesh.vertexBuffers:
if vertexBuffer.bufferType == A3D_VERTEXTYPE_COORDINATE:
coordinates += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_UV1:
uv1 += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_NORMAL1:
normal1 += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_UV2:
uv2 += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_COLOR:
colors += vertexBuffer.data
elif vertexBuffer.bufferType == A3D_VERTEXTYPE_NORMAL2:
normal2 += vertexBuffer.data
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
# Add blender vertices
blenderVertexIndices = []
blenderVertices = []
blenderUV1s = []
blenderUV2s = []
for submesh in mesh.submeshes:
polygonCount = len(submesh.indices) // 3
me.vertices.add(polygonCount*3)
me.loops.add(polygonCount*3)
me.polygons.add(polygonCount)
for indexI in range(submesh.indexCount):
index = submesh.indices[indexI]
blenderVertexIndices.append(indexI)
blenderVertices += list(coordinates[index])
blenderUV1s.append(uv1[index])
#blenderUV2s += uv2[index]
me.vertices.foreach_set("co", blenderVertices)
me.polygons.foreach_set("loop_start", range(0, len(blenderVertices)//3, 3))
me.loops.foreach_set("vertex_index", blenderVertexIndices)
# UVs
if len(uv1) != 0:
uvData = me.uv_layers.new(name="UV1").data
for polygonI, po in enumerate(me.polygons):
indexI = polygonI * 3
uvData[po.loop_start].uv = blenderUV1s[blenderVertexIndices[indexI]]
uvData[po.loop_start+1].uv = blenderUV1s[blenderVertexIndices[indexI+1]]
uvData[po.loop_start+2].uv = blenderUV1s[blenderVertexIndices[indexI+2]]
# Apply materials (version 2)
faceIndexBase = 0
for submeshI, submesh in enumerate(mesh.submeshes):
if submesh.materialID == None:
continue
me.materials.append(materials[submesh.materialID])
for faceI in range(submesh.indexCount//3):
me.polygons[faceI+faceIndexBase].material_index = submeshI
faceIndexBase += submesh.indexCount//3
# Finalise
me.validate()
me.update()
meshes.append(me)
# Create objects
for objec in modelData.objects:
me = meshes[objec.meshID]
mesh = modelData.meshes[objec.meshID]
transform = modelData.transforms[objec.transformID]
# Select a name for the blender object
name = ""
if objec.name != "":
name = objec.name
elif mesh.name != "":
name = mesh.name
else:
pass
name = transform.name
self.report({"INFO"}, f"Loaded A3D")
# Create the object
ob = bpy.data.objects.new(name, me)
bpy.context.collection.objects.link(ob)
# Set transform
ob.location = transform.position
ob.scale = transform.scale
ob.rotation_mode = "QUATERNION"
x, y, z, w = transform.rotation
ob.rotation_quaternion = (w, x, y, z)
# Apply materials (version 3)
for materialID in objec.materialIDs:
print(materialID)
if materialID == -1:
continue
me.materials.append(materials[materialID])
# Set the default material to the first one we added
for polygon in me.polygons:
polygon.material_index = 0
return {"FINISHED"}
def createBlenderMeshMin(self, mesh):
me = bpy.data.meshes.new(mesh.name)
bm = bmesh.new()
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]
])
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]
bm.to_mesh(me)
me.update()
return me
def createBlenderMesh(self, mesh, materials):
me = bpy.data.meshes.new(mesh.name)
bm = bmesh.new()
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]
])
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]
bm.to_mesh(me)
me.update()
# Materials
for submesh in mesh.submeshes:
material = materials[submesh.material]
me.materials.append(material)
materialI = len(me.materials) - 1
for polygon in me.polygons:
polygon.material_index = materialI
return me
'''
Menu
'''
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)")
'''
Register
Registration
'''
classes = {
ImportA3DModern
}
classes = [
ImportA3D
]
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)
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)
if __name__ == "__main__":
register()

View File

@@ -1,38 +0,0 @@
'''
Copyright (C) 2024 Pyogenics <https://www.github.com/Pyogenics>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
'''
from .A3DIOTools import unpackStream
from .A3D3_2 import A3D3_2
from .A3D3_3 import A3D3_3
def readA3D(file):
signature = file.read(4)
if signature != b"A3D\0":
raise RuntimeError(f"Invalid A3D signature: {signature}")
variant, _, rootBlockMarker, _ = unpackStream("<2H2I", file)
if rootBlockMarker != 1:
raise RuntimeError(f"Invalid root block marker: {rootBlockMarker}")
if variant == 3:
a3d = A3D3_3()
a3d.read(file)
elif variant == 2:
a3d = A3D3_2()
a3d.read(file)
elif variant == 1:
pass
else:
raise RuntimeError(f"Unknown A3D variant: {variant}")
from sys import argv
if __name__ == "__main__":
with open(argv[1], "rb") as file:
readA3D(file)

View File

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