-
-
Save elix22/155cce99daba3d60cb983a07c65aa626 to your computer and use it in GitHub Desktop.
Unity to Urho3D scene convertor
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Globalization; | |
using System.IO; | |
using System.Text; | |
using System.Xml; | |
using UnityEditor; | |
using UnityEditor.SceneManagement; | |
using UnityEngine; | |
using UnityEngine.SceneManagement; | |
public class UrhoExporter:IDisposable | |
{ | |
[MenuItem("Tools/Export Scene To Urho3D")] | |
public static void ExportToUrho() | |
{ | |
using (var exporter = new UrhoExporter(EditorSceneManager.GetActiveScene(), EditorUtility.SaveFilePanel( | |
"Save scene as Urho XML", | |
"", | |
"scene.xml", | |
"xml"))) | |
{ | |
exporter.Export(); | |
} | |
} | |
private Scene _scene; | |
private int _id; | |
string _outputFileName; | |
private TextWriter _stream; | |
private XmlTextWriter _writer; | |
private string _assetsFolder; | |
public UrhoExporter(Scene scene, string outputFileName) | |
{ | |
_scene = scene; | |
_outputFileName = Path.GetFullPath(outputFileName); | |
_assetsFolder = Path.GetDirectoryName(Path.GetDirectoryName(_outputFileName)); | |
if (string.IsNullOrEmpty(_assetsFolder)) | |
_assetsFolder = Path.GetDirectoryName(_outputFileName); | |
_stream = File.CreateText(_outputFileName); | |
_writer = new XmlTextWriter(_stream); | |
} | |
public void Export() | |
{ | |
if (string.IsNullOrEmpty(_outputFileName)) | |
return; | |
_writer.WriteStartDocument(); | |
_writer.WriteWhitespace("\n"); | |
_writer.WriteStartElement("scene"); | |
_writer.WriteAttributeString("id", (++_id).ToString()); | |
_writer.WriteWhitespace("\n"); | |
var prefix = "\t"; | |
StartCompoent(prefix, "Octree"); | |
EndElement(prefix); | |
StartCompoent(prefix, "DebugRenderer"); | |
EndElement(prefix); | |
StartCompoent(prefix, "PhysicsWorld"); | |
EndElement(prefix); | |
EnumerateObjects(prefix, _scene.GetRootGameObjects()); | |
_writer.WriteEndElement(); | |
_writer.WriteEndDocument(); | |
} | |
public void Dispose() | |
{ | |
if (_stream != null) | |
{ | |
_stream.Dispose(); | |
} | |
} | |
private void EnumerateObjects(string prefix, GameObject[] objects) | |
{ | |
foreach (var obj in objects) | |
{ | |
WriteObject(prefix, obj); | |
} | |
} | |
private string GetFileName(string name) | |
{ | |
foreach (var invalidFileNameChar in Path.GetInvalidFileNameChars()) | |
{ | |
name = name.Replace(invalidFileNameChar, '_'); | |
} | |
return name; | |
} | |
private void WriteObject(string prefix,GameObject obj) | |
{ | |
_writer.WriteWhitespace(prefix); | |
_writer.WriteStartElement("node"); | |
_writer.WriteAttributeString("id", (++_id).ToString()); | |
_writer.WriteWhitespace("\n"); | |
var subPrefix = prefix + "\t"; | |
var subSubPrefix = subPrefix + "\t"; | |
WriteAttribute(subPrefix, "Is Enabled", obj.activeSelf); | |
WriteAttribute(subPrefix, "Name", obj.name); | |
WriteAttribute(subPrefix, "Tags", obj.tag); | |
WriteAttribute(subPrefix, "Position", obj.transform.localPosition); | |
WriteAttribute(subPrefix, "Rotation", obj.transform.localRotation); | |
WriteAttribute(subPrefix, "Scale", obj.transform.localScale); | |
var meshFilter = obj.GetComponent<MeshFilter>(); | |
var meshRenderer = obj.GetComponent<MeshRenderer>(); | |
var meshCollider = obj.GetComponent<MeshCollider>(); | |
var terrain = obj.GetComponent<Terrain>(); | |
var light = obj.GetComponent<Light>(); | |
var camera = obj.GetComponent<Camera>(); | |
if (camera != null) | |
{ | |
StartCompoent(subPrefix, "Camera"); | |
WriteAttribute(subSubPrefix, "Near Clip", camera.nearClipPlane); | |
WriteAttribute(subSubPrefix, "Far Clip", camera.farClipPlane); | |
EndElement(subPrefix); | |
} | |
if (light != null && light.type != LightType.Area) | |
{ | |
StartCompoent(subPrefix, "Light"); | |
if (light.type == LightType.Directional) | |
{ | |
WriteAttribute(subSubPrefix, "Light Type", "Directional"); | |
} | |
else if (light.type == LightType.Spot) | |
{ | |
WriteAttribute(subSubPrefix, "Light Type", "Spot"); | |
} | |
else if (light.type == LightType.Point) | |
{ | |
WriteAttribute(subSubPrefix, "Range", light.range); | |
} | |
WriteAttribute(subSubPrefix, "Color", light.color); | |
WriteAttribute(subSubPrefix, "Brightness Multiplier", light.intensity); | |
EndElement(subPrefix); | |
} | |
if (terrain != null) | |
{ | |
var terrainSize = terrain.terrainData.size; | |
var y = terrain.SampleHeight(new Vector3(0, 0, 0)); | |
} | |
if (meshRenderer != null) | |
{ | |
if (meshFilter != null) | |
{ | |
StartCompoent(subPrefix, "StaticModel"); | |
var meshRelFileName = GetRelAssetPath(meshFilter.sharedMesh); | |
WriteAttribute(subSubPrefix, "Model", "Model;Models/" + meshRelFileName + ".mdl"); | |
var meshFileName = Path.Combine(Path.Combine(_assetsFolder, "Models"), meshRelFileName + ".mdl"); | |
if (!File.Exists(meshFileName)) | |
{ | |
Directory.CreateDirectory(Path.GetDirectoryName(meshFileName)); | |
using (var fileStream = File.Open(meshFileName, FileMode.Create, FileAccess.Write, FileShare.Read)) | |
{ | |
using (var writer = new BinaryWriter(fileStream)) | |
{ | |
WriteMesh(writer, meshFilter.sharedMesh); | |
} | |
} | |
} | |
StringBuilder material = new StringBuilder(); | |
material.Append("Material"); | |
var meshRendererMaterials = meshRenderer.sharedMaterials; | |
for (int i = 0; i < meshRendererMaterials.Length; ++i) | |
{ | |
var meshRendererMaterial = meshRendererMaterials[i]; | |
var relPath = GetRelAssetPath(meshRendererMaterial); | |
var outputMaterialName = "Materials/" + relPath+".xml"; | |
material.Append(";"); | |
material.Append(outputMaterialName); | |
var materialFileName = Path.Combine(_assetsFolder, outputMaterialName); | |
if (!File.Exists(materialFileName)) | |
{ | |
CreateMaterial(materialFileName, meshRendererMaterial); | |
} | |
} | |
WriteAttribute(subSubPrefix, "Material", material.ToString()); | |
EndElement(subPrefix); | |
} | |
} | |
if (meshCollider != null) | |
{ | |
} | |
foreach (Transform childTransform in obj.transform) | |
{ | |
if (childTransform.parent.gameObject == obj) | |
WriteObject(subPrefix, childTransform.gameObject); | |
} | |
_writer.WriteWhitespace(prefix); | |
_writer.WriteEndElement(); | |
_writer.WriteWhitespace("\n"); | |
} | |
public const uint Magic2 = 0x32444d55; | |
private void WriteMesh(BinaryWriter writer, Mesh _mesh) | |
{ | |
writer.Write(Magic2); | |
writer.Write(1); | |
for (int vbIndex = 0; vbIndex < 1 /*_mesh.vertexBufferCount*/; ++vbIndex) | |
{ | |
var positions = _mesh.vertices; | |
var normals = _mesh.normals; | |
var colors = _mesh.colors; | |
var tangents = _mesh.tangents; | |
var uvs = _mesh.uv; | |
var uvs2 = _mesh.uv2; | |
var uvs3 = _mesh.uv3; | |
var uvs4 = _mesh.uv4; | |
writer.Write(positions.Length); | |
var elements = new List<MeshStreamWriter>(); | |
if (positions.Length > 0) | |
{ | |
elements.Add(new MeshVector3Stream(positions, VertexElementSemantic.SEM_POSITION)); | |
} | |
if (normals.Length > 0) | |
{ | |
elements.Add(new MeshVector3Stream(normals, VertexElementSemantic.SEM_NORMAL)); | |
} | |
//if (colors.Length > 0) | |
//{ | |
// elements.Add(new MeshColorStream(colors, VertexElementSemantic.SEM_COLOR)); | |
//} | |
//if (tangents.Length > 0) | |
//{ | |
// elements.Add(new MeshVector4Stream(tangents, VertexElementSemantic.SEM_TANGENT)); | |
//} | |
if (uvs.Length > 0) | |
{ | |
elements.Add(new MeshVector2Stream(uvs, VertexElementSemantic.SEM_TEXCOORD, 0)); | |
} | |
if (uvs2.Length > 0) | |
{ | |
elements.Add(new MeshVector2Stream(uvs2, VertexElementSemantic.SEM_TEXCOORD, 1)); | |
} | |
if (uvs3.Length > 0) | |
{ | |
elements.Add(new MeshVector2Stream(uvs2, VertexElementSemantic.SEM_TEXCOORD, 2)); | |
} | |
if (uvs4.Length > 0) | |
{ | |
elements.Add(new MeshVector2Stream(uvs2, VertexElementSemantic.SEM_TEXCOORD, 3)); | |
} | |
writer.Write(elements.Count); | |
for (int i = 0; i < elements.Count; ++i) | |
{ | |
writer.Write(elements[i].Element); | |
} | |
int morphableVertexRangeStartIndex = 0; | |
int morphableVertexCount = 0; | |
writer.Write(morphableVertexRangeStartIndex); | |
writer.Write(morphableVertexCount); | |
for (int index = 0; index < positions.Length; ++index) | |
{ | |
for (int i = 0; i < elements.Count; ++i) | |
{ | |
elements[i].Write(writer, index); | |
} | |
} | |
var indicesPerSubMesh = new List<int[]>(); | |
int totalIndices = 0; | |
for (int subMeshIndex = 0; subMeshIndex < _mesh.subMeshCount; ++subMeshIndex) | |
{ | |
var indices = _mesh.GetIndices(subMeshIndex); | |
indicesPerSubMesh.Add(indices); | |
totalIndices += indices.Length; | |
} | |
writer.Write(1); | |
writer.Write(totalIndices); | |
if (positions.Length < 65536) | |
{ | |
writer.Write(2); | |
for (int subMeshIndex = 0; subMeshIndex < _mesh.subMeshCount; ++subMeshIndex) | |
{ | |
for (int i = 0; i < indicesPerSubMesh[subMeshIndex].Length; ++i) | |
{ | |
writer.Write((ushort)indicesPerSubMesh[subMeshIndex][i]); | |
} | |
} | |
} | |
else | |
{ | |
writer.Write(4); | |
for (int subMeshIndex = 0; subMeshIndex < _mesh.subMeshCount; ++subMeshIndex) | |
{ | |
for (int i = 0; i < indicesPerSubMesh[subMeshIndex].Length; ++i) | |
{ | |
writer.Write(indicesPerSubMesh[subMeshIndex][i]); | |
} | |
} | |
} | |
writer.Write(indicesPerSubMesh.Count); | |
totalIndices = 0; | |
for (int subMeshIndex = 0; subMeshIndex < indicesPerSubMesh.Count; ++subMeshIndex) | |
{ | |
var numberOfBoneMappingEntries = 0; | |
writer.Write(numberOfBoneMappingEntries); | |
var numberOfLODLevels = 1; | |
writer.Write(numberOfLODLevels); | |
writer.Write(0.0f); | |
writer.Write((int)PrimitiveType.TRIANGLE_LIST); | |
writer.Write(0); | |
writer.Write(0); | |
writer.Write(totalIndices); | |
writer.Write(indicesPerSubMesh[subMeshIndex].Length); | |
totalIndices += indicesPerSubMesh[subMeshIndex].Length; | |
writer.Write(0); | |
var numOfBones = 0; | |
writer.Write(numOfBones); | |
} | |
float minX, minY, minZ; | |
float maxX, maxY, maxZ; | |
maxX = maxY = maxZ = float.MinValue; | |
minX = minY = minZ = float.MaxValue; | |
for (int i = 0; i < positions.Length; ++i) | |
{ | |
if (minX > positions[i].x) | |
minX = positions[i].x; | |
if (minY > positions[i].y) | |
minY = positions[i].y; | |
if (minZ > positions[i].z) | |
minZ = positions[i].z; | |
if (maxX < positions[i].x) | |
maxX = positions[i].x; | |
if (maxY < positions[i].y) | |
maxY = positions[i].y; | |
if (maxZ < positions[i].z) | |
maxZ = positions[i].z; | |
} | |
writer.Write(minX); | |
writer.Write(minY); | |
writer.Write(minZ); | |
writer.Write(maxX); | |
writer.Write(maxY); | |
writer.Write(maxZ); | |
} | |
} | |
public enum PrimitiveType | |
{ | |
TRIANGLE_LIST = 0, | |
LINE_LIST, | |
POINT_LIST, | |
TRIANGLE_STRIP, | |
LINE_STRIP, | |
TRIANGLE_FAN | |
} | |
public enum VertexElementType | |
{ | |
TYPE_INT = 0, | |
TYPE_FLOAT, | |
TYPE_VECTOR2, | |
TYPE_VECTOR3, | |
TYPE_VECTOR4, | |
TYPE_UBYTE4, | |
TYPE_UBYTE4_NORM, | |
MAX_VERTEX_ELEMENT_TYPES | |
} | |
public enum VertexElementSemantic | |
{ | |
SEM_POSITION = 0, | |
SEM_NORMAL, | |
SEM_BINORMAL, | |
SEM_TANGENT, | |
SEM_TEXCOORD, | |
SEM_COLOR, | |
SEM_BLENDWEIGHTS, | |
SEM_BLENDINDICES, | |
SEM_OBJECTINDEX, | |
MAX_VERTEX_ELEMENT_SEMANTICS | |
} | |
internal abstract class MeshStreamWriter | |
{ | |
public int Element; | |
public abstract void Write(BinaryWriter writer, int index); | |
} | |
internal class MeshVector3Stream: MeshStreamWriter | |
{ | |
private Vector3[] positions; | |
public MeshVector3Stream(Vector3[] positions, VertexElementSemantic sem, int index = 0) | |
{ | |
this.positions = positions; | |
Element = (int)VertexElementType.TYPE_VECTOR3 | ((int)sem << 8) | (index <<16); | |
} | |
public override void Write(BinaryWriter writer, int index) | |
{ | |
writer.Write(positions[index].x); | |
writer.Write(positions[index].y); | |
writer.Write(positions[index].z); | |
} | |
} | |
internal class MeshVector2Stream : MeshStreamWriter | |
{ | |
private Vector2[] positions; | |
public MeshVector2Stream(Vector2[] positions, VertexElementSemantic sem, int index = 0) | |
{ | |
this.positions = positions; | |
Element = (int)VertexElementType.TYPE_VECTOR2 | ((int)sem << 8) | (index << 16); | |
} | |
public override void Write(BinaryWriter writer, int index) | |
{ | |
writer.Write(positions[index].x); | |
writer.Write(positions[index].y); | |
} | |
} | |
private void CreateMaterial(string materialFileName, Material material) | |
{ | |
Directory.CreateDirectory(Path.GetDirectoryName(materialFileName)); | |
using (var writer = XmlTextWriter.Create(materialFileName)) | |
{ | |
writer.WriteStartDocument(); | |
writer.WriteStartElement("material"); | |
writer.WriteStartElement("technique"); | |
writer.WriteAttributeString("name", "Techniques/Diff.xml"); | |
writer.WriteAttributeString("quality", "0"); | |
writer.WriteEndElement(); | |
var shader = material.shader; | |
for (int i = 0; i < ShaderUtil.GetPropertyCount(shader); i++) | |
{ | |
if (ShaderUtil.GetPropertyType(shader, i) == ShaderUtil.ShaderPropertyType.TexEnv) | |
{ | |
var propertyName = ShaderUtil.GetPropertyName(shader, i); | |
Texture texture = material.GetTexture(propertyName); | |
if (texture != null) | |
{ | |
switch (propertyName) | |
{ | |
case "_MainTex": | |
WriteTexture(texture, writer, "diffuse"); | |
break; | |
case "_SpecGlossMap": | |
WriteTexture(texture, writer, "specular"); | |
break; | |
case "_ParallaxMap": | |
break; | |
case "_BumpMap": | |
WriteTexture(texture, writer, "normal"); | |
break; | |
case "_DetailAlbedoMap": | |
break; | |
case "_DetailNormalMap": | |
break; | |
case "_EmissionMap": | |
break; | |
case "_MetallicGlossMap": | |
break; | |
case "_OcclusionMap": | |
break; | |
case "_DetailMask": | |
break; | |
default: | |
Debug.LogWarning(propertyName); | |
break; | |
} | |
} | |
} | |
} | |
writer.WriteEndElement(); | |
writer.WriteEndDocument(); | |
} | |
} | |
private string WriteTexture(Texture texture, XmlWriter writer, string name) | |
{ | |
if (texture == null) | |
return null; | |
var sourceTexture2D = texture as Texture2D; | |
var relPath = GetRelAssetPath(texture); | |
int extIndex = relPath.LastIndexOf('.')+1; | |
bool needFormatConvertion = false; | |
if (extIndex > 0) | |
{ | |
string ext = relPath.Substring(extIndex).ToLower(); | |
if (sourceTexture2D != null) | |
{ | |
if (ext == "psd" || sourceTexture2D.format == TextureFormat.DXT5 || sourceTexture2D.format == TextureFormat.DXT1) | |
{ | |
needFormatConvertion = true; | |
relPath = relPath.Substring(0, extIndex) + "dds"; | |
} | |
} | |
} | |
var dataPath = Application.dataPath; | |
var outputPath = "Textures/" + relPath; | |
writer.WriteStartElement("texture"); | |
writer.WriteAttributeString("unit", name); | |
writer.WriteAttributeString("name", outputPath); | |
writer.WriteEndElement(); | |
string destFileName = Path.Combine(this._assetsFolder, outputPath); | |
if (!File.Exists(destFileName)) | |
{ | |
Directory.CreateDirectory(Path.GetDirectoryName(destFileName)); | |
if (needFormatConvertion) | |
{ | |
//var texture2d = new Texture2D(sourceTexture.width, sourceTexture.height); | |
//texture2d.SetPixels32(sourceTexture.GetPixels32(0)); | |
var texture2d = new Texture2D(sourceTexture2D.width, sourceTexture2D.height, sourceTexture2D.format, sourceTexture2D.mipmapCount > 1); | |
texture2d.LoadRawTextureData(sourceTexture2D.GetRawTextureData()); | |
texture2d.Apply(); | |
texture2d.Compress(true); | |
var rawCompressedTexture = texture2d.GetRawTextureData(); | |
var ms = new MemoryStream(); | |
var br = new BinaryWriter(ms); | |
var a = new byte[] | |
{ | |
0x44, 0x44, 0x53, 0x20, 0x7C, 0x00, 0x00, 0x00, 0x07, 0x10, 0x0A, 0x00, | |
}; | |
br.Write(a); | |
br.Write(sourceTexture2D.width); | |
br.Write(sourceTexture2D.height); | |
a = new byte[] | |
{ | |
0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, | |
0x04, 0x00, 0x00, 0x00, | |
}; | |
br.Write(a); | |
if (texture2d.format == TextureFormat.DXT5) | |
a = new byte[] { 0x44, 0x58, 0x54, 0x35 }; | |
else | |
a = new byte[] { 0x44, 0x58, 0x54, 0x31 }; | |
br.Write(a); | |
a = new byte[] | |
{ | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x10, 0x40, 0x00, | |
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
}; | |
br.Write(a); | |
br.Write(rawCompressedTexture); | |
File.WriteAllBytes(destFileName, ms.ToArray()); | |
//var rawCompressedTexture = texture2d.EncodeToPNG(); | |
//File.WriteAllBytes(destFileName, rawCompressedTexture); | |
UnityEngine.Object.Destroy(texture2d); | |
} | |
else | |
{ | |
File.Copy(Path.Combine(dataPath, relPath), destFileName); | |
} | |
} | |
return relPath; | |
} | |
private string GetRelAssetPath(UnityEngine.Object assetObject) | |
{ | |
var path = AssetDatabase.GetAssetPath(assetObject); | |
if (string.IsNullOrEmpty(path)) | |
return GetFileName(TrimInstance(assetObject.name)); | |
var relPath = path.Substring(path.IndexOf('/') + 1); | |
return relPath; | |
} | |
private string TrimInstance(string assetObjectName) | |
{ | |
var instance = " Instance"; | |
var instance2 = " (Instance)"; | |
loop: | |
if (assetObjectName.EndsWith(instance)) | |
{ | |
assetObjectName = assetObjectName.Substring(0, assetObjectName.Length - instance.Length); | |
goto loop; | |
} | |
if (assetObjectName.EndsWith(instance2)) | |
{ | |
assetObjectName = assetObjectName.Substring(0, assetObjectName.Length - instance2.Length); | |
goto loop; | |
} | |
return assetObjectName; | |
} | |
private void WriteAttribute(string prefix, string name, float pos) | |
{ | |
WriteAttribute(prefix, name, string.Format(CultureInfo.InvariantCulture, "{0}", pos)); | |
} | |
private void WriteAttribute(string prefix,string name, Vector3 pos) | |
{ | |
WriteAttribute(prefix, name, string.Format(CultureInfo.InvariantCulture, "{0} {1} {2}", pos.x, pos.y, pos.z)); | |
} | |
private void WriteAttribute(string prefix, string name, Quaternion pos) | |
{ | |
WriteAttribute(prefix, name, string.Format(CultureInfo.InvariantCulture, "{0} {1} {2} {3}", pos.w, pos.x, pos.y, pos.z)); | |
} | |
private void WriteAttribute(string prefix, string name, Color pos) | |
{ | |
WriteAttribute(prefix, name, string.Format(CultureInfo.InvariantCulture, "{0} {1} {2} {3}", pos.r, pos.g, pos.b, pos.a)); | |
} | |
private void WriteAttribute(string prefix, string name, bool flag) | |
{ | |
WriteAttribute(prefix, name, flag ? "true" : "false"); | |
} | |
private void EndElement(string prefix) | |
{ | |
_writer.WriteWhitespace(prefix); | |
_writer.WriteEndElement(); | |
_writer.WriteWhitespace("\n"); | |
} | |
private void StartCompoent(string prefix, string type) | |
{ | |
_writer.WriteWhitespace(prefix); | |
_writer.WriteStartElement("component"); | |
_writer.WriteAttributeString("type", type); | |
_writer.WriteAttributeString("id", (++_id).ToString()); | |
_writer.WriteWhitespace("\n"); | |
} | |
private void WriteAttribute(string prefix, string name, string vaue) | |
{ | |
_writer.WriteWhitespace(prefix); | |
_writer.WriteStartElement("attribute"); | |
_writer.WriteAttributeString("name", name); | |
_writer.WriteAttributeString("value", vaue); | |
_writer.WriteEndElement(); | |
_writer.WriteWhitespace("\n"); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment