commit 49fee37cf5e910dcef688cce2eb8e12f2f0678f9 Author: copygirl Date: Fri Oct 3 19:49:21 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a1aac6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.vscode/ +/bin/ +/obj/ +/test/ diff --git a/Bson.cs b/Bson.cs new file mode 100644 index 0000000..d6bad2b --- /dev/null +++ b/Bson.cs @@ -0,0 +1,278 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json.Nodes; + +namespace Res2tf; + +using List = List; +using Dict = Dictionary; + +public enum ElementType : byte +{ + Double = 1, + String, + Object, + Array, + Binary, + Undefined, // deprecated + ObjectId, // unsupported + Boolean, + DateTime, + Null, + RegExp, // unsupported + DbPointer, // deprecated + JsCode, // unsupported + Symbol, // deprecated + JsCodeScope, // deprecated + Int32, + Timestamp, // unsupported + Int64, + Decimal128, // unsupported +}; + +public class BsonValue : IEquatable +{ + public static readonly BsonValue Null = new(ElementType.Null, null); + + public ElementType ElementType { get; } + public object? Value { get; } + + public int Count => AsCollection().Count; + public BsonValue this[int index] { get => AsList()[index]; set => AsList()[index] = value; } + public BsonValue this[string key] { get => AsDict()[key ]; set => AsDict()[key ] = value; } + public Dict.KeyCollection Keys => AsDict().Keys; + public Dict.ValueCollection Values => AsDict().Values; + + private BsonValue(ElementType type, object? value) { ElementType = type; Value = value; } + + public BsonValue(double value) : this(ElementType.Double , value) { } + public BsonValue(string value) : this(ElementType.String , value) { ArgumentNullException.ThrowIfNull(value); } + public BsonValue(byte[] value) : this(ElementType.Binary , value) { ArgumentNullException.ThrowIfNull(value); } + public BsonValue(bool value) : this(ElementType.Boolean , value) { } + public BsonValue(DateTime value) : this(ElementType.DateTime, value) { } + public BsonValue(int value) : this(ElementType.Int32 , value) { } + public BsonValue(long value) : this(ElementType.Int64 , value) { } + + public static BsonValue Array() => new(ElementType.Array, new List()); + public static BsonValue Array(params IEnumerable values) => new(ElementType.Array, values.ToList()); + + public static BsonValue Object() => new(ElementType.Object, new Dict()); + public static BsonValue Object(IDictionary entries) => new(ElementType.Object, entries.ToDictionary()); + public static BsonValue Object(params IEnumerable<(string, BsonValue)> entries) => new(ElementType.Object, entries.ToDictionary()); + + public List AsList() => (Value as List) ?? throw new InvalidOperationException($"Attempting to access {ElementType} as Array"); + public Dict AsDict() => (Value as Dict) ?? throw new InvalidOperationException($"Attempting to access {ElementType} as Object"); + protected ICollection AsCollection() => Value switch { List list => list, Dict dict => dict.Values, _ => throw new NotSupportedException($"Attempting to access {ElementType} as Array or Object"), }; + + public JsonNode? ToJson() + => Value switch { + Dict v => new JsonObject(AsDict().Select(e => new KeyValuePair(e.Key, e.Value.ToJson()))), + List v => new JsonArray (AsList().Select(e => e.ToJson()).ToArray()), + double v => JsonValue.Create(v), + string v => JsonValue.Create(v)!, + byte[] v => JsonValue.Create(v)!, + bool v => JsonValue.Create(v), + DateTime v => JsonValue.Create(v), + int v => JsonValue.Create(v), + long v => JsonValue.Create(v), + null => null, + _ => throw new NotImplementedException(), + }; + + public T[] ToArray() + { + if (typeof(T) == typeof(BsonValue)) return (T[])(object)AsList().ToArray(); + if (typeof(T) == typeof(byte) && ElementType == ElementType.Binary) return (T[])Value!; + if (typeof(T) == typeof(float)) return AsList().Select(v => (T)(object)(float)(double)v.Value!).ToArray(); + if (ElementTypeOf() == null) throw new NotSupportedException($"{typeof(T)} is not a valid ElementType"); + return AsList().Select(v => (T)v.Value!).ToArray(); + } + + public Dictionary ToDictionary() + { + if (typeof(T) == typeof(BsonValue)) return (Dictionary)(object)AsDict().ToDictionary(); + if (ElementTypeOf() == null) throw new NotSupportedException($"{typeof(T)} is not a valid ElementType"); + return AsDict().ToDictionary(e => e.Key, e => (T)e.Value.Value!); + } + + public BsonValue? GetOrNull(string key) => AsDict().GetValueOrDefault(key); + + public void Add(BsonValue value) => AsList().Add(value); + public void Add(string key, BsonValue value) => AsDict().Add(key, value); + + public bool Contains(BsonValue value) => AsList().Contains(value); + public bool ContainsKey(string key) => AsDict().ContainsKey(key); + + public void RemoveAt(int index) => AsList().RemoveAt(index); + public bool Remove(string key) => AsDict().Remove(key); + + public void Clear() + { + switch (Value) { + case List list: list.Clear(); break; + case Dict dict: dict.Clear(); break; + default: throw new NotSupportedException($"Attempting to access {ElementType} as Array or Object"); + }; + } + + public override bool Equals(object? obj) + => Equals(obj as BsonValue); + public bool Equals(BsonValue? other) + => (other is not null) + && (ElementType == other.ElementType) + && ElementType switch { + ElementType.Array => Enumerable.SequenceEqual(AsList(), other.AsList()), + ElementType.Object => DictionaryEquals(AsDict(), other.AsDict()), + _ => Equals(Value, other.Value), + }; + public override int GetHashCode() + => HashCode.Combine(ElementType, Value); + + public static bool operator ==(BsonValue? left, BsonValue? right) + => ReferenceEquals(left, right) || (left?.Equals(right) ?? false); + public static bool operator !=(BsonValue? left, BsonValue? right) + => !(left == right); + + public static implicit operator BsonValue(double value) => new(value); + public static implicit operator BsonValue(string value) => new(value); + public static implicit operator BsonValue(byte[] value) => new(value); + public static implicit operator BsonValue(bool value) => new(value); + public static implicit operator BsonValue(DateTime value) => new(value); + public static implicit operator BsonValue(int value) => new(value); + public static implicit operator BsonValue(long value) => new(value); + + public static explicit operator double (BsonValue value) => (double )value.Value!; + public static explicit operator string (BsonValue value) => (string )value.Value!; + public static explicit operator byte[] (BsonValue value) => (byte[] )value.Value!; + public static explicit operator bool (BsonValue value) => (bool )value.Value!; + public static explicit operator DateTime(BsonValue value) => (DateTime)value.Value!; + public static explicit operator int (BsonValue value) => (int )value.Value!; + public static explicit operator long (BsonValue value) => (long )value.Value!; + + public static explicit operator float(BsonValue value) => (float)(double)value; + + private static ElementType? ElementTypeOf() + { + if (typeof(T) == typeof(double )) return ElementType.Double; + if (typeof(T) == typeof(string )) return ElementType.String; + if (typeof(T) == typeof(Dict )) return ElementType.Object; + if (typeof(T) == typeof(List )) return ElementType.Array; + if (typeof(T) == typeof(byte[] )) return ElementType.Binary; + if (typeof(T) == typeof(bool )) return ElementType.Boolean; + if (typeof(T) == typeof(DateTime)) return ElementType.DateTime; + if (typeof(T) == typeof(int )) return ElementType.Int32; + if (typeof(T) == typeof(long )) return ElementType.Int64; + return null; + } + + private static bool DictionaryEquals(Dict first, Dict second) + => (first.Count == second.Count) && !first.Except(second).Any(); +} + +public class BsonReader : IDisposable +{ + private readonly BinaryReader _reader; + private readonly List _cStringBuf = []; + + public BsonReader(Stream stream, bool leaveOpen = false) + { + if (!stream.CanRead) throw new ArgumentException("stream is not readable"); + _reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen); + } + + public void Dispose() + { + _reader.Dispose(); + GC.SuppressFinalize(this); + } + + public static BsonValue Load(Stream stream, bool leaveOpen = false) + { + using var reader = new BsonReader(stream, leaveOpen); + return reader.ReadObject(); + } + public static BsonValue Load(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + return Load(stream); + } + + public BsonValue ReadObject() + { + // A "document" is prefixed with the number of bytes it takes up, but + // we can just ignore it and read elements until we detect a NUL byte. + // Thankfully we don't need to write a BsonWriter ... + _reader.ReadInt32(); // length, unused + + var result = BsonValue.Object(); + while (ReadElement() is (string name, BsonValue value)) + result.Add(name, value); + return result; + } + + public BsonValue ReadArray() + { + // An "array" is just an object with stringified integer keys ... + _reader.ReadInt32(); // length, unused + + var result = BsonValue.Array(); + while (ReadElement() is (string name, BsonValue value)) + { + if (!int.TryParse(name, out var index)) throw new Exception("Couldn't parse array index as integer"); + if (index != result.Count) throw new Exception("Non-sequential array index"); + result.Add(value); + } + return result; + } + + public BsonValue ReadBinary() + { + var length = _reader.ReadInt32(); + _reader.ReadByte(); // subtype, unused + return _reader.ReadBytes(length); + } + + public (string Name, BsonValue Value)? ReadElement() + { + var type = (ElementType)_reader.ReadByte(); + if (type == 0) return null; + + var name = ReadCString(); + return (name, type switch + { + ElementType.Double => _reader.ReadDouble(), + ElementType.String => ReadLengthPrefixedString(), + ElementType.Object => ReadObject(), + ElementType.Array => ReadArray(), + ElementType.Binary => ReadBinary(), + ElementType.Boolean => _reader.ReadByte() != 0, + ElementType.DateTime => DateTime.UnixEpoch + TimeSpan.FromMilliseconds(_reader.ReadInt64()), + ElementType.Null => BsonValue.Null, + ElementType.Int32 => _reader.ReadInt32(), + ElementType.Int64 => _reader.ReadInt64(), + + ElementType.Undefined | ElementType.ObjectId | ElementType.RegExp + | ElementType.DbPointer | ElementType.JsCode | ElementType.Symbol + | ElementType.JsCodeScope | ElementType.Timestamp | ElementType.Decimal128 + => throw new NotSupportedException($"Reading {type} is not supported"), + + _ => throw new Exception($"Unknown ElementType {type}"), + }); + } + + private string ReadLengthPrefixedString() + { + var length = _reader.ReadInt32(); + var bytes = _reader.ReadBytes(length); + if (bytes[^1] != 0) throw new Exception("Trailing byte in string must be NUL"); + return Encoding.UTF8.GetString(bytes.AsSpan()[0..^1]); + } + + private string ReadCString() + { + _cStringBuf.Clear(); + byte b; while ((b = _reader.ReadByte()) != 0) _cStringBuf.Add(b); + var bytes = CollectionsMarshal.AsSpan(_cStringBuf); + return Encoding.UTF8.GetString(bytes); + } +} diff --git a/DataTree.cs b/DataTree.cs new file mode 100644 index 0000000..eb37a10 --- /dev/null +++ b/DataTree.cs @@ -0,0 +1,82 @@ +using System.IO.Compression; +using System.Numerics; +using System.Text; + +namespace Res2tf; + +public class DataTree +{ + public BsonValue Bson { get; } + public string[] Types { get; } + public DataTreeNode Root { get; } + + // Technically unused, but kept in case it will be needed. + public Dictionary Nodes { get; } = []; + + public DataTree(string path) : this(LoadBson(path)) { } + public DataTree(Stream stream) : this(LoadBson(stream)) { } + public DataTree(BsonValue bson) + { + Bson = bson; + Types = bson["Types"].ToArray(); + Root = CreateNodeRecursive(null, bson["Object"]); + } + + public int? GetTypeId(string type) + => Array.IndexOf(Types, type) switch + { -1 => null, int i => i }; + + DataTreeNode CreateNodeRecursive(DataTreeNode? parent, BsonValue bson) + { + var node = new DataTreeNode(this, parent, bson); + foreach (var childBson in bson.GetOrNull("Children")?.AsList() ?? []) + node.Children.Add(CreateNodeRecursive(node, childBson)); + Nodes[node.ID] = node; + return node; + } + + static BsonValue LoadBson(string path) + => LoadBson(File.OpenRead(path)); + static BsonValue LoadBson(Stream stream) + { + // Read and verify file header. + using var reader = new BinaryReader(stream); + var magic = Encoding.ASCII.GetString(reader.ReadBytes(4)); + var version = reader.ReadInt32(); + var compression = reader.ReadByte(); + if (magic != "FrDT") throw new Exception("Invalid magic number"); + if (version != 0) throw new Exception("Unknown version"); + if (compression != 3) throw new Exception("Unsupported compression"); + + // Decompress and parse BSON. + using var brotli = new BrotliStream(stream, CompressionMode.Decompress); + return BsonReader.Load(brotli); + } +} + +public record DataTreeNode(DataTree Tree, DataTreeNode? Parent, BsonValue Bson) +{ + public List Children { get; } = []; + + public string ID => (string)Bson["ID"]; + public string Name => (string)Bson["Name"]["Data"]; + + public Vector3 Position { get { var p = Bson["Position"]["Data"].ToArray(); return new(p[0], p[1], p[2]); } } + public Quaternion Rotation { get { var r = Bson["Rotation"]["Data"].ToArray(); return new(r[0], r[1], r[2], r[3]); } } + public Vector3 Scale { get { var s = Bson["Scale"] ["Data"].ToArray(); return new(s[0], s[1], s[2]); } } + + public void Remove() { + Tree.Nodes.Remove(ID); + Parent?.Children.Remove(this); + } + + public IEnumerable EnumerateRecursive() + { + yield return this; + // Iterate children in reverse order so calling Remove on the current child is safe? + // for (var i = Children.Count - 1; i >= 0; i--) + foreach (var child in Children) + foreach (var elem in child.EnumerateRecursive()) + yield return elem; + } +} diff --git a/MeshAsset.cs b/MeshAsset.cs new file mode 100644 index 0000000..0d05117 --- /dev/null +++ b/MeshAsset.cs @@ -0,0 +1,96 @@ +using System.Numerics; +using Elements.Assets; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; + +namespace Res2tf; + +public class MeshAsset(MeshX meshX, ModelRoot model) +{ + public MeshX MeshX { get; } = meshX; + public Mesh Mesh { get; } = model.CreateMesh(Build(meshX)); + + static IMeshBuilder Build(MeshX meshX) + => (meshX.HasNormals, meshX.HasTangents) switch { + (false, false) => Build(meshX), + ( true, false) => Build(meshX), + ( true, true) => Build(meshX), + _ => throw new InvalidOperationException(), + }; + + static IMeshBuilder Build(MeshX meshX) + where G : struct, IVertexGeometry + => (meshX.HasColors, meshX.UV_ChannelCount) switch { + (false, 0) => Build(meshX), + (false, 1) => Build(meshX), + (false, 2) => Build(meshX), + (false, 3) => Build(meshX), + (false, 4) => Build(meshX), + ( true, 0) => Build(meshX), + ( true, 1) => Build(meshX), + ( true, 2) => Build(meshX), + ( true, 3) => Build(meshX), + ( true, 4) => Build(meshX), + _ => throw new InvalidOperationException(), + }; + + static IMeshBuilder Build(MeshX meshX) + where G : struct, IVertexGeometry + where M : struct, IVertexMaterial + => meshX.HasBoneBindings + ? Build(meshX) + : Build(meshX); + + static MeshBuilder Build(MeshX meshX) + where G : struct, IVertexGeometry + where M : struct, IVertexMaterial + where S : struct, IVertexSkinning + { + var mesh = new MeshBuilder(); + + foreach (var subMeshX in meshX.Submeshes) { + var prim = mesh.UsePrimitive(new()); // placeholder materials + + VertexBuilder BuildVertex(Vertex v) { + var vertex = new VertexBuilder { Position = v.Position }; + if (meshX.HasNormals ) vertex.Geometry.SetNormal(v.Normal); + if (meshX.HasTangents) vertex.Geometry.SetTangent(v.Tangent4); + if (meshX.HasColors ) vertex.Material.SetColor(0, new(v.Color.r, v.Color.g, v.Color.b, v.Color.a)); + for (var i = 0; i < meshX.UV_ChannelCount; i++) vertex.Material.SetTexCoord(i, v.GetUV(i)); + if (meshX.HasBoneBindings) { var b = v.BoneBinding; vertex.Skinning.SetBindings( + (b.boneIndex0, b.weight0), (b.boneIndex1, b.weight1), (b.boneIndex2, b.weight2), (b.boneIndex3, b.weight3)); } + return vertex; + } + + foreach (var t in subMeshX.RawIndicies.Chunk(3)) + prim.AddTriangle(BuildVertex(meshX.GetVertex(t[0])), + BuildVertex(meshX.GetVertex(t[1])), + BuildVertex(meshX.GetVertex(t[2]))); + } + + return mesh; + } + + public IEnumerable GetInverseBindMatrices() + => MeshX.Bones + .Select(b => b.BindPose) + .Select(ResoniteToNumerics) + .Select(NormalizeIdentityColumn); + + + static Matrix4x4 ResoniteToNumerics(Elements.Core.float4x4 m) + => new(m.m00, m.m10, m.m20, m.m30, + m.m01, m.m11, m.m21, m.m31, + m.m02, m.m12, m.m22, m.m32, + m.m03, m.m13, m.m23, m.m33); + + // glTF is very sensitive about the identity column being exactly (0, 0, 0, 1). + static Matrix4x4 NormalizeIdentityColumn(Matrix4x4 m) + => (IsApproxEqual(m.M14, 0) && IsApproxEqual(m.M24, 0) && IsApproxEqual(m.M34, 0) && IsApproxEqual(m.M44, 1)) + ? m with { M14 = 0, M24 = 0, M34 = 0, M44 = 1 } : m; + + static bool IsApproxEqual(float a, float b, float epsilon = 0.00001f) + => MathF.Abs(a - b) < epsilon; +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..610dc05 --- /dev/null +++ b/Program.cs @@ -0,0 +1,63 @@ +using Res2tf; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; +using SharpGLTF.Transforms; + + +using var package = new ResonitePackage("test/Test.resonitepackage"); +var nodeMap = new Dictionary(); +var model = ModelRoot.CreateModel(); + +// Initialize Data on the DataTree nodes to scene nodes. +foreach (var n in package.Tree.Root.EnumerateRecursive()) { + if (n.Parent == null) continue; // Tree root => scene root. + + var parent = nodeMap.TryGetValue(n.Parent, out var parentNode) + ? (IVisualNodeContainer)parentNode : model.UseScene(0); + + var node = parent.CreateNode(n.Name); + node.LocalTransform = new(n.Scale, n.Rotation, n.Position); + nodeMap[n] = node; +} + +package.ProcessAssets(model); + +var meshRenderer = package.Tree.GetTypeId("[FrooxEngine]FrooxEngine.MeshRenderer"); +var skinnedMeshRenderer = package.Tree.GetTypeId("[FrooxEngine]FrooxEngine.SkinnedMeshRenderer"); + +// Add meshes to the Scene hierarchy. +// (Skinned meshes need skeleton bones for this to work.) +foreach (var node in package.Tree.Root.EnumerateRecursive()) + foreach (var component in node.Bson["Components"]["Data"].AsList()) { + var type = (int)component["Type"]; + if (type == meshRenderer) { + var assetId = (string)component["Data"]["Mesh"]["Data"]; + if (package.TryGetAsset(assetId, out var asset)) { + var materials = component["Data"]["Materials"]["Data"].AsList() + .Select(material => (MaterialBuilder)package.Assets[(string)material["Data"]]); + foreach (var (i, material) in materials.Select((m, i) => (i, m))) + asset.Mesh.Primitives[i].Material = model.CreateMaterial(material); + + nodeMap[node].WithMesh(asset.Mesh); + } + } else if (type == skinnedMeshRenderer) { + var assetId = (string)component["Data"]["Mesh"]["Data"]; + if (package.TryGetAsset(assetId, out var asset)) { + var materials = component["Data"]["Materials"]["Data"].AsList() + .Select(material => (MaterialBuilder)package.Assets[(string)material["Data"]]); + foreach (var (i, material) in materials.Select((m, i) => (i, m))) + asset.Mesh.Primitives[i].Material = model.CreateMaterial(material); + + var bones = component["Data"]["Bones"]["Data"].AsList() + .Select(bone => nodeMap[package.Tree.Nodes[(string)bone["Data"]]]); + var inverseBoneMatrices = asset.GetInverseBindMatrices(); + var joints = bones.Zip(inverseBoneMatrices).ToArray(); + + // Skeleton node must not have transform. + nodeMap[node].LocalTransform = AffineTransform.Identity; + nodeMap[node].WithSkinnedMesh(asset.Mesh, joints); + } + } + } + +model.SaveGLB("test/scene.glb"); diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee172c4 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +This repository contains some code that was supposed to help me fix my avatar +in [Resonite], which turned out to not be appropriate for public instances. + +![output viewed in Blender](docs/blender.png) + +Unfortunately I have yet to actually make the round-trip back into Resonite +work properly. Unsure if I'm mapping the bones correctly. + +### How it works + +Because Resonite does not (yet) allow "skinned" meshes (with bones and weights +and all that stuff) to be exported in a way that keeps the skinning information, +we have to go about it in a more roundabout way: + +- Export the model as a Resonite Package, place in `test/Test.resonitepackage` +- Open the package, which is actually just disguised `.zip` archive +- Parse the `R-Main.record` entry, a JSON file +- The `assetUri` field points to the main asset file in the archive +- Parse this file using [BSON], a horrible "Binary JSON" format +- The resulting `DataTree` contains the hierarchical node and component info +- Types are encoded as IDs, which we can decode using a lookup array +- Process supported asset types: Meshes, textures, and materials +- These assets can then be referenced by their ID by other nodes +- Go through all node and attach meshes to them if they have some +- Export the whole thing as a `.glb` with [SharpGLTF] + +**NOTE:** It might be possible to parse a good chunk of this with Resonite's +own library, but it probably requires setting up an `Engine` or so for it to +parse all the related files, I don't have the know-how to do that. + +[Resonite]: https://resonite.com/ +[SharpGLTF]: https://github.com/vpenades/SharpGLTF +[BSON]: https://bsonspec.org/ \ No newline at end of file diff --git a/Res2tf.csproj b/Res2tf.csproj new file mode 100644 index 0000000..a7a4359 --- /dev/null +++ b/Res2tf.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/ResonitePackage.cs b/ResonitePackage.cs new file mode 100644 index 0000000..5759357 --- /dev/null +++ b/ResonitePackage.cs @@ -0,0 +1,137 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Numerics; +using System.Text.Json; +using Elements.Assets; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; + +namespace Res2tf; + +public class ResonitePackage : IDisposable +{ + readonly ZipArchive _zip; + + public DataTree Tree { get; } + public BsonValue Bson => Tree.Bson; + public Dictionary Assets { get; } = []; + + public ResonitePackage(string path) + { + _zip = new ZipArchive(File.OpenRead(path)); + + using var mainRecord = _zip.GetEntry("R-Main.record")!.Open(); + var mainDocument = JsonDocument.Parse(mainRecord); + var mainAssetUri = mainDocument.RootElement.GetProperty("assetUri").GetString()!; + + Tree = new(ResolveToStream(mainAssetUri)); + } + + public void Dispose() + { + _zip.Dispose(); + GC.SuppressFinalize(this); + } + + public byte[] ResolveToBytes(string uriString) + { + using var stream = ResolveToStream(uriString, true); + return ((MemoryStream)stream).ToArray(); + } + public Stream ResolveToStream(string uriString, bool copy = false) + { + // URIs coming from the DataTree are gonna start with an '@' symbol, so just strip it. + if (uriString.StartsWith('@')) uriString = uriString[1..]; + + var uri = new Uri(uriString); + if (uri.Scheme != "packdb") throw new ArgumentException("Not a packdb URI"); + if (!uri.AbsolutePath.StartsWith('/')) throw new ArgumentException("Not valid packdb URI"); + + var stream = _zip.GetEntry($"Assets{uri.AbsolutePath}")!.Open(); + if (!copy) return stream; + else using (stream) { + var bytes = new MemoryStream(); + stream.CopyTo(bytes); + bytes.Seek(0, SeekOrigin.Begin); + return bytes; + } + } + + public void ProcessAssets(ModelRoot model) + { + // Process static assets first. + var staticMesh = Tree.GetTypeId("[FrooxEngine]FrooxEngine.StaticMesh"); + var staticTexture = Tree.GetTypeId("[FrooxEngine]FrooxEngine.StaticTexture2D"); + foreach (var asset in Bson["Assets"].AsList()) { + var type = (int)asset["Type"]; + + if (type == staticMesh) { + var id = (string)asset["Data"]["ID"]; + var uri = (string)asset["Data"]["URL"]["Data"]; + var mesh = new MeshAsset(new MeshX(ResolveToStream(uri, true)), model); + Assets[id] = mesh; + } + + else if (type == staticTexture) { + var id = (string)asset["Data"]["ID"]; + var uri = (string)asset["Data"]["URL"]["Data"]; + // For now, texture "asset" is just its URI, material will load it from there. + Assets[id] = uri; + } + } + + var toonMaterial = Tree.GetTypeId("[FrooxEngine]FrooxEngine.XiexeToonMaterial"); + var unlitMaterial = Tree.GetTypeId("[FrooxEngine]FrooxEngine.UnlitMaterial"); + foreach (var asset in Bson["Assets"].AsList()) { + var type = (int)asset["Type"]; + + if (type == toonMaterial) { + var id = (string)asset["Data"]["ID"]; + + var color = ResoniteToColor(asset["Data"]["Color"]); + + var textureId = (string?)asset["Data"]["MainTexture"]["Data"]; + var texturePath = (textureId != null) ? (string)Assets[textureId] : null; + var texture = (texturePath != null) ? ResolveToBytes(texturePath) : null; + + var material = new MaterialBuilder() + .WithDoubleSide(false) + .WithBaseColor(color) + .WithChannelImage(KnownChannel.BaseColor, texture); + Assets[id] = material; + } + + else if (type == unlitMaterial) { + var id = (string)asset["Data"]["ID"]; + + var color = ResoniteToColor(asset["Data"]["TintColor"]); + + var textureId = (string)asset["Data"]["Texture"]["Data"]; + var texturePath = (textureId != null) ? (string)Assets[textureId] : null; + var texture = (texturePath != null) ? ResolveToBytes(texturePath) : null; + + var material = new MaterialBuilder() + .WithDoubleSide(false) + .WithBaseColor(color) + .WithChannelImage(KnownChannel.BaseColor, texture); + Assets[id] = material; + } + } + } + + public bool TryGetAsset(string assetId, [NotNullWhen(true)] out TAsset? asset) + { + if (Assets.TryGetValue(assetId, out var assetUntyped) && (assetUntyped is TAsset a)) + { asset = a; return true; } else { asset = default; return false; } + } + + static Vector4 ResoniteToColor(BsonValue bson) + { + var list = bson["Data"].AsList(); + var r = Math.Clamp((float)list[0], 0.0f, 1.0f); + var g = Math.Clamp((float)list[1], 0.0f, 1.0f); + var b = Math.Clamp((float)list[2], 0.0f, 1.0f); + var a = Math.Clamp((float)list[3], 0.0f, 1.0f); + return new(r, g, b, a); + } +} diff --git a/UNLICENSE.txt b/UNLICENSE.txt new file mode 100644 index 0000000..efb9808 --- /dev/null +++ b/UNLICENSE.txt @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +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 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. + +For more information, please refer to diff --git a/docs/blender.png b/docs/blender.png new file mode 100644 index 0000000..21af235 Binary files /dev/null and b/docs/blender.png differ