using UnityEngine; using UnityEngine.Networking; using System; using System.Collections; using System.Collections.Generic; using System.Text.RegularExpressions; using System.IO; using System.Globalization; using System.Threading.Tasks; using static BrainFailProductions.PolyFewRuntime.PolyfewRuntime; using System.Text; namespace BrainFailProductions.PolyFew.AsImpL { /// /// Class for loading OBJ files into Unity scene at run-time and in editor mode. /// /// /// Partially derived from "Runtime OBJ Loader" /// (http://forum.unity3d.com/threads/free-runtime-obj-loader.365884/) /// and from "runtime .OBJ file loader for Unity3D" /// (https://github.com/hammmm/unity-obj-loader) and /// (https://github.com/cmdr2/unity-remote-obj-loader) /// /// New features: /// /// meshes with more than 65K vertices/indices are splitted and loaded /// groups are loaded into game (sub) objects /// extended material support /// computation of normal maps and tangents /// computation of albedo texture from diffuse and opacity textures /// progressive loading /// reusing data for multiple objects /// create a loader for each model for parallel loading /// support for asset import /// /// /// /// /// public class LoaderObj : Loader { private string mtlLib; private string loadedText; /// /// Parse dependencies of the given OBJ file. /// /// absolute file path /// The list of dependencies (textures files, if any). public override string[] ParseTexturePaths(string absolutePath) { List mtlTexPathList = new List(); string basePath = GetDirName(absolutePath); string mtlLibName = ParseMaterialLibName(absolutePath); if (!string.IsNullOrEmpty(mtlLibName)) { //mtlDepPathList.Add(mtlLibName); string mtlPath = basePath + mtlLibName; string[] lines = File.ReadAllLines(mtlPath); List mtlData = new List(); ParseMaterialData(lines, mtlData); foreach (MaterialData mtl in mtlData) { if (!string.IsNullOrEmpty(mtl.diffuseTexPath)) { mtlTexPathList.Add(mtl.diffuseTexPath); } if (!string.IsNullOrEmpty(mtl.specularTexPath)) { mtlTexPathList.Add(mtl.specularTexPath); } if (!string.IsNullOrEmpty(mtl.bumpTexPath)) { mtlTexPathList.Add(mtl.bumpTexPath); } if (!string.IsNullOrEmpty(mtl.opacityTexPath)) { mtlTexPathList.Add(mtl.opacityTexPath); } } } return mtlTexPathList.ToArray(); } protected override async Task LoadModelFile(string absolutePath, string texturesFolderPath = "", string materialsFolderPath = "") { #pragma warning disable string url = absolutePath.Contains("//") ? absolutePath : "file:///" + absolutePath; using (StreamReader sr = new StreamReader(absolutePath)) { loadedText = await sr.ReadToEndAsync(); } //yield return LoadOrDownloadText(url); if (string.IsNullOrEmpty(loadedText)) { totalProgress.singleProgress.Remove(objLoadingProgress); throw new InvalidOperationException("Failed to load data from file. The file might be empty or non readable."); // remove this progress to let complete the total loading process //return; } //Debug.LogFormat("Parsing geometry data in {0}...", www.url); ParseGeometryData(loadedText); } protected override async Task LoadModelFileNetworked(string objURL) { bool isWorking = true; byte[] downloadedBytes = null; Exception ex = null; float oldProgress = individualProgress.Value; try { StartCoroutine(DownloadFile(objURL, individualProgress, (bytes) => { isWorking = false; downloadedBytes = bytes; //loadedText = Encoding.UTF8.GetString(bytes); }, (error) => { ObjectImporter.activeDownloads -= 1; ex = new System.InvalidOperationException("Failed to download base model." + error); isWorking = false; })); } catch(Exception exc) { ObjectImporter.activeDownloads -= 1; individualProgress.Value = oldProgress; ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; isWorking = false; throw exc; } while (isWorking) { //Debug.Log("Stuck in ISWORKING WHILE LOOP"); ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; await Task.Delay(1); } if (ex != null) { throw ex; } ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; if (downloadedBytes != null && downloadedBytes.Length > 0) { using (StreamReader sr = new StreamReader(new MemoryStream(downloadedBytes))) { loadedText = await sr.ReadToEndAsync(); } } //yield return LoadOrDownloadText(url); if (string.IsNullOrEmpty(loadedText)) { totalProgress.singleProgress.Remove(objLoadingProgress); throw new InvalidOperationException("Failed to load data from the downloaded obj file. The file might be empty or non readable."); // remove this progress to let complete the total loading process //return; } //Debug.LogFormat("Parsing geometry data in {0}...", www.url); try { ParseGeometryData(loadedText); } catch (Exception exc) { throw exc; } } protected override IEnumerator LoadModelFileNetworkedWebGL(string objURL, Action OnError) { bool isWorking = true; Exception ex = null; float oldProgress = individualProgress.Value; try { StartCoroutine(DownloadFileWebGL(objURL, individualProgress, (text) => { isWorking = false; loadedText = text; //loadedText = Encoding.UTF8.GetString(bytes); }, (error) => { ObjectImporter.activeDownloads -= 1; ex = new System.InvalidOperationException("Base model download unsuccessful." + error); ObjectImporter.isException = true; OnError(ex); isWorking = false; })); } catch (Exception exc) { ObjectImporter.activeDownloads -= 1; individualProgress.Value = oldProgress; ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; isWorking = false; isWorking = false; OnError(exc); ObjectImporter.isException = true; } while (isWorking) { yield return new WaitForSeconds(0.1f); ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; } if (ObjectImporter.isException) { yield return null; } ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; //yield return LoadOrDownloadText(url); if (string.IsNullOrEmpty(loadedText)) { totalProgress.singleProgress.Remove(objLoadingProgress); throw new InvalidOperationException("Failed to load data from the downloaded obj file. The file might be empty or non readable."); } //Debug.LogFormat("Parsing geometry data in {0}...", www.url); try { ParseGeometryData(loadedText); } catch (Exception exc) { OnError(exc); ObjectImporter.isException = true; } } protected override async Task LoadMaterialLibrary(string absolutePath, string materialsFolderPath = "") { string mtlPath; string basePath = GetDirName(absolutePath); if (absolutePath.Contains("//")) { int pos; // handle the special case of a PHP URL containing "...?...=model.obj" if (absolutePath.Contains("?")) { // in this case try to get the library path reading until last "=". pos = absolutePath.LastIndexOf('='); } else { pos = absolutePath.LastIndexOf('/'); } mtlPath = absolutePath.Remove(pos + 1) + mtlLib; } else { if(Path.IsPathRooted(mtlLib)) { mtlPath = "file:///" + mtlLib; } else { mtlPath = "file:///" + basePath + mtlLib; } } string matPath = string.IsNullOrWhiteSpace(materialsFolderPath) ? basePath + mtlLib : materialsFolderPath + mtlLib; if (File.Exists(matPath)) { using (StreamReader sr = new StreamReader(matPath)) { loadedText = await sr.ReadToEndAsync(); } } else { Debug.LogWarning("Cannot find the associated material file at the path " + basePath + mtlLib); } //yield return LoadOrDownloadText(mtlPath,false); /* if (loadedText == null) { mtlLib = Path.GetFileName(mtlLib); mtlPath = "file:///" + basePath + mtlLib; Debug.LogWarningFormat("Material library {0} loaded from the same directory as the OBJ file.\n", mtlLib); yield return LoadOrDownloadText(mtlPath); } */ if (!string.IsNullOrWhiteSpace(loadedText)) { //Debug.LogFormat("Parsing material libray {0}...", loader.url); objLoadingProgress.message = "Parsing material library..."; ParseMaterialData(loadedText); } } protected override async Task LoadMaterialLibrary(string materialURL) { bool isWorking = true; byte[] downloadedBytes = null; float oldProgress = individualProgress.Value; try { StartCoroutine(DownloadFile(materialURL, individualProgress, (bytes) => { isWorking = false; downloadedBytes = bytes; //loadedText = Encoding.UTF8.GetString(bytes); }, (error) => { ObjectImporter.activeDownloads -= 1; isWorking = false; Debug.LogWarning("Failed to load the associated material file." + error); })); } catch (Exception exc) { ObjectImporter.activeDownloads -= 1; individualProgress.Value = oldProgress; ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; isWorking = false; throw exc; } while (isWorking) { ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; await Task.Delay(3); } ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; if (downloadedBytes != null && downloadedBytes.Length > 0) { using (StreamReader sr = new StreamReader(new MemoryStream(downloadedBytes))) { loadedText = await sr.ReadToEndAsync(); } } if (!string.IsNullOrWhiteSpace(loadedText)) { //Debug.LogFormat("Parsing material libray {0}...", loader.url); objLoadingProgress.message = "Parsing material library..."; ParseMaterialData(loadedText); } } protected override IEnumerator LoadMaterialLibraryWebGL(string materialURL) { bool isWorking = true; float oldProgress = individualProgress.Value; StartCoroutine(DownloadFileWebGL(materialURL, individualProgress, (text) => { isWorking = false; loadedText = text; //loadedText = Encoding.UTF8.GetString(bytes); }, (error) => { ObjectImporter.activeDownloads -= 1; isWorking = false; Debug.LogWarning("Failed to load the associated material file." + error); })); while (isWorking) { yield return new WaitForSeconds(0.1f); ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; } ObjectImporter.downloadProgress.Value = (individualProgress.Value / ObjectImporter.activeDownloads) * 100f; if (!string.IsNullOrWhiteSpace(loadedText)) { //Debug.LogFormat("Parsing material libray {0}...", loader.url); objLoadingProgress.message = "Parsing material library..."; ParseMaterialData(loadedText); } } private void GetFaceIndicesByOneFaceLine(DataSet.FaceIndices[] faces, string[] p, bool isFaceIndexPlus) { if (isFaceIndexPlus) { for (int j = 1; j < p.Length; j++) { string[] c = p[j].Trim().Split("/".ToCharArray()); DataSet.FaceIndices fi = new DataSet.FaceIndices(); // vertex int vi = int.Parse(c[0]); fi.vertIdx = vi - 1; // uv if (c.Length > 1 && c[1] != "") { int vu = int.Parse(c[1]); fi.uvIdx = vu - 1; } // normal if (c.Length > 2 && c[2] != "") { int vn = int.Parse(c[2]); fi.normIdx = vn - 1; } else { fi.normIdx = -1; } faces[j - 1] = fi; } } else { // for minus index int vertexCount = dataSet.vertList.Count; int uvCount = dataSet.uvList.Count; for (int j = 1; j < p.Length; j++) { string[] c = p[j].Trim().Split("/".ToCharArray()); DataSet.FaceIndices fi = new DataSet.FaceIndices(); // vertex int vi = int.Parse(c[0]); fi.vertIdx = vertexCount + vi; // uv if (c.Length > 1 && c[1] != "") { int vu = int.Parse(c[1]); fi.uvIdx = uvCount + vu; } // normal if (c.Length > 2 && c[2] != "") { int vn = int.Parse(c[2]); fi.normIdx = vertexCount + vn; } else { fi.normIdx = -1; } faces[j - 1] = fi; } } } /// /// Convert coordinates according to import options. /// private Vector3 ConvertVec3(float x, float y, float z) { if (Scaling != 1f) { x *= Scaling; y *= Scaling; z *= Scaling; } if (ConvertVertAxis) return new Vector3(x, z, y); return new Vector3(x, y, -z); } /// /// Parse a string to get a floating point number using the invariant culture. /// /// String with the number to be parsed /// The parsed floating point number. private float ParseFloat(string floatString) { return float.Parse(floatString, CultureInfo.InvariantCulture.NumberFormat); } /// /// Parse the OBJ file to extract geometry data. /// /// OBJ file text /// Execution is splitted into steps to not freeze the caller method. #pragma warning disable protected void ParseGeometryData(string objDataText) { string[] lines = objDataText.Split("\n".ToCharArray()); bool isFirstInGroup = true; bool isFaceIndexPlus = true; objLoadingProgress.message = "Parsing geometry data..."; // store separators, used multiple times char[] separators = new char[] { ' ', '\t' }; for (int i = 0; i < lines.Length; i++) { string line = lines[i].Trim(); if (line.Length > 0 && line[0] == '#') { // comment line continue; } string[] p = line.Split(separators, StringSplitOptions.RemoveEmptyEntries); if (p.Length == 0) { // empty line continue; } string parameters = null; if (line.Length > p[0].Length) { parameters = line.Substring(p[0].Length + 1).Trim(); } switch (p[0]) { case "o": dataSet.AddObject(parameters); isFirstInGroup = true; break; case "g": isFirstInGroup = true; dataSet.AddGroup(parameters); break; case "v": dataSet.AddVertex(ConvertVec3(ParseFloat(p[1]), ParseFloat(p[2]), ParseFloat(p[3]))); if (p.Length >= 7) { // 7 for "v x y z r g b" // 8 for "v x y z r g b w" // w is the weight required for rational curves and surfaces. It is // not required for non - rational curves and surfaces.If you do not // specify a value for w, the default is 1.0. [http://paulbourke.net/dataformats/obj/] dataSet.AddColor(new Color(ParseFloat(p[4]), ParseFloat(p[5]), ParseFloat(p[6]), 1f)); } break; case "vt": dataSet.AddUV(new Vector2(ParseFloat(p[1]), ParseFloat(p[2]))); break; case "vn": dataSet.AddNormal(ConvertVec3(ParseFloat(p[1]), ParseFloat(p[2]), ParseFloat(p[3]))); break; case "f": { int numVerts = p.Length - 1; DataSet.FaceIndices[] face = new DataSet.FaceIndices[numVerts]; if (isFirstInGroup) { isFirstInGroup = false; string[] c = p[1].Trim().Split("/".ToCharArray()); isFaceIndexPlus = (int.Parse(c[0]) >= 0); } GetFaceIndicesByOneFaceLine(face, p, isFaceIndexPlus); if (numVerts == 3) { dataSet.AddFaceIndices(face[0]); dataSet.AddFaceIndices(face[2]); dataSet.AddFaceIndices(face[1]); } else { // Triangulate the polygon // TODO: Texturing and lighting work better with a triangulation that maximizes triangles areas. // TODO: the following true must be replaced to a proper option (disabled by default) as soon as a proper triangulation method is implemented. Triangulator.Triangulate(dataSet, face); // TODO: Maybe triangulation could be done in ObjectImporter instead. } } break; case "mtllib": if (!string.IsNullOrEmpty(parameters)) { mtlLib = parameters; } break; case "usemtl": if (!string.IsNullOrEmpty(parameters)) { dataSet.AddMaterialName(DataSet.FixMaterialName(parameters)); } break; } // update progress only sometimes if (i % 7000 == 0) { objLoadingProgress.percentage = LOAD_PHASE_PERC * i / lines.Length; //return; } } objLoadingProgress.percentage = LOAD_PHASE_PERC; //dataSet.PrintSummary(); } /// /// Extract the material library (file) name from the OBJ file. /// /// /// private string ParseMaterialLibName(string path) { string[] lines = File.ReadAllLines(path); objLoadingProgress.message = "Parsing geometry data..."; for (int i = 0; i < lines.Length; i++) { string l = lines[i].Trim(); if (l.StartsWith("mtllib")) { return l.Substring("mtllib".Length).Trim(); } } return null; } /// /// Check if a material library file is defined. /// protected override bool HasMaterialLibrary { get { return mtlLib != null; } } /// /// Parse the material library text to get material data. /// /// material library text (read from file) private void ParseMaterialData(string data) { objLoadingProgress.message = "Parsing material data..."; string[] lines = data.Split("\n".ToCharArray()); materialData = new List(); ParseMaterialData(lines, materialData); } /// /// Parse the material library lines to get material data. /// /// lines read from the material library file /// list of material data private void ParseMaterialData(string[] lines, List mtlData) { MaterialData current = new MaterialData(); char[] separators = new char[] { ' ', '\t' }; for (int i = 0; i < lines.Length; i++) { string line = lines[i].Trim(); // remove comments if (line.IndexOf("#") != -1) line = line.Substring(0, line.IndexOf("#")); string[] p = line.Split(separators, StringSplitOptions.RemoveEmptyEntries); if (p.Length == 0 || string.IsNullOrEmpty(p[0])) continue; string parameters = null; if (line.Length > p[0].Length) { parameters = line.Substring(p[0].Length + 1).Trim(); } try { switch (p[0]) { case "newmtl": current = new MaterialData(); current.materialName = DataSet.FixMaterialName(parameters); mtlData.Add(current); break; case "Ka": // Ambient component (not supported) current.ambientColor = StringsToColor(p); break; case "Kd": // Diffuse component current.diffuseColor = StringsToColor(p); break; case "Ks": // Specular component current.specularColor = StringsToColor(p); break; case "Ke": // Specular component current.emissiveColor = StringsToColor(p); break; case "Ns": // Specular exponent --> shininess current.shininess = ParseFloat(p[1]); break; case "d": // dissolve into the background (1=opaque, 0=transparent) current.overallAlpha = p.Length > 1 && p[1] != "" ? ParseFloat(p[1]) : 1.0f; break; case "Tr": // Transparency current.overallAlpha = p.Length > 1 && p[1] != "" ? 1.0f - ParseFloat(p[1]) : 1.0f; break; case "map_KD": case "map_Kd": // Color texture, diffuse reflectivity if (!string.IsNullOrEmpty(parameters)) { current.diffuseTexPath = parameters; } break; // TODO: different processing needed, options not supported case "map_Ks": // specular reflectivity of the material case "map_kS": case "map_Ns": // Scalar texture for specular exponent if (!string.IsNullOrEmpty(parameters)) { current.specularTexPath = parameters; } break; case "map_bump": // Bump map texture if (!string.IsNullOrEmpty(parameters)) { current.bumpTexPath = parameters; } break; case "bump": ParseBumpParameters(p, current); break; case "map_opacity": case "map_d": // Scalar texture modulating the dissolve into the background if (!string.IsNullOrEmpty(parameters)) { current.opacityTexPath = parameters; } break; case "illum": // Illumination model. 1 - diffuse, 2 - specular (not used) current.illumType = int.Parse(p[1]); break; case "refl": // reflection map (replaced with Unity environment reflection) if (!string.IsNullOrEmpty(parameters)) { current.hasReflectionTex = true; } break; case "map_Ka": // ambient reflectivity color texture case "map_kA": if (!string.IsNullOrEmpty(parameters)) { Debug.Log("Map not supported:" + line); } break; default: //Debug.Log("this line was not processed :" + line); break; } } catch (Exception e) { Debug.LogErrorFormat("Error at line {0} in mtl file: {1}", i + 1, e); } } } /// /// Parse bump parameters. /// /// list of paramers /// material data to be updated /// Only the bump map texture path is used here. /// private void ParseBumpParameters(string[] param, MaterialData mtlData) { Regex regexNumber = new Regex(@"^[-+]?[0-9]*\.?[0-9]+$"); var bumpParams = new Dictionary(); bumpParams.Add("bm", new BumpParamDef("bm", "string", 1, 1)); bumpParams.Add("clamp", new BumpParamDef("clamp", "string", 1, 1)); bumpParams.Add("blendu", new BumpParamDef("blendu", "string", 1, 1)); bumpParams.Add("blendv", new BumpParamDef("blendv", "string", 1, 1)); bumpParams.Add("imfchan", new BumpParamDef("imfchan", "string", 1, 1)); bumpParams.Add("mm", new BumpParamDef("mm", "string", 1, 1)); bumpParams.Add("o", new BumpParamDef("o", "number", 1, 3)); bumpParams.Add("s", new BumpParamDef("s", "number", 1, 3)); bumpParams.Add("t", new BumpParamDef("t", "number", 1, 3)); bumpParams.Add("texres", new BumpParamDef("texres", "string", 1, 1)); int pos = 1; string filename = null; while (pos < param.Length) { if (!param[pos].StartsWith("-")) { filename = param[pos]; pos++; continue; } // option processing string optionName = param[pos].Substring(1); pos++; if (!bumpParams.ContainsKey(optionName)) { continue; } BumpParamDef def = bumpParams[optionName]; ArrayList args = new ArrayList(); int i = 0; bool isOptionNotEnough = false; for (; i < def.valueNumMin; i++, pos++) { if (pos >= param.Length) { isOptionNotEnough = true; break; } if (def.valueType == "number") { Match match = regexNumber.Match(param[pos]); if (!match.Success) { isOptionNotEnough = true; break; } } args.Add(param[pos]); } if (isOptionNotEnough) { Debug.Log("bump variable value not enough for option:" + optionName + " of material:" + mtlData.materialName); continue; } for (; i < def.valueNumMax && pos < param.Length; i++, pos++) { if (def.valueType == "number") { Match match = regexNumber.Match(param[pos]); if (!match.Success) { break; } } args.Add(param[pos]); } // TODO: some processing of options Debug.Log("found option: " + optionName + " of material: " + mtlData.materialName + " args: " + string.Concat(args.ToArray())); } // set the file name, if found // TODO: other parsed parameters are not used for now if (filename != null) { mtlData.bumpTexPath = filename; } } private Color StringsToColor(string[] p) { return new Color(ParseFloat(p[1]), ParseFloat(p[2]), ParseFloat(p[3])); } private IEnumerator LoadOrDownloadText(string url, bool notifyErrors = true) { loadedText = null; #if UNITY_2018_3_OR_NEWER UnityWebRequest uwr = UnityWebRequest.Get(url); yield return uwr.SendWebRequest(); if (uwr.isNetworkError || uwr.isHttpError) { if (notifyErrors) { Debug.LogError(uwr.error); } } else { // Get downloaded asset bundle loadedText = uwr.downloadHandler.text; } #else WWW www = new WWW(url); yield return www; if (www.error != null) { if (notifyErrors) { Debug.LogError("Error loading " + url + "\n" + www.error); } } else { loadedText = www.text; } #endif } /// /// Bump parameter definition /// /// Not really used for material definition, for now. /// private class BumpParamDef { public string optionName; public string valueType; public int valueNumMin; public int valueNumMax; public BumpParamDef(string name, string type, int numMin, int numMax) { optionName = name; valueType = type; valueNumMin = numMin; valueNumMax = numMax; } } } }