diff --git a/images/demo7.png b/images/demo7.png new file mode 100644 index 0000000..131cac8 Binary files /dev/null and b/images/demo7.png differ diff --git a/images/demo8.png b/images/demo8.png new file mode 100644 index 0000000..63c440a Binary files /dev/null and b/images/demo8.png differ diff --git a/io_scene_a3d/A3DBlenderImporter.py b/io_scene_a3d/A3DBlenderImporter.py index cf47b7e..c0be3c2 100644 --- a/io_scene_a3d/A3DBlenderImporter.py +++ b/io_scene_a3d/A3DBlenderImporter.py @@ -32,21 +32,7 @@ from .A3DObjects import ( A3D_VERTEXTYPE_COLOR, A3D_VERTEXTYPE_NORMAL2 ) - -def addImageTextureToMaterial(image, node_tree): - nodes = node_tree.nodes - links = node_tree.links - - # Check if this material already has a texture on it - if len(nodes) > 2: - return - - # Create nodes - principledBSDFNode = nodes[0] - textureNode = nodes.new(type="ShaderNodeTexImage") - links.new(textureNode.outputs["Color"], principledBSDFNode.inputs["Base Color"]) - # Apply image - if image != None: textureNode.image = image +from .BlenderMaterialUtils import addImageTextureToMaterial def mirrorUVY(uv): x, y = uv @@ -171,8 +157,16 @@ class A3DBlenderImporter: me.polygons[faceI+faceIndexBase].material_index = submeshI faceIndexBase += submesh.indexCount//3 - # Finalise + #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 diff --git a/io_scene_a3d/AlternativaProtocol.py b/io_scene_a3d/AlternativaProtocol.py new file mode 100644 index 0000000..101180c --- /dev/null +++ b/io_scene_a3d/AlternativaProtocol.py @@ -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) diff --git a/io_scene_a3d/BattleMap.py b/io_scene_a3d/BattleMap.py new file mode 100644 index 0000000..1f0f24c --- /dev/null +++ b/io_scene_a3d/BattleMap.py @@ -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) \ No newline at end of file diff --git a/io_scene_a3d/BattleMapBlenderImporter.py b/io_scene_a3d/BattleMapBlenderImporter.py new file mode 100644 index 0000000..c7f2434 --- /dev/null +++ b/io_scene_a3d/BattleMapBlenderImporter.py @@ -0,0 +1,445 @@ +''' +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 json import load + +import bpy +from bpy_extras.image_utils import load_image +from bpy_extras.node_shader_utils import PrincipledBSDFWrapper +import bmesh +from mathutils import Matrix + +from .A3D import A3D +from .A3DBlenderImporter import A3DBlenderImporter +from .BlenderMaterialUtils import addImageTextureToMaterial, decodeIntColorToTuple + +class BattleMapBlenderImporter: + # Allows subsequent map loads to be faster + libraryCache = {} + + def __init__(self, mapData, lightmapData, propLibrarySourcePath, map_scale_factor=0.01, import_static_geom=True, import_collision_geom=False, import_spawn_points=False, import_lightmapdata=False): + self.mapData = mapData + self.lightmapData = lightmapData + self.propLibrarySourcePath = propLibrarySourcePath + self.map_scale_factor = map_scale_factor + self.import_static_geom = import_static_geom + self.import_collision_geom = import_collision_geom + self.import_spawn_points = import_spawn_points + self.import_lightmapdata = import_lightmapdata + + # Cache for collision meshes, don't cache triangles because they are set using unique vertices + self.collisionPlaneMesh = None + self.collisionBoxMesh = None + + self.materials = {} + + def importData(self): + print("Importing BattleMap data into blender") + + # Process materials + for materialData in self.mapData.materials: + ma = self.createBlenderMaterial(materialData) + self.materials[materialData.ID] = ma + + # Static geometry + propObjects = [] + if self.import_static_geom: + # Load props + for propData in self.mapData.staticGeometry: + ob = self.getBlenderProp(propData) + propObjects.append(ob) + print(f"Loaded {len(propObjects)} prop objects") + + # Collision geometry + collisionObjects = [] + if self.import_collision_geom: + # Create collision meshes + self.collisionPlaneMesh = bpy.data.meshes.new("collisionPlane") + bm = bmesh.new() + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1.0) + bm.to_mesh(self.collisionPlaneMesh) + bm.free() + + self.collisionBoxMesh = bpy.data.meshes.new("collisionBox") + bm = bmesh.new() + bmesh.ops.create_cube(bm) + bm.to_mesh(self.collisionBoxMesh) + bm.free() + + # Load collision meshes + collisionTriangles = self.mapData.collisionGeometry.triangles + self.mapData.collisionGeometryOutsideGamingZone.triangles + collisionTriangleObjects = self.createBlenderCollisionTriangles(collisionTriangles) + collisionPlanes = self.mapData.collisionGeometry.planes + self.mapData.collisionGeometryOutsideGamingZone.planes + collisionPlaneObjects = self.createBlenderCollisionPlanes(collisionPlanes) + collisionBoxes = self.mapData.collisionGeometry.boxes + self.mapData.collisionGeometryOutsideGamingZone.boxes + collisionBoxObjects = self.createBlenderCollisionBoxes(collisionBoxes) + + collisionObjects += collisionTriangleObjects + collisionObjects += collisionPlaneObjects + collisionObjects += collisionBoxObjects + print(f"Loaded {len(collisionObjects)} collision objects") + + # Spawn points + spawnPointObjects = [] + if self.import_spawn_points: + # Create spawn points + for spawnPointData in self.mapData.spawnPoints: + ob = self.createBlenderSpawnPoint(spawnPointData) + spawnPointObjects.append(ob) + print(f"Loaded {len(spawnPointObjects)} spawn points") + + # Create container object to store all our objects + objects = propObjects + collisionObjects + spawnPointObjects + mapOB = bpy.data.objects.new("BattleMap", None) + mapOB.empty_display_size = 100 # Alternativa use a x100 scale + mapOB.scale = (self.map_scale_factor, self.map_scale_factor, self.map_scale_factor) + objects.append(mapOB) + + # Create empty objects to group each type of object + if self.import_static_geom: + groupOB = bpy.data.objects.new("StaticGeometry", None) + groupOB.parent = mapOB + objects.append(groupOB) + for ob in propObjects: + ob.parent = groupOB + if self.import_collision_geom: + groupOB = bpy.data.objects.new("CollisionGeometry", None) + groupOB.parent = mapOB + objects.append(groupOB) + for ob in collisionObjects: + ob.parent = groupOB + if self.import_spawn_points: + groupOB = bpy.data.objects.new("SpawnPoints", None) + groupOB.parent = mapOB + objects.append(groupOB) + for ob in spawnPointObjects: + ob.parent = groupOB + + # Lighting data + if self.import_lightmapdata: + # Create a sun light object + li = bpy.data.lights.new("DirectionalLight", "SUN") + li.color = decodeIntColorToTuple(self.lightmapData.lightColour) + + ob = bpy.data.objects.new(li.name, li) + ob.location = (0.0, 0.0, 1000.0) # Just place it like 10 meters off the ground (in alternativa units) + lightAngleX, lightAngleZ = self.lightmapData.lightAngle + ob.rotation_mode = "XYZ" + ob.rotation_euler = (lightAngleX, 0.0, lightAngleZ) + + ob.parent = mapOB + objects.append(ob) + + # Set ambient world light + scene = bpy.context.scene + if scene.world == None: + wd = bpy.data.worlds.new("map") + scene.world = wd + world = scene.world + world.use_nodes = False + world.color = decodeIntColorToTuple(self.lightmapData.ambientLightColour) + + return objects + + def getPropLibrary(self, libraryName): + # First check if we've already loaded the required prop library + if not libraryName in self.libraryCache: + # Load the proplib + libraryPath = f"{self.propLibrarySourcePath}/{libraryName}" + library = PropLibrary(libraryPath) + self.libraryCache[libraryName] = library + + return self.libraryCache[libraryName] + + def tryLoadTexture(self, textureName, libraryName): + if libraryName == None: + # For some reason Remaster proplib is alwaus marked as None? This is not true for the ny2024 remaster prop lib though + libraryName = "Remaster" + + propLibrary = self.getPropLibrary(libraryName) + texture = propLibrary.getTexture(f"{textureName}.webp") + return texture + + ''' + Blender data builders + ''' + def getBlenderProp(self, propData): + # Load prop + propLibrary = self.getPropLibrary(propData.libraryName) + prop = propLibrary.getProp(propData.name, propData.groupName) + propOB = prop.mainObject.copy() # We want to use a copy of the prop object + + # Assign data + propOB.name = f"{propData.name}_{propData.ID}" + propOB.location = propData.position + propOB.rotation_mode = "XYZ" + propRotation = propData.rotation + if propRotation == None: + propRotation = (0.0, 0.0, 0.0) + propOB.rotation_euler = propRotation + propScale = propData.scale + if propScale == None: + propScale = (1.0, 1.0, 1.0) + propOB.scale = propScale + + # Lighting info + if self.import_lightmapdata: + lightingMapObject = None + for mapObject in self.lightmapData.mapObjects: + if mapObject.index == propData.ID: + lightingMapObject = mapObject + break + if lightingMapObject != None: + #XXX: do something with lightingMapObject.recieveShadows?? + propOB.visible_shadow = lightingMapObject.castShadows + + # Material + ma = self.materials[propData.materialID] + if len(propOB.data.materials) != 0: + # Create a duplicate mesh object if it needs a different material, XXX: could probably cache these to reuse datablocks + if propOB.data.materials[0] != ma: + propOB.data = propOB.data.copy() + propOB.data.materials[0] = ma + + return propOB + + def createBlenderCollisionTriangles(self, collisionTriangles): + objects = [] + for collisionTriangle in collisionTriangles: + # Create the mesh + me = bpy.data.meshes.new("collisionTriangle") + + # Create array for coordinate data, blender doesn't like tuples + vertices = [] + vertices += collisionTriangle.v0 + vertices += collisionTriangle.v1 + vertices += collisionTriangle.v2 + + # Assign coordinates + me.vertices.add(3) + me.vertices.foreach_set("co", vertices) + me.loops.add(3) + me.loops.foreach_set("vertex_index", [0, 1, 2]) + me.polygons.add(1) + me.polygons.foreach_set("loop_start", [0]) + + me.validate() + me.update() + + # Create object + ob = bpy.data.objects.new("collisionTriangle", me) + ob.location = collisionTriangle.position + ob.rotation_mode = "XYZ" + ob.rotation_euler = collisionTriangle.rotation + #print(collisionTriangle.length) # XXX: how to handle collisionTriangle.length? + + objects.append(ob) + + return objects + + def createBlenderCollisionPlanes(self, collisionPlanes): + objects = [] + for collisionPlane in collisionPlanes: + # Create object + ob = bpy.data.objects.new("collisionPlane", self.collisionPlaneMesh) + ob.location = collisionPlane.position + ob.rotation_mode = "XYZ" + ob.rotation_euler = collisionPlane.rotation + ob.scale = (collisionPlane.width*0.5, collisionPlane.length*0.5, 1.0) # Unsure why they double the width and length, could be because of central origin? + + objects.append(ob) + + return objects + + def createBlenderCollisionBoxes(self, collisionBoxes): + objects = [] + for collisionBox in collisionBoxes: + # Create object + ob = bpy.data.objects.new("collisionBox", self.collisionBoxMesh) + ob.location = collisionBox.position + ob.rotation_mode = "XYZ" + ob.rotation_euler = collisionBox.rotation + ob.scale = collisionBox.size + + objects.append(ob) + + return objects + + def createBlenderSpawnPoint(self, spawnPointData): + #TODO: implement spawn type name lookup + ob = bpy.data.objects.new(f"SpawnPoint_{spawnPointData.type}", None) + ob.empty_display_type = "ARROWS" + ob.empty_display_size = 100 # The map will be at 100x scale so it's a good idea to match that here + ob.location = spawnPointData.position + ob.rotation_mode = "XYZ" + ob.rotation_euler = spawnPointData.rotation + + return ob + + def createBlenderMaterial(self, materialData): + ma = bpy.data.materials.new(f"{materialData.ID}_{materialData.name}") + + # Shader specific logic + if materialData.shader == "TankiOnline/SingleTextureShader" or materialData.shader == "TankiOnline/SingleTextureShaderWinter": + bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True) + bsdf.roughness_set(1.0) + bsdf.ior_set(1.0) + + # Try load texture + textureParameter = materialData.textureParameters[0] + texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName) + + addImageTextureToMaterial(texture, ma.node_tree) + elif materialData.shader == "TankiOnline/SpriteShader": + bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True) + bsdf.roughness_set(1.0) + bsdf.ior_set(1.0) + + # Try load texture + textureParameter = materialData.textureParameters[0] + texture = self.tryLoadTexture(textureParameter.textureName, textureParameter.libraryName) + + addImageTextureToMaterial(texture, ma.node_tree, linkAlpha=True) + elif materialData.shader == "TankiOnline/Terrain": + # XXX: still need to figure out how to do the terrain properly, all manual attempts have yielded mixed results + bsdf = PrincipledBSDFWrapper(ma, is_readonly=False, use_nodes=True) + bsdf.roughness_set(1.0) + bsdf.ior_set(1.0) + bsdf.base_color_set((0.0, 0.0, 0.0)) + else: + pass # Unknown shader + + return ma + +class PropLibrary: + propGroups = {} + def __init__(self, directory): + self.directory = directory + self.libraryInfo = {} + + # Load library info + with open(f"{self.directory}/library.json", "r") as file: self.libraryInfo = load(file) + print(f"Loaded prop library: " + self.libraryInfo["name"]) + + def getProp(self, propName, groupName): + # Create the prop group if it's not already loaded + if not groupName in self.propGroups: + self.propGroups[groupName] = {} + + # Load the prop if it's not already loaded + if not propName in self.propGroups[groupName]: + # Find the prop group + groupInfo = None + for group in self.libraryInfo["groups"]: + if group["name"] == groupName: + groupInfo = group + break + if groupInfo == None: + raise RuntimeError(f"Unable to find prop group with name {groupName} in " + self.libraryInfo["name"]) + + # Find the prop + propInfo = None + for prop in groupInfo["props"]: + if prop["name"] == propName: + propInfo = prop + break + if propInfo == None: + raise RuntimeError(f"Unable to find prop with name {propName} in {groupName} from " + self.libraryInfo["name"]) + + # Create the prop + prop = Prop() + meshInfo = propInfo["mesh"] + spriteInfo = propInfo["sprite"] + if meshInfo != None: + modelPath = f"{self.directory}/" + meshInfo["file"] + prop.loadModel(modelPath) + elif spriteInfo != None: + prop.loadSprite(propInfo) + else: + #XXX: Uhhhhhh, empty prop? + pass + self.propGroups[groupName][propName] = prop + + return self.propGroups[groupName][propName] + + def getTexture(self, textureName): + im = load_image(textureName, self.directory) + return im + +class Prop: + def __init__(self): + self.objects = [] + self.mainObject = None + + def loadModel(self, modelPath): + fileExtension = modelPath.split(".")[-1] + if fileExtension == "a3d": + modelData = A3D() + with open(modelPath, "rb") as file: modelData.read(file) + + # Import the model + modelImporter = A3DBlenderImporter(modelData, None, reset_empty_transform=False, try_import_textures=False) + self.objects = modelImporter.importData() + elif fileExtension == "3ds": + bpy.ops.import_scene.max3ds(filepath=modelPath, use_apply_transform=False) + for ob in bpy.context.selectable_objects: + # The imported objects are added to the active collection, remove them + bpy.context.collection.objects.unlink(ob) + + # Correct the origin XXX: this does not work for all cases, investigate more + ob.animation_data_clear() + x, y, z = -ob.location.x, -ob.location.y, -ob.location.z + objectOrigin = Matrix.Translation((x, y, z)) + ob.data.transform(objectOrigin) + ob.location = (0.0, 0.0, 0.0) + + self.objects.append(ob) + else: + raise RuntimeError(f"Unknown model file extension: {fileExtension}") + + # Identify the main parent object + for ob in self.objects: + if ob.parent == None: self.mainObject = ob + if self.mainObject == None: + raise RuntimeError(f"Unable to find the parent object for: {modelPath}") + + def loadSprite(self, propInfo): + spriteInfo = propInfo["sprite"] + + # Create a plane we can use for the sprite + me = bpy.data.meshes.new(propInfo["name"]) + + # bm = bmesh.new() + # bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=spriteInfo["scale"]*100) + # bm.to_mesh(me) + # bm.free() + + ob = bpy.data.objects.new(me.name, me) + + # Assign data + ob.scale = (spriteInfo["width"], 1.0, spriteInfo["height"]) #XXX: this should involve spriteInfo["scale"] probably? + spriteOrigin = Matrix.Translation((0.0, spriteInfo["originY"], 0.0)) + me.transform(spriteOrigin) + + # Finalise + self.objects.append(ob) + self.mainObject = ob diff --git a/io_scene_a3d/BlenderMaterialUtils.py b/io_scene_a3d/BlenderMaterialUtils.py new file mode 100644 index 0000000..f240d6b --- /dev/null +++ b/io_scene_a3d/BlenderMaterialUtils.py @@ -0,0 +1,51 @@ +''' +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. +''' + +''' +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) \ No newline at end of file diff --git a/io_scene_a3d/LightmapData.py b/io_scene_a3d/LightmapData.py new file mode 100644 index 0000000..cb4dbd2 --- /dev/null +++ b/io_scene_a3d/LightmapData.py @@ -0,0 +1,113 @@ +''' +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 .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("= 0: + self.lightmapScaleOffset = unpackStream("<4f", stream) + + # Check if we have UVs and read them + hasUVs, = unpackStream("b", stream) + if hasUVs > 0: + vertexCount, = unpackStream(" 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}]") \ No newline at end of file diff --git a/io_scene_a3d/__init__.py b/io_scene_a3d/__init__.py index 98356fc..ef1a1d5 100644 --- a/io_scene_a3d/__init__.py +++ b/io_scene_a3d/__init__.py @@ -21,14 +21,30 @@ SOFTWARE. ''' import bpy -from bpy.types import Operator, OperatorFileListElement -from bpy.props import StringProperty, BoolProperty, CollectionProperty +from bpy.types import Operator, OperatorFileListElement, AddonPreferences +from bpy.props import StringProperty, BoolProperty, CollectionProperty, FloatProperty from bpy_extras.io_utils import ImportHelper from .A3D import A3D from .A3DBlenderImporter import A3DBlenderImporter +from .BattleMap import BattleMap +from .BattleMapBlenderImporter import BattleMapBlenderImporter +from .LightmapData import LightmapData -from glob import glob +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 @@ -40,8 +56,8 @@ class ImportA3D(Operator, ImportHelper): 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"}) + 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) @@ -49,15 +65,17 @@ class ImportA3D(Operator, ImportHelper): 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(self.layout, self) + import_panel_options_a3d(self.layout, self) def invoke(self, context, event): return ImportHelper.invoke(self, context, event) def execute(self, context): + importStartTime = time() + objects = [] for file in self.files: - filepath = self.directory + file.name + filepath = f"{self.directory}/{file.name}" # Read the file print(f"Reading A3D data from {filepath}") modelData = A3D() @@ -76,12 +94,72 @@ class ImportA3D(Operator, ImportHelper): for obI, ob in enumerate(objects): collection.objects.link(ob) + importEndTime = time() + self.report({'INFO'}, f"Imported {len(objects)} objects in {importEndTime-importStartTime}s") + + return {"FINISHED"} + +class ImportBattleMap(Operator, ImportHelper): + bl_idname = "import_scene.tanki_battlemap" + bl_label = "Import map" + bl_description = "Import a BIN format Tanki Online map file" + bl_options = {'PRESET', 'UNDO'} + + filter_glob: StringProperty(default="*.bin", options={'HIDDEN'}) + directory: StringProperty(subtype="DIR_PATH", options={'HIDDEN'}) + + # User options + import_static_geom: BoolProperty(name="Import static geometry", description="Static geometry includes all the visual aspects of the map", default=True) + import_collision_geom: BoolProperty(name="Import collision geometry", description="Collision geometry defines the geometry used for collision checks and cannot normally be seen by players", default=False) + import_spawn_points: BoolProperty(name="Import spawn points", description="Places a marker at locations where tanks can spawn", default=False) + import_lightmapdata: BoolProperty(name="Import lighting information", description="Loads the lightmapdata file which stores information about the sun, ambient lighting and shadow settings. Only works on remaster maps.", default=True) + map_scale_factor: FloatProperty(name="Map scale", description="Sets the map's default scale, maps and models are at a 100x scale so this allows you to directly import the map in the right size.", default=0.01, min=0.0, soft_max=1.0) + + def draw(self, context): + import_panel_options_battlemap(self.layout, self) + + def invoke(self, context, event): + return ImportHelper.invoke(self, context, event) + + def execute(self, context): + print(f"Reading BattleMap data from {self.filepath}") + importStartTime = time() + + # lightmapdata files only exist for remaster maps + lightmapData = LightmapData() + if self.import_lightmapdata: + try: + with open(f"{self.directory}/lightmapdata", "rb") as file: lightmapData.read(file) + except: + print("Couldn't open lightmapdata file, ignoring") + self.import_lightmapdata = False + + # read map data + mapData = BattleMap() + with open(self.filepath, "rb") as file: + mapData.read(file) + + # Import data into blender + 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(layout, operator): +def import_panel_options_a3d(layout, operator): header, body = layout.panel("alternativa_import_options", default_closed=False) header.label(text="Options") if body: @@ -89,25 +167,42 @@ def import_panel_options(layout, operator): body.prop(operator, "try_import_textures") body.prop(operator, "reset_empty_transform") +def import_panel_options_battlemap(layout, operator): + header, body = layout.panel("tanki_battlemap_import_options", default_closed=False) + header.label(text="Options") + if body: + body.prop(operator, "import_static_geom") + body.prop(operator, "import_collision_geom") + body.prop(operator, "import_spawn_points") + body.prop(operator, "import_lightmapdata") + body.prop(operator, "map_scale_factor") + def menu_func_import_a3d(self, context): self.layout.operator(ImportA3D.bl_idname, text="Alternativa3D HTML5 (.a3d)") +def menu_func_import_battlemap(self, context): + self.layout.operator(ImportBattleMap.bl_idname, text="Tanki Online BattleMap (.bin)") + ''' Registration ''' classes = [ - ImportA3D + Preferences, + ImportA3D, + ImportBattleMap ] def register(): for c in classes: bpy.utils.register_class(c) bpy.types.TOPBAR_MT_file_import.append(menu_func_import_a3d) + bpy.types.TOPBAR_MT_file_import.append(menu_func_import_battlemap) def unregister(): for c in classes: bpy.utils.unregister_class(c) bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_a3d) + bpy.types.TOPBAR_MT_file_import.remove(menu_func_import_battlemap) if __name__ == "__main__": register() \ No newline at end of file