Source code for pyunity.loader

## Copyright (c) 2020-2022 The PyUnity Team
## This file is licensed under the MIT License.
## See https://docs.pyunity.x10.bz/en/latest/license.html

"""
Utility functions related to loading
and saving PyUnity meshes and scenes.

This will be imported as ``pyunity.Loader``.

"""

__all__ = ["Primitives", "GetImports", "SaveScene",
           "LoadMesh", "SaveMesh", "LoadObj", "SaveObj",
           "LoadMat", "SaveMat", "LoadProject", "ObjectInfo",
           "SaveProject", "ResaveScene", "GenerateProject",
           "LoadScene", "LoadStl", "LoadPrefab",
           "LoadObjectInfos", "LoadGameObjects",
           "SaveGameObjects", "SavePrefab"]

from . import Logger
from ._version import versionInfo
from .meshes import Mesh, Material, Color
from .errors import PyUnityException, ProjectParseException
from .core import GameObject, Component, Tag, SavesProjectID
from .values import Vector3, Vector2, ImmutableStruct, Quaternion, SavableStruct
from .scenes import SceneManager, Scene
from .files import Behaviour, Scripts, Project, File, Texture2D, Asset, Prefab
from .resources import getPath
from pathlib import Path
from uuid import uuid4
import struct
import shutil
import inspect
import json
import enum
import os

[docs]def LoadObj(filename): """ Loads a .obj file to a PyUnity mesh. Parameters ---------- filename : Pathlike Name of file Returns ------- Mesh A mesh of the object file """ vertices = [] normals = [] faces = [] for line in open(filename, "r"): if line.startswith("#"): continue values = line.split() if not values: continue if values[0] == "v": v = Vector3(float(values[1]), -float(values[3]), float(values[2])) vertices.append(v) elif values[0] == "f": face = [] for v in values[1:]: w = v.split("/") face.append(int(w[0]) - 1) face.reverse() faces.append(face) for face in faces: a = vertices[face[2]] - vertices[face[1]] b = vertices[face[0]] - vertices[face[1]] normal = a.cross(b).normalized() normals.append(normal) return Mesh(vertices, faces, normals)
[docs]def SaveObj(mesh, path): """ Save a PyUnity Mesh to a .obj file. Parameters ---------- mesh : Mesh Mesh to save path : Pathlike Path to save mesh """ def vectorToStr(v): l = [v.x, v.z, -v.y] return " ".join(map(str, round(l, 8))) directory = Path(path).resolve().parent directory.mkdir(parents=True, exist_ok=True) with open(path, "w+") as f: for vertex in mesh.verts: f.write(f"v {vectorToStr(vertex)}\n") for normal in mesh.normals: f.write(f"vn {vectorToStr(normal)}\n") for face in mesh.triangles: face = " ".join([f"{x + 1}//{x + 1}" for x in face]) f.write(f"f {face}\n")
[docs]def LoadStl(filename): """ Loads a .stl mesh to a PyUnity mesh. Parameters ---------- filename : Pathlike Path to PyUnity mesh. Raises ------ PyUnityException If the file format is incorrect """ def vectorFromStr(s): l = list(map(float, s.split(" ")[-3:])) # Flip Z and Y axes # Reverse Z axis return Vector3(l[0], l[2], -l[1]) def vectorFromBytes(b): x, y, z = b[:4], b[4:8], b[8:] l = [struct.unpack("<f", a)[0] for a in [x, y, z]] # Flip Z and Y axes # Reverse Z axis return Vector3(l[0], l[2], -l[1]) faces = [] vertices = [] normals = [] with open(filename, "rb") as f: binary = f.read() if binary[:5] == b"solid": # Ascii format text = "\n".join(binary.decode().rstrip().split("\n")[1:-1]) sections = text.split("\n endloop\nendfacet\nfacet ") if not sections[0].startswith("facet normal "): raise PyUnityException( "File does not start with \"facet normal\"") sections[0] = sections[0][:-len("facet normal ")] if not sections[-1].endswith("\n endloop\nendfacet"): raise PyUnityException( "File does not end with \"endfacet\"") sections[-1] = sections[-1][:-len("\n endloop\nendfacet")] i = 0 for section in sections: lines = section.split("\n") normal = vectorFromStr(lines[0].split(" ")[-3:]) a = vectorFromStr(lines[2].split(" ")[-3:]) b = vectorFromStr(lines[3].split(" ")[-3:]) c = vectorFromStr(lines[4].split(" ")[-3:]) faces.append([i, i + 1, i + 2]) vertices.extend([a, b, c]) normals.extend([normal.copy(), normal.copy(), normal.copy()]) i += 3 return Mesh(vertices, faces, normals) else: # Binary format length = int.from_bytes(binary[80:84], byteorder="little") realLength = (len(binary) - 84) // 50 if length != realLength: raise PyUnityException( f"STL length does not match real length: " f"expected {realLength}, got {length}") for i in range(length): section = binary[84 + i * 50: 134 + i * 50] normal = vectorFromBytes(section[:12]) a = vectorFromBytes(section[12:24]) b = vectorFromBytes(section[24:36]) c = vectorFromBytes(section[36:48]) faces.append([i * 3, i * 3 + 1, i * 3 + 2]) vertices.extend([a, b, c]) normals.extend([normal.copy(), normal.copy(), normal.copy()]) return Mesh(vertices, faces, normals)
[docs]def SaveStl(mesh, path): """ Save a PyUnity Mesh to a .stl file. Parameters ---------- mesh : Mesh Mesh to save path : Pathlike Path to save mesh """ def bytesFromVector(v): l = [struct.pack("<f", i) for i in [v.x, -v.z, v.y]] return b"".join(l) directory = Path(path).resolve().parent directory.mkdir(parents=True, exist_ok=True) with open(path, "wb+") as f: header = f"Exported by PyUnity {versionInfo}" f.write(header.encode().ljust(80, b"\x00")) f.write(len(mesh.triangles).to_bytes(4, "little")) for i in range(len(mesh.triangles)): left = mesh.verts[mesh.triangles[i][0]] - mesh.verts[mesh.triangles[i][1]] right = mesh.verts[mesh.triangles[i][2]] - mesh.verts[mesh.triangles[i][1]] f.write(bytesFromVector(left.cross(right).normalized())) for index in mesh.triangles[i]: f.write(bytesFromVector(mesh.verts[index])) f.write(b"\x00\x00")
[docs]def LoadMesh(filename): """ Loads a .mesh file generated by :func:`SaveMesh`. It is optimized for faster loading. Parameters ---------- filename : Pathlike Name of file relative to the cwd Returns ------- Mesh Generated mesh """ with open(filename, "r") as f: lines = list(map(str.rstrip, f.read().rstrip().splitlines())) vertices = list(map(float, lines[0].split("/"))) vertices = [ Vector3(vertices[i], vertices[i + 1], vertices[i + 2]) for i in range(0, len(vertices), 3) ] faces = list(map(int, lines[1].split("/"))) faces = [ [faces[i], faces[i + 1], faces[i + 2]] for i in range(0, len(faces), 3) ] normals = list(map(float, lines[2].split("/"))) normals = [ Vector3(normals[i], normals[i + 1], normals[i + 2]) for i in range(0, len(normals), 3) ] if len(lines) > 3: texcoords = list(map(float, lines[3].split("/"))) texcoords = [ [texcoords[i], texcoords[i + 1]] for i in range(0, len(texcoords), 2) ] else: texcoords = None return Mesh(vertices, faces, normals, texcoords)
[docs]def SaveMesh(mesh, path): """ Saves a mesh to a .mesh file for faster loading. Parameters ---------- mesh : Mesh Mesh to save path : Pathlike Path to save mesh """ directory = Path(path).resolve().parent directory.mkdir(parents=True, exist_ok=True) with open(path, "w+") as f: i = 0 for vertex in mesh.verts: i += 1 f.write(str(round(vertex.x, 8)) + "/") f.write(str(round(vertex.y, 8)) + "/") f.write(str(round(vertex.z, 8))) if i != len(mesh.verts): f.write("/") f.write("\n") i = 0 for triangle in mesh.triangles: i += 1 j = 0 for item in triangle: j += 1 f.write(str(item)) if i != len(mesh.triangles) or j != 3: f.write("/") f.write("\n") i = 0 for normal in mesh.normals: i += 1 f.write(str(round(normal.x, 8)) + "/") f.write(str(round(normal.y, 8)) + "/") f.write(str(round(normal.z, 8))) if i != len(mesh.normals): f.write("/") f.write("\n") i = 0 for texcoord in mesh.texcoords: i += 1 f.write(str(texcoord[0]) + "/") f.write(str(texcoord[1])) if i != len(mesh.texcoords): f.write("/") f.write("\n")
[docs]class Primitives(metaclass=ImmutableStruct): """ Primitive preloaded meshes. Do not instantiate this class. """ _names = ["cube", "quad", "doubleQuad", "sphere", "capsule", "cylinder"] cube = LoadMesh(getPath("primitives/cube.mesh")) quad = LoadMesh(getPath("primitives/quad.mesh")) doubleQuad = LoadMesh(getPath("primitives/doubleQuad.mesh")) sphere = LoadMesh(getPath("primitives/sphere.mesh")) capsule = LoadMesh(getPath("primitives/capsule.mesh")) cylinder = LoadMesh(getPath("primitives/cylinder.mesh"))
[docs]def GetImports(file): with open(file) as f: lines = f.read().rstrip().splitlines() imports = [] for line in lines: line = line.lstrip() if line.startswith("import") or (line.startswith("from") and " import " in line): imports.append(line) return "\n".join(imports) + "\n\n"
[docs]def parseString(string, project=None): if project is not None and string in project._idMap: return True, project._idMap[string] if string.startswith("Vector2("): return True, Vector2(*list(map(float, string[8:-1].split(", ")))) if string.startswith("Vector3("): return True, Vector3(*list(map(float, string[8:-1].split(", ")))) if string.startswith("Quaternion("): return True, Quaternion(*list(map(float, string[11:-1].split(", ")))) if string.startswith("RGB(") or string.startswith("HSV("): return True, Color.fromString(string) if "\n" in string: struct = {} check = True for line in string.split("\n"): key, value = line.split(": ") valid, value = parseString(value, project) if not valid: check = False break struct[key] = value if check: return True, struct if string in ["True", "False"]: return True, string == "True" if string == "None": return True, None if string.isdigit(): return True, int(string) try: return True, float(string) except (ValueError, OverflowError): pass try: # Only want strings here outStr = json.loads(string) if not isinstance(outStr, str): raise ValueError return True, outStr except ValueError: pass if ((string.startswith("(") and string.endswith(")")) or (string.startswith("[") and string.endswith("]"))): check = [] items = [] if len(string) > 2: for section in string[1:-1].split(", "): if section.isspace() or section == "": continue valid, obj = parseString(section.rstrip().lstrip(), project) check.append(valid) items.append(obj) if all(check): if string.startswith("("): return True, tuple(items) return True, items return False, None
[docs]def parseStringFallback(string, project, fallback): success, value = parseString(string, project) if not success: return fallback return value
[docs]class ObjectInfo:
[docs] class SkipConv: def __init__(self, value): self.value = value
def __init__(self, name, uuid, attrs): self.name = name self.uuid = uuid self.attrs = attrs
[docs] @staticmethod def convString(v): if isinstance(v, str): return json.dumps(v) elif isinstance(v, enum.Enum): return str(v.value) else: return str(v)
def __str__(self): s = f"{self.name} : {self.uuid}" for k, v in self.attrs.items(): if isinstance(v, ObjectInfo.SkipConv): string = v.value if string.startswith("\n"): s += f"\n {k}:{string}" else: s += f"\n {k}: {string}" else: string = ObjectInfo.convString(v) s += f"\n {k}: {string}" return s
[docs]def SaveMat(material, project, filename): Path(filename).parent.mkdir(parents=True, exist_ok=True) if material.texture is None: texID = "None" else: texID = project._ids[material.texture] colStr = str(material.color) with open(filename, "w+") as f: f.write(f"Material\n texture: {texID}\n color: {colStr}")
[docs]def LoadMat(path, project): if not Path(path).is_file(): raise PyUnityException(f"The specified file does not exist: {path}") with open(path) as f: contents = f.read().rstrip().splitlines() if contents.pop(0) != "Material": raise ProjectParseException("Expected \"Material\" as line 1") parts = {split[0][4:]: split[1] for split in map(lambda x: x.split(": "), contents)} if not (parts["color"].startswith("RGB") or parts["color"].startswith("HSV")): raise ProjectParseException("Color value does not start with RGB or HSV") color = Color.fromString(parts["color"]) if parts["texture"] not in project._idMap and parts["texture"] != "None": raise ProjectParseException(f"Project file UUID not found: {parts['texture']}") if parts["texture"] == "None": texture = None else: texture = project._idMap[parts["texture"]] return Material(color, texture)
[docs]def SavePrefab(prefab, path, project): filename = Path(path) filename.parent.mkdir(parents=True, exist_ok=True) data = [] SaveGameObjects(prefab.gameObjects, data, project) with open(filename, "w+") as f: f.write("\n".join(map(str, data)))
[docs]def LoadPrefab(path, project): data = LoadObjectInfos(path) gameObjects = LoadGameObjects(data, project) g = gameObjects[0] while g.transform.parent is not None: g = g.transform.parent.gameObject prefab = Prefab(g, prune=False) return prefab
savable = ( Color, Vector3, Quaternion, # PyUnity types bool, int, str, float, list, tuple, # Python types SavesProjectID, ObjectInfo.SkipConv # Special procedures ) """All savable types that will not be saved as UUIDs"""
[docs]def SaveGameObjects(gameObjects, data, project): for gameObject in gameObjects: attrs = { "name": gameObject.name, "tag": gameObject.tag.tag, "enabled": gameObject.enabled, "transform": ObjectInfo.SkipConv(project.GetUuid(gameObject.transform)) } data.append(ObjectInfo("GameObject", project.GetUuid(gameObject), attrs)) for gameObject in gameObjects: gameObjectID = project.GetUuid(gameObject) for component in gameObject.components: attrs = { "gameObject": ObjectInfo.SkipConv(gameObjectID), "enabled": gameObject.enabled } for k in component._saved.keys(): v = getattr(component, k) if isinstance(v, SavesProjectID): if v not in project._ids and isinstance(v, Asset): project.ImportAsset(v, gameObject) v = ObjectInfo.SkipConv(project.GetUuid(v)) elif hasattr(v, "_wrapper"): if isinstance(getattr(v, "_wrapper"), SavableStruct): wrapper = getattr(v, "_wrapper") struct = {} for key in wrapper.attrs: if hasattr(v, key): item = getattr(v, key) if isinstance(item, SavesProjectID): if item not in project._ids and isinstance(item, Asset): project.ImportAsset(item, gameObject) struct[key] = project.GetUuid(item) else: struct[key] = ObjectInfo.convString(item) sep = "\n " v = ObjectInfo.SkipConv( sep + sep.join(": ".join(x) for x in struct.items())) if v is not None and not isinstance(v, savable): continue attrs[k] = v if isinstance(component, Behaviour): behaviour = component.__class__ if behaviour not in project._ids: filename = Path("Scripts") / (behaviour.__name__ + ".py") os.makedirs(project.path / "Scripts", exist_ok=True) with open(project.path / filename, "w+") as f: f.write(GetImports(inspect.getsourcefile(behaviour)) + inspect.getsource(behaviour)) uuid = project.GetUuid(behaviour) file = File(filename, uuid) project.ImportFile(file, write=False) attrs["_script"] = ObjectInfo.SkipConv(project._ids[behaviour]) name = behaviour.__name__ + "(Behaviour)" else: name = component.__class__.__name__ + "(Component)" data.append(ObjectInfo(name, project.GetUuid(component), attrs))
[docs]def LoadObjectInfos(file): with open(file) as f: contents = f.read().rstrip().splitlines() data = [] attrs = {} struct = {} name, uuid = contents.pop(0).split(" : ") for line in contents: if "#" in line: quotes = 0 for i, char in enumerate(line): if char == "\"" and line[char - 1] != "\\": quotes += 1 if char == "#": if quotes % 2 == 0: break line = line[:i] line = line.rstrip() if line == "": continue if line.startswith(" "): key, value = line[8:].split(": ") struct[key] = value elif line.startswith(" "): if line.endswith(":"): key = line[4:-1] struct = {} attrs[key] = struct else: key, value = line[4:].split(": ") attrs[key] = value else: # New section for key in attrs: if isinstance(attrs[key], dict): text = "\n".join(": ".join(x) for x in attrs[key].items()) attrs[key] = text data.append(ObjectInfo(name, uuid, attrs)) name, uuid = line.split(" : ") attrs = {} # Final objectinfo for key in attrs: if isinstance(attrs[key], dict): text = "\n".join(": ".join(x) for x in attrs[key].items()) attrs[key] = text data.append(ObjectInfo(name, uuid, attrs)) return data
[docs]def instanceCheck(type_, value): if type_ is float: type_ = (float, int) elif issubclass(type_, enum.Enum): if value in list(type_.__members__.values()): value = type_(value) else: raise ProjectParseException(f"{value} not in enum {type_}") return type_, value
[docs]def GetComponentMap(): import pyunity componentMap = {} for item in pyunity.__all__: obj = getattr(pyunity, item) if isinstance(obj, type) and issubclass(obj, Component): componentMap[item] = obj return componentMap
[docs]def LoadGameObjects(data, project): def addUuid(obj, uuid): if obj in project._ids: return project._ids[obj] = uuid project._idMap[uuid] = obj try: import PyUnityScripts except ImportError: raise PyUnityException("Please run Scripts.LoadScript before this function") gameObjectInfo = [x for x in data if x.name == "GameObject"] componentInfo = [x for x in data if x.name.endswith("(Component)")] behaviourInfo = [x for x in data if x.name.endswith("(Behaviour)")] gameObjects = [] for part in gameObjectInfo: gameObject = GameObject.BareObject(json.loads(part.attrs["name"])) gameObject.tag = Tag(int(part.attrs["tag"])) gameObject.enabled = part.attrs.get("enabled", "True") == "True" addUuid(gameObject, part.uuid) gameObjects.append(gameObject) componentMap = GetComponentMap() # first pass, adding components for part in componentInfo + behaviourInfo: gameObjectID = part.attrs.pop("gameObject") gameObject = project._idMap[gameObjectID] enabled = part.attrs.pop("enabled", "True") == "True" if part.name.endswith("(Component)"): component = gameObject.AddComponent(componentMap[part.name[:-11]]) else: file = project.fileIDs[part.attrs.pop("_script")] fullpath = project.path.resolve() / file.path behaviourType = PyUnityScripts._lookup[str(fullpath)] component = gameObject.AddComponent(behaviourType) if part.name[:-11] != behaviourType.__name__: raise PyUnityException(f"{behaviourType.__name__} does not match {part.name[:-11]}") component.enabled = enabled addUuid(component, part.uuid) # second part, assigning attrs for part in componentInfo + behaviourInfo: component = project._idMap[part.uuid] for k, v in part.attrs.items(): success, value = parseString(v, project) if not success: continue if value is not None: type_, value = instanceCheck(component._saved[k].type, value) if hasattr(type_, "_wrapper"): if isinstance(getattr(type_, "_wrapper"), SavableStruct): value = getattr(type_, "_wrapper").fromDict(type_, value, instanceCheck) if not isinstance(value, type_): raise ProjectParseException( f"Value {value!r} does not match type {type_}: " f"attribute {k!r} of {component}") setattr(component, k, value) # Transform check for part in gameObjectInfo: gameObject = project._idMap[part.uuid] transform = project._idMap[part.attrs["transform"]] if transform is not gameObject.transform: Logger.LogLine(Logger.WARN, f"GameObject transform does not match transform UUID: {gameObject.name!r}") if transform.parent is not None: transform.parent.children.append(transform) return gameObjects
[docs]def SaveScene(scene, project, path): location = project.path / path data = [ObjectInfo( "Scene", project.GetUuid(scene), { "name": scene.name, "mainCamera": ObjectInfo.SkipConv(project.GetUuid(scene.mainCamera)) } )] SaveGameObjects(scene.gameObjects, data, project) location.parent.mkdir(parents=True, exist_ok=True) with open(location, "w+") as f: f.write("\n".join(map(str, data))) project.ImportFile(File(Path(path), project.GetUuid(scene)))
savers = { Mesh: SaveMesh, Material: SaveMat, Scene: SaveScene, Prefab: SavePrefab, }
[docs]def ResaveScene(scene, project): if scene not in project._ids: raise PyUnityException(f"Scene is not part of project: {scene.name!r}") path = project.fileIDs[project._ids[scene]].path SaveScene(scene, project, Path(path))
[docs]def GenerateProject(name, force=True): path = Path(name).resolve() if os.path.exists(path): if force: if os.path.isfile(path): os.remove(path) else: shutil.rmtree(path) else: if os.path.isfile(path): raise PyUnityException(f"File exists: {path}") else: raise PyUnityException(f"Directory exists: {path}") project = Project(path) SaveProject(project) return project
[docs]def SaveProject(project): for scene in SceneManager.scenesByIndex: project.ImportAsset(scene)
[docs]def LoadProject(folder, remove=True): if remove: SceneManager.RemoveAllScenes() project = Project.FromFolder(folder) Scripts.GenerateModule() # Scripts for file in project.filePaths: if file.endswith(".py") and not file.startswith("__"): Scripts.LoadScript(project.path / os.path.normpath(file)) # Meshes for file in project.filePaths: if file.endswith(".mesh"): mesh = LoadMesh(project.path / os.path.normpath(file)) project.SetAsset(file, mesh) # Textures for file in project.filePaths: if file.endswith(".png") or file.endswith(".jpg"): texture = Texture2D(project.path / os.path.normpath(file)) project.SetAsset(file, texture) # Materials for file in project.filePaths: if file.endswith(".mat"): material = LoadMat(project.path / os.path.normpath(file), project) project.SetAsset(file, material) # Prefabs for file in project.filePaths: if file.endswith(".prefab"): prefab = LoadPrefab(project.path / os.path.normpath(file), project) project.SetAsset(file, prefab) # Scenes for file in project.filePaths: if file.endswith(".scene"): scene = LoadScene(project.path / os.path.normpath(file), project) project.SetAsset(file, scene) return project
[docs]def LoadScene(sceneFile, project): def addUuid(obj, uuid): if obj in project._ids: return project._ids[obj] = uuid project._idMap[uuid] = obj if not Path(sceneFile).is_file(): raise PyUnityException(f"The specified file does not exist: {sceneFile}") data = LoadObjectInfos(sceneFile) gameObjects = LoadGameObjects(data[1:], project) sceneInfo = data[0] if sceneInfo.name != "Scene": raise ProjectParseException(f"Expected \"Scene\" as first section") scene = SceneManager.AddBareScene(json.loads(sceneInfo.attrs["name"])) addUuid(scene, sceneInfo.uuid) scene.mainCamera = project._idMap[sceneInfo.attrs["mainCamera"]] for gameObject in gameObjects: scene.Add(gameObject) return scene