// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.MixedReality.Toolkit.Utilities.Gltf.Schema;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using UnityEngine;
#if ENABLE_WINMD_SUPPORT
using Windows.Storage;
using Windows.Storage.Streams;
#else
using Microsoft.MixedReality.Toolkit.Utilities;
#endif
namespace Microsoft.MixedReality.Toolkit.Utilities.Gltf.Serialization
{
public static class GltfUtility
{
private const uint GltfMagicNumber = 0x46546C67;
private const string DefaultObjectName = "GLTF Object";
private static readonly WaitForUpdate Update = new WaitForUpdate();
private static readonly WaitForBackgroundThread BackgroundThread = new WaitForBackgroundThread();
///
/// Imports a glTF object from the provided uri.
///
/// the path to the file to load
/// New imported from uri.
///
/// Must be called from the main thread.
/// If the Application.isPlaying is false, then this method will run synchronously.
///
public static async Task ImportGltfObjectFromPathAsync(string uri)
{
if (!SyncContextUtility.IsMainThread)
{
Debug.LogError("ImportGltfObjectFromPathAsync must be called from the main thread!");
return null;
}
if (string.IsNullOrWhiteSpace(uri))
{
Debug.LogError("Uri is not valid.");
return null;
}
GltfObject gltfObject;
bool useBackgroundThread = Application.isPlaying;
if (useBackgroundThread) { await BackgroundThread; }
if (uri.EndsWith(".gltf", StringComparison.OrdinalIgnoreCase))
{
string gltfJson = File.ReadAllText(uri);
gltfObject = GetGltfObjectFromJson(gltfJson);
if (gltfObject == null)
{
Debug.LogError("Failed to load glTF object from JSON schema.");
return null;
}
}
else if (uri.EndsWith(".glb", StringComparison.OrdinalIgnoreCase))
{
byte[] glbData;
#if ENABLE_WINMD_SUPPORT
if (useBackgroundThread)
{
try
{
var storageFile = await StorageFile.GetFileFromPathAsync(uri);
if (storageFile == null)
{
Debug.LogError($"Failed to locate .glb file at {uri}");
return null;
}
var buffer = await FileIO.ReadBufferAsync(storageFile);
using (DataReader dataReader = DataReader.FromBuffer(buffer))
{
glbData = new byte[buffer.Length];
dataReader.ReadBytes(glbData);
}
}
catch (Exception e)
{
Debug.LogError(e.Message);
return null;
}
}
else
{
glbData = UnityEngine.Windows.File.ReadAllBytes(uri);
}
#else
using (FileStream stream = File.Open(uri, FileMode.Open))
{
glbData = new byte[stream.Length];
if (useBackgroundThread)
{
await stream.ReadAsync(glbData, 0, (int)stream.Length);
}
else
{
stream.Read(glbData, 0, (int)stream.Length);
}
}
#endif
gltfObject = GetGltfObjectFromGlb(glbData);
if (gltfObject == null)
{
Debug.LogError("Failed to load glTF Object from .glb!");
return null;
}
}
else
{
Debug.LogError("Unsupported file name extension.");
return null;
}
gltfObject.Uri = uri;
try
{
gltfObject.Name = Path.GetFileNameWithoutExtension(uri);
}
catch (ArgumentException)
{
Debug.LogWarning("Uri contained invalid character");
gltfObject.Name = DefaultObjectName;
}
gltfObject.UseBackgroundThread = useBackgroundThread;
await gltfObject.ConstructAsync();
if (gltfObject.GameObjectReference == null)
{
Debug.LogError("Failed to construct glTF object.");
}
if (useBackgroundThread) { await Update; }
return gltfObject;
}
///
/// Gets a glTF object from the provided json string.
///
/// String defining a glTF Object.
///
/// Returned still needs to be initialized using .
public static GltfObject GetGltfObjectFromJson(string jsonString)
{
var gltfObject = JsonUtility.FromJson(jsonString);
if (gltfObject.extensionsRequired?.Length > 0)
{
StringBuilder logMessage = new StringBuilder("One or more unsupported glTF extensions required. Unable to load the model:");
for (int i = 0; i < gltfObject.extensionsRequired.Length; ++i)
{
logMessage.Append($"\nExtension: {gltfObject.extensionsRequired[i]}");
}
Debug.LogError(logMessage);
return null;
}
if (gltfObject.extensionsUsed?.Length > 0)
{
StringBuilder logMessage = new StringBuilder("One or more unsupported glTF extensions in use. Ignoring the following:");
for (int i = 0; i < gltfObject.extensionsUsed.Length; ++i)
{
logMessage.Append($"\nExtension: {gltfObject.extensionsUsed[i]}");
}
Debug.Log(logMessage);
}
var meshPrimitiveAttributes = GetGltfMeshPrimitiveAttributes(jsonString);
int numPrimitives = 0;
for (var i = 0; i < gltfObject.meshes?.Length; i++)
{
numPrimitives += gltfObject.meshes[i]?.primitives?.Length ?? 0;
}
if (numPrimitives != meshPrimitiveAttributes.Count)
{
Debug.LogError("The number of mesh primitive attributes does not match the number of mesh primitives");
return null;
}
int primitiveIndex = 0;
for (int i = 0; i < gltfObject.meshes?.Length; i++)
{
for (int j = 0; j < gltfObject.meshes[i].primitives.Length; j++)
{
gltfObject.meshes[i].primitives[j].Attributes = new GltfMeshPrimitiveAttributes(StringIntDictionaryFromJson(meshPrimitiveAttributes[primitiveIndex]));
primitiveIndex++;
}
}
return gltfObject;
}
///
/// Gets a glTF object from the provided byte array
///
/// Raw glb byte data.
///
/// Returned still needs to be initialized using .
public static GltfObject GetGltfObjectFromGlb(byte[] glbData)
{
const int stride = sizeof(uint);
var magicNumber = BitConverter.ToUInt32(glbData, 0);
var version = BitConverter.ToUInt32(glbData, stride);
var length = BitConverter.ToUInt32(glbData, stride * 2);
if (magicNumber != GltfMagicNumber)
{
Debug.LogError("File is not a glb object!");
return null;
}
if (version != 2)
{
Debug.LogError("Glb file version mismatch! Glb must use version 2");
return null;
}
if (length != glbData.Length)
{
Debug.LogError("Glb file size does not match the glb header defined size");
return null;
}
var chunk0Length = (int)BitConverter.ToUInt32(glbData, stride * 3);
var chunk0Type = BitConverter.ToUInt32(glbData, stride * 4);
if (chunk0Type != (ulong)GltfChunkType.Json)
{
Debug.LogError("Expected chunk 0 to be Json data!");
return null;
}
string jsonChunk = Encoding.ASCII.GetString(glbData, stride * 5, chunk0Length);
GltfObject gltfObject = GetGltfObjectFromJson(jsonChunk);
int chunk1Length = (int)BitConverter.ToUInt32(glbData, stride * 5 + chunk0Length);
uint chunk1Type = BitConverter.ToUInt32(glbData, stride * 6 + chunk0Length);
if (chunk1Type != (ulong)GltfChunkType.BIN)
{
Debug.LogError("Expected chunk 1 to be BIN data!");
return null;
}
if (gltfObject == null)
{
Debug.LogError("Failed to load glTF object from JSON schema.");
return null;
}
// Per the spec, "byte length of BIN chunk could be up to 3 bytes bigger than JSON-defined buffer.byteLength to satisfy GLB padding requirements"
// https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md#glb-stored-buffer
Debug.Assert(gltfObject.buffers[0].byteLength <= chunk1Length && gltfObject.buffers[0].byteLength >= chunk1Length - 3, "chunk 1 & buffer 0 length mismatch");
gltfObject.buffers[0].BufferData = new byte[chunk1Length];
Array.Copy(glbData, stride * 7 + chunk0Length, gltfObject.buffers[0].BufferData, 0, chunk1Length);
return gltfObject;
}
///
/// Get a single Json Object using the handle provided.
///
/// The json string to search.
/// The handle to look for.
/// A snippet of the json string that defines the object.
private static string GetJsonObject(string jsonString, string handle)
{
var regex = new Regex($"\"{handle}\"\\s*:\\s*\\{{");
var match = regex.Match(jsonString);
return match.Success ? GetJsonObject(jsonString, match.Index + match.Length) : null;
}
private static List GetGltfMeshPrimitiveAttributes(string jsonString)
{
var regex = new Regex("\"attributes\" ?: ?(?{[^}]+})");
return GetGltfMeshPrimitiveAttributes(jsonString, regex);
}
private static List GetGltfMeshPrimitiveAttributes(string jsonString, Regex regex)
{
var jsonObjects = new List();
if (!regex.IsMatch(jsonString))
{
return jsonObjects;
}
MatchCollection matches = regex.Matches(jsonString);
for (var i = 0; i < matches.Count; i++)
{
jsonObjects.Add(matches[i].Groups["Data"].Captures[0].Value);
}
return jsonObjects;
}
///
/// Get a collection of glTF Extensions using the handle provided.
///
/// The json string to search.
/// The handle to look for.
/// A collection of snippets with the json string that defines the object.
private static Dictionary GetGltfExtensionObjects(string jsonString, string handle)
{
// Assumption: This code assumes that a name is declared before extensions in the glTF schema.
// This may not work for all exporters. Some exporters may fail to adhere to the standard glTF schema.
var regex = new Regex($"(\"name\":\\s*\"\\w*\",\\s*\"extensions\":\\s*{{\\s*?)(\"{handle}\"\\s*:\\s*{{)");
return GetGltfExtensions(jsonString, regex);
}
///
/// Get a collection of glTF Extras using the handle provided.
///
/// The json string to search.
/// The handle to look for.
/// A collection of snippets with the json string that defines the object.
private static Dictionary GetGltfExtraObjects(string jsonString, string handle)
{
// Assumption: This code assumes that a name is declared before extensions in the glTF schema.
// This may not work for all exporters. Some exporters may fail to adhere to the standard glTF schema.
var regex = new Regex($"(\"name\":\\s*\"\\w*\",\\s*\"extras\":\\s*{{\\s*?)(\"{handle}\"\\s*:\\s*{{)");
return GetGltfExtensions(jsonString, regex);
}
private static Dictionary GetGltfExtensions(string jsonString, Regex regex)
{
var jsonObjects = new Dictionary();
if (!regex.IsMatch(jsonString))
{
return jsonObjects;
}
var matches = regex.Matches(jsonString);
var nodeName = string.Empty;
for (var i = 0; i < matches.Count; i++)
{
for (int j = 0; j < matches[i].Groups.Count; j++)
{
for (int k = 0; k < matches[i].Groups[i].Captures.Count; k++)
{
nodeName = GetGltfNodeName(matches[i].Groups[i].Captures[i].Value);
}
}
if (!jsonObjects.ContainsKey(nodeName))
{
jsonObjects.Add(nodeName, GetJsonObject(jsonString, matches[i].Index + matches[i].Length));
}
}
return jsonObjects;
}
private static string GetJsonObject(string jsonString, int startOfObject)
{
int index;
int bracketCount = 1;
for (index = startOfObject; bracketCount > 0; index++)
{
if (jsonString[index] == '{')
{
bracketCount++;
}
else if (jsonString[index] == '}')
{
bracketCount--;
}
}
return $"{{{jsonString.Substring(startOfObject, index - startOfObject)}";
}
private static string GetGltfNodeName(string jsonString)
{
jsonString = jsonString.Replace("\"name\"", string.Empty);
jsonString = jsonString.Replace(": \"", string.Empty);
jsonString = jsonString.Replace(":\"", string.Empty);
jsonString = jsonString.Substring(0, jsonString.IndexOf("\"", StringComparison.Ordinal));
return jsonString;
}
///
/// A utility function to work around the JsonUtility inability to deserialize to a dictionary.
///
/// JSON string
/// A dictionary with the key value pairs found in the json
private static Dictionary StringIntDictionaryFromJson(string json)
{
string reformatted = JsonDictionaryToArray(json);
StringIntKeyValueArray loadedData = JsonUtility.FromJson(reformatted);
Dictionary dictionary = new Dictionary();
for (int i = 0; i < loadedData.items.Length; i++)
{
dictionary.Add(loadedData.items[i].key, loadedData.items[i].value);
}
return dictionary;
}
///
/// Takes a json object string with key value pairs, and returns a json string
/// in the format of `{"items": [{"key": $key_name, "value": $value}]}`.
/// This format can be handled by JsonUtility and support an arbitrary number
/// of key/value pairs
///
/// JSON string in the format `{"key": $value}`
/// Returns a reformatted JSON string
private static string JsonDictionaryToArray(string json)
{
string reformatted = "{\"items\": [";
string pattern = @"""(\w+)"":\s?(""?\w+""?)";
RegexOptions options = RegexOptions.Multiline;
foreach (Match m in Regex.Matches(json, pattern, options))
{
string key = m.Groups[1].Value;
string value = m.Groups[2].Value;
reformatted += $"{{\"key\":\"{key}\", \"value\":{value}}},";
}
reformatted = reformatted.TrimEnd(',');
reformatted += "]}";
return reformatted;
}
[System.Serializable]
private class StringKeyValue
{
public string key = string.Empty;
public int value = 0;
}
[System.Serializable]
private class StringIntKeyValueArray
{
public StringKeyValue[] items = Array.Empty();
}
}
}