Browse Source

Merge pull request #16 from vpenades/SceneBuilder

Scene builder
Vicente Penades 6 years ago
parent
commit
ee5c9f45b9
56 changed files with 4397 additions and 705 deletions
  1. 2 2
      build/SharpGLTF.CodeGen/SharpGLTF.CodeGen.csproj
  2. 15 0
      src/Shared/_Extensions.cs
  3. 170 0
      src/SharpGLTF.Core/Animations/CubicSamplers.cs
  4. 34 0
      src/SharpGLTF.Core/Animations/Interfaces.cs
  5. 173 0
      src/SharpGLTF.Core/Animations/LinearSamplers.cs
  6. 316 0
      src/SharpGLTF.Core/Animations/SamplerFactory.cs
  7. 18 17
      src/SharpGLTF.Core/Schema2/gltf.Animations.cs
  8. 1 1
      src/SharpGLTF.Core/Schema2/gltf.Node.cs
  9. 1 1
      src/SharpGLTF.Core/SharpGLTF.Core.csproj
  10. 19 7
      src/SharpGLTF.Core/Transforms/AffineTransform.cs
  11. 0 192
      src/SharpGLTF.Core/Transforms/AnimationSamplerFactory.cs
  12. 98 0
      src/SharpGLTF.Toolkit/Animations/AnimatableProperty.cs
  13. 316 0
      src/SharpGLTF.Toolkit/Animations/CurveBuilder.cs
  14. 168 0
      src/SharpGLTF.Toolkit/Animations/CurveFactory.cs
  15. 48 0
      src/SharpGLTF.Toolkit/Collections/EmptyDictionary.cs
  16. 108 1
      src/SharpGLTF.Toolkit/Debug/DebugViews.cs
  17. 1 7
      src/SharpGLTF.Toolkit/Geometry/PackedMeshBuilder.cs
  18. 90 47
      src/SharpGLTF.Toolkit/Geometry/PrimitiveBuilder.cs
  19. 40 1
      src/SharpGLTF.Toolkit/Geometry/VertexBuilder.cs
  20. 1 1
      src/SharpGLTF.Toolkit/Geometry/VertexTypes/FragmentPreprocessors.cs
  21. 8 0
      src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexEmpty.cs
  22. 5 0
      src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexGeometry.cs
  23. 5 0
      src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexMaterial.cs
  24. 49 0
      src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexSkinning.cs
  25. 39 73
      src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexUtils.cs
  26. 58 17
      src/SharpGLTF.Toolkit/Materials/ChannelBuilder.cs
  27. 85 10
      src/SharpGLTF.Toolkit/Materials/MaterialBuilder.cs
  28. 29 0
      src/SharpGLTF.Toolkit/Materials/MaterialEnums.cs
  29. 110 11
      src/SharpGLTF.Toolkit/Materials/TextureBuilder.cs
  30. 26 12
      src/SharpGLTF.Toolkit/README.md
  31. 219 0
      src/SharpGLTF.Toolkit/Scenes/Content.cs
  32. 55 0
      src/SharpGLTF.Toolkit/Scenes/InstanceBuilder.cs
  33. 232 0
      src/SharpGLTF.Toolkit/Scenes/NodeBuilder.cs
  34. 123 0
      src/SharpGLTF.Toolkit/Scenes/SceneBuilder.Schema2.cs
  35. 52 0
      src/SharpGLTF.Toolkit/Scenes/SceneBuilder.cs
  36. 46 0
      src/SharpGLTF.Toolkit/Scenes/readme.md
  37. 54 3
      src/SharpGLTF.Toolkit/Schema2/AnimationExtensions.cs
  38. 25 3
      src/SharpGLTF.Toolkit/Schema2/MaterialExtensions.cs
  39. 58 68
      src/SharpGLTF.Toolkit/Schema2/MeshExtensions.cs
  40. 1 1
      src/SharpGLTF.Toolkit/SharpGLTF.Toolkit.csproj
  41. 146 15
      tests/SharpGLTF.Tests/AnimationSamplingTests.cs
  42. 166 0
      tests/SharpGLTF.Tests/Animations/CurveBuilderTests.cs
  43. 26 2
      tests/SharpGLTF.Tests/Geometry/MeshBuilderTests.cs
  44. 271 0
      tests/SharpGLTF.Tests/Geometry/Parametric/SolidMeshUtils.cs
  45. 1 1
      tests/SharpGLTF.Tests/Geometry/VertexTypes/JointWeightPairTests.cs
  46. 7 5
      tests/SharpGLTF.Tests/Geometry/VertexTypes/VertexSkinningTests.cs
  47. 4 4
      tests/SharpGLTF.Tests/Memory/MemoryArrayTests.cs
  48. 272 0
      tests/SharpGLTF.Tests/NumericsAssert.cs
  49. 303 0
      tests/SharpGLTF.Tests/Plotting.cs
  50. 203 0
      tests/SharpGLTF.Tests/Scenes/SceneBuilderTests.cs
  51. 2 0
      tests/SharpGLTF.Tests/Schema2/Authoring/BasicSceneCreationTests.cs
  52. 43 25
      tests/SharpGLTF.Tests/Schema2/Authoring/MeshBuilderCreationTests.cs
  53. 0 171
      tests/SharpGLTF.Tests/Schema2/Authoring/SolidMeshUtils.cs
  54. 2 1
      tests/SharpGLTF.Tests/SharpGLTF.Tests.csproj
  55. 12 0
      tests/SharpGLTF.Tests/ToolkitUtils.cs
  56. 41 6
      tests/SharpGLTF.Tests/Utils.cs

+ 2 - 2
build/SharpGLTF.CodeGen/SharpGLTF.CodeGen.csproj

@@ -8,8 +8,8 @@
 
   <ItemGroup>
     <PackageReference Include="LibGit2Sharp" Version="0.26.0" />
-    <PackageReference Include="NJsonSchema.CodeGeneration" Version="10.0.14" />
-    <PackageReference Include="NJsonSchema.CodeGeneration.CSharp" Version="10.0.14" />
+    <PackageReference Include="NJsonSchema.CodeGeneration" Version="10.0.21" />
+    <PackageReference Include="NJsonSchema.CodeGeneration.CSharp" Version="10.0.21" />
   </ItemGroup>
 
 </Project>

+ 15 - 0
src/Shared/_Extensions.cs

@@ -164,6 +164,21 @@ namespace SharpGLTF
 
         #region linq
 
+        internal static int GetContentHashCode<T>(this IEnumerable<T> collection, int count = int.MaxValue)
+        {
+            if (collection == null) return 0;
+
+            int h = 0;
+
+            foreach (var element in collection.Take(count))
+            {
+                h ^= element == null ? 0 : element.GetHashCode();
+                h *= 17;
+            }
+
+            return h;
+        }
+
         internal static ArraySegment<T> Slice<T>(this T[] array, int offset)
         {
             return new ArraySegment<T>(array, offset, array.Length - offset);

+ 170 - 0
src/SharpGLTF.Core/Animations/CubicSamplers.cs

@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF.Animations
+{
+    /// <summary>
+    /// Defines a <see cref="Vector3"/> curve sampler that can be sampled with CUBIC interpolation.
+    /// </summary>
+    struct Vector3CubicSampler : ICurveSampler<Vector3>, IConvertibleCurve<Vector3>
+    {
+        #region lifecycle
+
+        public Vector3CubicSampler(IEnumerable<(float, (Vector3, Vector3, Vector3))> sequence)
+        {
+            _Sequence = sequence;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly IEnumerable<(float, (Vector3, Vector3, Vector3))> _Sequence;
+
+        #endregion
+
+        #region API
+
+        public int MaxDegree => 3;
+
+        public Vector3 GetPoint(float offset)
+        {
+            var segment = SamplerFactory.FindPairContainingOffset(_Sequence, offset);
+
+            return SamplerFactory.CubicLerp
+                (
+                segment.Item1.Item2, segment.Item1.Item3,   // start, startTangentOut
+                segment.Item2.Item2, segment.Item2.Item1,   // end, endTangentIn
+                segment.Item3                               // amount
+                );
+        }
+
+        public IReadOnlyDictionary<float, Vector3> ToStepCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        public IReadOnlyDictionary<float, Vector3> ToLinearCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        public IReadOnlyDictionary<float, (Vector3, Vector3, Vector3)> ToSplineCurve()
+        {
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        #endregion
+    }
+
+    /// <summary>
+    /// Defines a <see cref="Quaternion"/> curve sampler that can be sampled with CUBIC interpolation.
+    /// </summary>
+    struct QuaternionCubicSampler : ICurveSampler<Quaternion>, IConvertibleCurve<Quaternion>
+    {
+        #region lifecycle
+
+        public QuaternionCubicSampler(IEnumerable<(float, (Quaternion, Quaternion, Quaternion))> sequence)
+        {
+            _Sequence = sequence;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly IEnumerable<(float, (Quaternion, Quaternion, Quaternion))> _Sequence;
+
+        #endregion
+
+        #region API
+
+        public int MaxDegree => 3;
+
+        public Quaternion GetPoint(float offset)
+        {
+            var segment = SamplerFactory.FindPairContainingOffset(_Sequence, offset);
+
+            return SamplerFactory.CubicLerp
+                (
+                segment.Item1.Item2, segment.Item1.Item3,   // start, startTangentOut
+                segment.Item2.Item2, segment.Item2.Item1,   // end, endTangentIn
+                segment.Item3                               // amount
+                );
+        }
+
+        public IReadOnlyDictionary<float, Quaternion> ToStepCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        public IReadOnlyDictionary<float, Quaternion> ToLinearCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        public IReadOnlyDictionary<float, (Quaternion, Quaternion, Quaternion)> ToSplineCurve()
+        {
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        #endregion
+    }
+
+    /// <summary>
+    /// Defines a <see cref="float"/>[] curve sampler that can be sampled with CUBIC interpolation.
+    /// </summary>
+    struct ArrayCubicSampler : ICurveSampler<float[]>, IConvertibleCurve<float[]>
+    {
+        #region lifecycle
+
+        public ArrayCubicSampler(IEnumerable<(float, (float[], float[], float[]))> sequence)
+        {
+            _Sequence = sequence;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly IEnumerable<(float, (float[], float[], float[]))> _Sequence;
+
+        #endregion
+
+        #region API
+
+        public int MaxDegree => 3;
+
+        public float[] GetPoint(float offset)
+        {
+            var segment = SamplerFactory.FindPairContainingOffset(_Sequence, offset);
+
+            return SamplerFactory.CubicLerp
+                (
+                segment.Item1.Item2, segment.Item1.Item3,   // start, startTangentOut
+                segment.Item2.Item2, segment.Item2.Item1,   // end, endTangentIn
+                segment.Item3                               // amount
+                );
+        }
+
+        public IReadOnlyDictionary<float, float[]> ToStepCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        public IReadOnlyDictionary<float, float[]> ToLinearCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        public IReadOnlyDictionary<float, (float[], float[], float[])> ToSplineCurve()
+        {
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        #endregion
+    }
+}

+ 34 - 0
src/SharpGLTF.Core/Animations/Interfaces.cs

@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace SharpGLTF.Animations
+{
+    /// <summary>
+    /// Defines a curve that can be sampled at specific points.
+    /// </summary>
+    /// <typeparam name="T">The type of a point in the curve.</typeparam>
+    public interface ICurveSampler<T>
+    {
+        T GetPoint(float offset);
+    }
+
+    /// <summary>
+    /// Defines methods that convert the current curve to a Step, Linear or Spline curve.
+    /// </summary>
+    /// <typeparam name="T">The type of a point of the curve</typeparam>
+    public interface IConvertibleCurve<T>
+    {
+        /// <summary>
+        /// Gets a value indicating the maximum degree of the curve, current values are:
+        /// 0: STEP.
+        /// 1: LINEAR.
+        /// 3: CUBIC.
+        /// </summary>
+        int MaxDegree { get; }
+
+        IReadOnlyDictionary<float, T> ToStepCurve();
+        IReadOnlyDictionary<float, T> ToLinearCurve();
+        IReadOnlyDictionary<float, (T, T, T)> ToSplineCurve();
+    }
+}

+ 173 - 0
src/SharpGLTF.Core/Animations/LinearSamplers.cs

@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF.Animations
+{
+    /// <summary>
+    /// Defines a <see cref="Vector3"/> curve sampler that can be sampled with STEP or LINEAR interpolations.
+    /// </summary>
+    struct Vector3LinearSampler : ICurveSampler<Vector3>, IConvertibleCurve<Vector3>
+    {
+        #region lifecycle
+
+        public Vector3LinearSampler(IEnumerable<(float, Vector3)> sequence, bool isLinear)
+        {
+            _Sequence = sequence;
+            _Linear = isLinear;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly IEnumerable<(float, Vector3)> _Sequence;
+        private readonly Boolean _Linear;
+
+        #endregion
+
+        #region API
+
+        public int MaxDegree => _Linear ? 1 : 0;
+
+        public Vector3 GetPoint(float offset)
+        {
+            var segment = SamplerFactory.FindPairContainingOffset(_Sequence, offset);
+
+            if (!_Linear) return segment.Item1;
+
+            return Vector3.Lerp(segment.Item1, segment.Item2, segment.Item3);
+        }
+
+        public IReadOnlyDictionary<float, Vector3> ToStepCurve()
+        {
+            Guard.IsFalse(_Linear, nameof(_Linear));
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        public IReadOnlyDictionary<float, Vector3> ToLinearCurve()
+        {
+            Guard.IsTrue(_Linear, nameof(_Linear));
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        public IReadOnlyDictionary<float, (Vector3, Vector3, Vector3)> ToSplineCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        #endregion
+    }
+
+    /// <summary>
+    /// Defines a <see cref="Quaternion"/> curve sampler that can be sampled with STEP or LINEAR interpolations.
+    /// </summary>
+    struct QuaternionLinearSampler : ICurveSampler<Quaternion>, IConvertibleCurve<Quaternion>
+    {
+        #region lifecycle
+
+        public QuaternionLinearSampler(IEnumerable<(float, Quaternion)> sequence, bool isLinear)
+        {
+            _Sequence = sequence;
+            _Linear = isLinear;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly IEnumerable<(float, Quaternion)> _Sequence;
+        private readonly Boolean _Linear;
+
+        #endregion
+
+        #region API
+
+        public int MaxDegree => _Linear ? 1 : 0;
+
+        public Quaternion GetPoint(float offset)
+        {
+            var segment = SamplerFactory.FindPairContainingOffset(_Sequence, offset);
+
+            if (!_Linear) return segment.Item1;
+
+            return Quaternion.Slerp(segment.Item1, segment.Item2, segment.Item3);
+        }
+
+        public IReadOnlyDictionary<float, Quaternion> ToStepCurve()
+        {
+            Guard.IsFalse(_Linear, nameof(_Linear));
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        public IReadOnlyDictionary<float, Quaternion> ToLinearCurve()
+        {
+            Guard.IsTrue(_Linear, nameof(_Linear));
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        public IReadOnlyDictionary<float, (Quaternion, Quaternion, Quaternion)> ToSplineCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        #endregion
+    }
+
+    /// <summary>
+    /// Defines a <see cref="float"/>[] curve sampler that can be sampled with STEP or LINEAR interpolations.
+    /// </summary>
+    struct ArrayLinearSampler : ICurveSampler<float[]>, IConvertibleCurve<float[]>
+    {
+        #region lifecycle
+
+        public ArrayLinearSampler(IEnumerable<(float, float[])> sequence, bool isLinear)
+        {
+            _Sequence = sequence;
+            _Linear = isLinear;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly IEnumerable<(float, float[])> _Sequence;
+        private readonly Boolean _Linear;
+
+        #endregion
+
+        #region API
+
+        public int MaxDegree => _Linear ? 1 : 0;
+
+        public float[] GetPoint(float offset)
+        {
+            var segment = SamplerFactory.FindPairContainingOffset(_Sequence, offset);
+
+            if (!_Linear) return segment.Item1;
+
+            return SamplerFactory.Lerp(segment.Item1, segment.Item2, segment.Item3);
+        }
+
+        public IReadOnlyDictionary<float, float[]> ToStepCurve()
+        {
+            Guard.IsFalse(_Linear, nameof(_Linear));
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        public IReadOnlyDictionary<float, float[]> ToLinearCurve()
+        {
+            Guard.IsTrue(_Linear, nameof(_Linear));
+            return _Sequence.ToDictionary(pair => pair.Item1, pair => pair.Item2);
+        }
+
+        public IReadOnlyDictionary<float, (float[], float[], float[])> ToSplineCurve()
+        {
+            throw new NotImplementedException();
+        }
+
+        #endregion
+    }
+}

+ 316 - 0
src/SharpGLTF.Core/Animations/SamplerFactory.cs

@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+
+namespace SharpGLTF.Animations
+{
+    /// <summary>
+    /// Utility class to create samplers from curve collections.
+    /// </summary>
+    public static class SamplerFactory
+    {
+        #region sampler utils
+
+        public static Vector3 CreateTangent(Vector3 fromValue, Vector3 toValue, Single scale = 1)
+        {
+            return (toValue - fromValue) * scale;
+        }
+
+        public static Quaternion CreateTangent(Quaternion fromValue, Quaternion toValue, Single scale = 1)
+        {
+            var tangent = Quaternion.Concatenate(toValue, Quaternion.Inverse(fromValue));
+
+            if (scale == 1) return tangent;
+
+            // decompose into Axis - Angle pair
+            var axis = Vector3.Normalize(new Vector3(tangent.X, tangent.Y, tangent.Z));
+            var angle = Math.Acos(tangent.W) * 2;
+
+            return Quaternion.CreateFromAxisAngle(axis, scale * (float)angle);
+        }
+
+        public static Single[] CreateTangent(Single[] fromValue, Single[] toValue, Single scale = 1)
+        {
+            var r = new float[fromValue.Length];
+
+            for (int i = 0; i < r.Length; ++i)
+            {
+                r[i] = (toValue[i] - fromValue[i]) * scale;
+            }
+
+            return r;
+        }
+
+        /// <summary>
+        /// Calculates the Hermite point weights for a given <paramref name="amount"/>
+        /// </summary>
+        /// <param name="amount">The input amount (must be between 0 and 1)</param>
+        /// <returns>
+        /// The output weights.
+        /// - Item1: Weight for Start point
+        /// - Item2: Weight for End point
+        /// - Item3: Weight for Start Outgoing Tangent
+        /// - Item4: Weight for End Incoming Tangent
+        /// </returns>
+        public static (float, float, float, float) CreateHermitePointWeights(float amount)
+        {
+            System.Diagnostics.Debug.Assert(amount >= 0 && amount <= 1, nameof(amount));
+
+            // http://mathworld.wolfram.com/HermitePolynomial.html
+
+            // https://www.cubic.org/docs/hermite.htm
+
+            var squared = amount * amount;
+            var cubed = amount * squared;
+
+            /*
+            var part1 = (2.0f * cubed) - (3.0f * squared) + 1.0f;
+            var part2 = (-2.0f * cubed) + (3.0f * squared);
+            var part3 = cubed - (2.0f * squared) + amount;
+            var part4 = cubed - squared;
+            */
+
+            var part2 = (3.0f * squared) - (2.0f * cubed);
+            var part1 = 1 - part2;
+            var part4 = cubed - squared;
+            var part3 = part4 - squared + amount;
+
+            return (part1, part2, part3, part4);
+        }
+
+        /// <summary>
+        /// Calculates the Hermite tangent weights for a given <paramref name="amount"/>
+        /// </summary>
+        /// <param name="amount">The input amount (must be between 0 and 1)</param>
+        /// <returns>
+        /// The output weights.
+        /// - Item1: Weight for Start point
+        /// - Item2: Weight for End point
+        /// - Item3: Weight for Start Outgoing Tangent
+        /// - Item4: Weight for End Incoming Tangent
+        /// </returns>
+        public static (float, float, float, float) CreateHermiteTangentWeights(float amount)
+        {
+            System.Diagnostics.Debug.Assert(amount >= 0 && amount <= 1, nameof(amount));
+
+            // https://math.stackexchange.com/questions/1270776/how-to-find-tangent-at-any-point-along-a-cubic-hermite-spline
+
+            var squared = amount * amount;
+
+            /*
+            var part1 = (6 * squared) - (6 * amount);
+            var part2 = -(6 * squared) + (6 * amount);
+            var part3 = (3 * squared) - (4 * amount) + 1;
+            var part4 = (3 * squared) - (2 * amount);
+            */
+
+            var part1 = (6 * squared) - (6 * amount);
+            var part2 = -part1;
+            var part3 = (3 * squared) - (4 * amount) + 1;
+            var part4 = (3 * squared) - (2 * amount);
+
+            return (part1, part2, part3, part4);
+        }
+
+        /// <summary>
+        /// Given a <paramref name="sequence"/> of float+<typeparamref name="T"/> pairs and an <paramref name="offset"/>,
+        /// it finds two consecutive values that contain <paramref name="offset"/> between them.
+        /// </summary>
+        /// <typeparam name="T">The value type</typeparam>
+        /// <param name="sequence">A sequence of float+<typeparamref name="T"/> pairs sorted in ascending order.</param>
+        /// <param name="offset">the offset to look for in the sequence.</param>
+        /// <returns>Two consecutive <typeparamref name="T"/> values and a float amount to LERP amount.</returns>
+        public static (T, T, float) FindPairContainingOffset<T>(this IEnumerable<(float, T)> sequence, float offset)
+        {
+            if (!sequence.Any()) return (default(T), default(T), 0);
+
+            (float, T)? left = null;
+            (float, T)? right = null;
+            (float, T)? prev = null;
+
+            var first = sequence.First();
+            if (offset < first.Item1) offset = first.Item1;
+
+            foreach (var item in sequence)
+            {
+                System.Diagnostics.Debug.Assert(!prev.HasValue || prev.Value.Item1 < item.Item1, "Values in the sequence must be sorted ascending.");
+
+                if (item.Item1 == offset)
+                {
+                    left = item; continue;
+                }
+
+                if (item.Item1 > offset)
+                {
+                    if (left == null) left = prev;
+                    right = item;
+                    break;
+                }
+
+                prev = item;
+            }
+
+            if (left == null && right == null) return (default(T), default(T), 0);
+            if (left == null) return (right.Value.Item2, right.Value.Item2, 0);
+            if (right == null) return (left.Value.Item2, left.Value.Item2, 0);
+
+            var delta = right.Value.Item1 - left.Value.Item1;
+
+            System.Diagnostics.Debug.Assert(delta > 0);
+
+            var amount = (offset - left.Value.Item1) / delta;
+
+            System.Diagnostics.Debug.Assert(amount >= 0 && amount <= 1);
+
+            return (left.Value.Item2, right.Value.Item2, amount);
+        }
+
+        /// <summary>
+        /// Given a <paramref name="sequence"/> of offsets and an <paramref name="offset"/>,
+        /// it finds two consecutive offsets that contain <paramref name="offset"/> between them.
+        /// </summary>
+        /// <param name="sequence">A sequence of offsets sorted in ascending order.</param>
+        /// <param name="offset">the offset to look for in the sequence.</param>
+        /// <returns>Two consecutive offsets and a LERP amount.</returns>
+        public static (float, float, float) FindPairContainingOffset(IEnumerable<float> sequence, float offset)
+        {
+            if (!sequence.Any()) return (0, 0, 0);
+
+            float? left = null;
+            float? right = null;
+            float? prev = null;
+
+            var first = sequence.First();
+            if (offset < first) offset = first;
+
+            foreach (var item in sequence)
+            {
+                System.Diagnostics.Debug.Assert(!prev.HasValue || prev.Value < item, "Values in the sequence must be sorted ascending.");
+
+                if (item == offset)
+                {
+                    left = item; continue;
+                }
+
+                if (item > offset)
+                {
+                    if (left == null) left = prev;
+                    right = item;
+                    break;
+                }
+
+                prev = item;
+            }
+
+            if (left == null && right == null) return (0, 0, 0);
+            if (left == null) return (right.Value, right.Value, 0);
+            if (right == null) return (left.Value, left.Value, 0);
+
+            var delta = right.Value - left.Value;
+
+            System.Diagnostics.Debug.Assert(delta > 0);
+
+            var amount = (offset - left.Value) / delta;
+
+            System.Diagnostics.Debug.Assert(amount >= 0 && amount <= 1);
+
+            return (left.Value, right.Value, amount);
+        }
+
+        #endregion
+
+        #region interpolation utils
+
+        public static Single[] Lerp(Single[] start, Single[] end, Single amount)
+        {
+            var startW = 1 - amount;
+            var endW = amount;
+
+            var result = new float[start.Length];
+
+            for (int i = 0; i < result.Length; ++i)
+            {
+                result[i] = (start[i] * startW) + (end[i] * endW);
+            }
+
+            return result;
+        }
+
+        public static Vector3 CubicLerp(Vector3 start, Vector3 outgoingTangent, Vector3 end, Vector3 incomingTangent, Single amount)
+        {
+            var hermite = SamplerFactory.CreateHermitePointWeights(amount);
+
+            return (start * hermite.Item1) + (end * hermite.Item2) + (outgoingTangent * hermite.Item3) + (incomingTangent * hermite.Item4);
+        }
+
+        public static Quaternion CubicLerp(Quaternion start, Quaternion outgoingTangent, Quaternion end, Quaternion incomingTangent, Single amount)
+        {
+            var hermite = CreateHermitePointWeights(amount);
+
+            return Quaternion.Normalize((start * hermite.Item1) + (end * hermite.Item2) + (outgoingTangent * hermite.Item3) + (incomingTangent * hermite.Item4));
+        }
+
+        public static Single[] CubicLerp(Single[] start, Single[] outgoingTangent, Single[] end, Single[] incomingTangent, Single amount)
+        {
+            var hermite = CreateHermitePointWeights(amount);
+
+            var result = new float[start.Length];
+
+            for (int i = 0; i < result.Length; ++i)
+            {
+                result[i] = (start[i] * hermite.Item1) + (end[i] * hermite.Item2) + (outgoingTangent[i] * hermite.Item3) + (incomingTangent[i] * hermite.Item4);
+            }
+
+            return result;
+        }
+
+        #endregion
+
+        #region sampler creation
+
+        public static ICurveSampler<Vector3> CreateSampler(this IEnumerable<(Single, Vector3)> collection, bool isLinear = true)
+        {
+            if (collection == null) return null;
+
+            return new Vector3LinearSampler(collection, isLinear);
+        }
+
+        public static ICurveSampler<Quaternion> CreateSampler(this IEnumerable<(Single, Quaternion)> collection, bool isLinear = true)
+        {
+            if (collection == null) return null;
+
+            return new QuaternionLinearSampler(collection, isLinear);
+        }
+
+        public static ICurveSampler<Single[]> CreateSampler(this IEnumerable<(Single, Single[])> collection, bool isLinear = true)
+        {
+            if (collection == null) return null;
+
+            return new ArrayLinearSampler(collection, isLinear);
+        }
+
+        public static ICurveSampler<Vector3> CreateSampler(this IEnumerable<(Single, (Vector3, Vector3, Vector3))> collection)
+        {
+            if (collection == null) return null;
+
+            return new Vector3CubicSampler(collection);
+        }
+
+        public static ICurveSampler<Quaternion> CreateSampler(this IEnumerable<(Single, (Quaternion, Quaternion, Quaternion))> collection)
+        {
+            if (collection == null) return null;
+
+            return new QuaternionCubicSampler(collection);
+        }
+
+        public static ICurveSampler<Single[]> CreateSampler(this IEnumerable<(Single, (Single[], Single[], Single[]))> collection)
+        {
+            if (collection == null) return null;
+
+            return new ArrayCubicSampler(collection);
+        }
+
+        #endregion
+    }
+}

+ 18 - 17
src/SharpGLTF.Core/Schema2/gltf.Animations.cs

@@ -6,6 +6,7 @@ using System.Numerics;
 
 using SharpGLTF.Collections;
 using SharpGLTF.Transforms;
+using SharpGLTF.Animations;
 
 namespace SharpGLTF.Schema2
 {
@@ -159,9 +160,9 @@ namespace SharpGLTF.Schema2
             var rfunc = FindRotationSampler(node)?.CreateCurveSampler();
             var tfunc = FindTranslationSampler(node)?.CreateCurveSampler();
 
-            if (sfunc != null) xform.Scale = sfunc(time);
-            if (rfunc != null) xform.Rotation = rfunc(time);
-            if (tfunc != null) xform.Translation = tfunc(time);
+            if (sfunc != null) xform.Scale = sfunc.GetPoint(time);
+            if (rfunc != null) xform.Rotation = rfunc.GetPoint(time);
+            if (tfunc != null) xform.Translation = tfunc.GetPoint(time);
 
             return xform;
         }
@@ -176,7 +177,7 @@ namespace SharpGLTF.Schema2
             var mfunc = FindMorphSampler(node)?.CreateCurveSampler();
             if (mfunc == null) return morphWeights;
 
-            return mfunc(time);
+            return mfunc.GetPoint(time);
         }
 
         #endregion
@@ -509,43 +510,43 @@ namespace SharpGLTF.Schema2
             }
         }
 
-        CurveSampler<Vector3> IAnimationSampler<Vector3>.CreateCurveSampler()
+        ICurveSampler<Vector3> IAnimationSampler<Vector3>.CreateCurveSampler()
         {
             var xsampler = this as IAnimationSampler<Vector3>;
 
             switch (this.InterpolationMode)
             {
-                case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateStepSamplerFunc();
-                case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateLinearSamplerFunc();
-                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateCubicSamplerFunc();
+                case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateSampler(false);
+                case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateSampler();
+                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateSampler();
             }
 
             throw new NotImplementedException();
         }
 
-        CurveSampler<Quaternion> IAnimationSampler<Quaternion>.CreateCurveSampler()
+        ICurveSampler<Quaternion> IAnimationSampler<Quaternion>.CreateCurveSampler()
         {
             var xsampler = this as IAnimationSampler<Quaternion>;
 
             switch (this.InterpolationMode)
             {
-                case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateStepSamplerFunc();
-                case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateLinearSamplerFunc();
-                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateCubicSamplerFunc();
+                case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateSampler(false);
+                case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateSampler();
+                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateSampler();
             }
 
             throw new NotImplementedException();
         }
 
-        CurveSampler<Single[]> IAnimationSampler<Single[]>.CreateCurveSampler()
+        ICurveSampler<Single[]> IAnimationSampler<Single[]>.CreateCurveSampler()
         {
             var xsampler = this as IAnimationSampler<Single[]>;
 
             switch (this.InterpolationMode)
             {
-                case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateStepSamplerFunc();
-                case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateLinearSamplerFunc();
-                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateCubicSamplerFunc();
+                case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateSampler(false);
+                case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateSampler();
+                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateSampler();
             }
 
             throw new NotImplementedException();
@@ -562,7 +563,7 @@ namespace SharpGLTF.Schema2
 
         IEnumerable<(Single, (T, T, T))> GetCubicKeys();
 
-        CurveSampler<T> CreateCurveSampler();
+        ICurveSampler<T> CreateCurveSampler();
     }
 
     public sealed partial class ModelRoot

+ 1 - 1
src/SharpGLTF.Core/Schema2/gltf.Node.cs

@@ -111,7 +111,7 @@ namespace SharpGLTF.Schema2
         /// </summary>
         public Transforms.AffineTransform LocalTransform
         {
-            get => new Transforms.AffineTransform(_matrix, _scale, _rotation, _translation);
+            get => new Transforms.AffineTransform(_matrix, _translation, _rotation, _scale);
             set
             {
                 Guard.IsFalse(this._skin.HasValue, _NOTRANSFORMMESSAGE);

+ 1 - 1
src/SharpGLTF.Core/SharpGLTF.Core.csproj

@@ -46,7 +46,7 @@
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="Microsoft.CodeQuality.Analyzers" Version="2.9.2" >
+    <PackageReference Include="Microsoft.CodeQuality.Analyzers" Version="2.9.3">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>

+ 19 - 7
src/SharpGLTF.Core/Transforms/AffineTransform.cs

@@ -16,17 +16,27 @@ namespace SharpGLTF.Transforms
     {
         #region lifecycle
 
-        internal AffineTransform(Matrix4x4? m, Vector3? s, Quaternion? r, Vector3? t)
+        public static AffineTransform Create(Matrix4x4 matrix)
         {
-            if (m.HasValue)
+            return new AffineTransform(matrix, null, null, null);
+        }
+
+        public static AffineTransform Create(Vector3? translation, Quaternion? rotation, Vector3? scale)
+        {
+            return new AffineTransform(null, translation, rotation, scale);
+        }
+
+        internal AffineTransform(Matrix4x4? matrix, Vector3? translation, Quaternion? rotation, Vector3? scale)
+        {
+            if (matrix.HasValue)
             {
-                Matrix4x4.Decompose(m.Value, out Scale, out Rotation, out Translation);
+                Matrix4x4.Decompose(matrix.Value, out Scale, out Rotation, out Translation);
             }
             else
             {
-                Rotation = r ?? Quaternion.Identity;
-                Scale = s ?? Vector3.One;
-                Translation = t ?? Vector3.Zero;
+                Rotation = rotation ?? Quaternion.Identity;
+                Scale = scale ?? Vector3.One;
+                Translation = translation ?? Vector3.Zero;
             }
         }
 
@@ -58,6 +68,8 @@ namespace SharpGLTF.Transforms
 
         #region properties
 
+        public static AffineTransform Identity => new AffineTransform { Rotation = Quaternion.Identity, Scale = Vector3.One, Translation = Vector3.Zero };
+
         /// <summary>
         /// Gets the <see cref="Matrix4x4"/> transform of the current <see cref="AffineTransform"/>
         /// </summary>
@@ -113,7 +125,7 @@ namespace SharpGLTF.Transforms
         {
             if (transform.HasValue) return transform.Value;
 
-            return new AffineTransform(null, scale, rotation, translation).Matrix;
+            return new AffineTransform(null, translation, rotation, scale).Matrix;
         }
 
         public static Matrix4x4 LocalToWorld(Matrix4x4 parentWorld, Matrix4x4 childLocal)

+ 0 - 192
src/SharpGLTF.Core/Transforms/AnimationSamplerFactory.cs

@@ -1,192 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Numerics;
-using System.Text;
-
-namespace SharpGLTF.Transforms
-{
-    public delegate T CurveSampler<T>(Single time);
-
-    internal static class AnimationSamplerFactory
-    {
-        private static (T, T, float) _GetSample<T>(this IEnumerable<(float, T)> sequence, float offset)
-        {
-            (float, T)? left = null;
-            (float, T)? right = null;
-            (float, T)? prev = null;
-
-            if (offset < 0) offset = 0;
-
-            foreach (var item in sequence)
-            {
-                if (item.Item1 == offset)
-                {
-                    left = item; continue;
-                }
-
-                if (item.Item1 > offset)
-                {
-                    if (left == null) left = prev;
-                    right = item;
-                    break;
-                }
-
-                prev = item;
-            }
-
-            if (left == null && right == null) return (default(T), default(T), 0);
-            if (left == null) return (right.Value.Item2, right.Value.Item2, 0);
-            if (right == null) return (left.Value.Item2, left.Value.Item2, 0);
-
-            var delta = right.Value.Item1 - left.Value.Item1;
-
-            System.Diagnostics.Debug.Assert(delta > 0);
-
-            var amount = (offset - left.Value.Item1) / delta;
-
-            System.Diagnostics.Debug.Assert(amount >= 0 && amount <= 1);
-
-            return (left.Value.Item2, right.Value.Item2, amount);
-        }
-
-        internal static CurveSampler<T> CreateStepSamplerFunc<T>(this IEnumerable<(float, T)> collection)
-        {
-            if (collection == null) return null;
-
-            T _sampler(float offset)
-            {
-                var sample = collection._GetSample(offset);
-                return sample.Item1;
-            }
-
-            return _sampler;
-        }
-
-        internal static CurveSampler<Vector3> CreateLinearSamplerFunc(this IEnumerable<(float, Vector3)> collection)
-        {
-            if (collection == null) return null;
-
-            Vector3 _sampler(float offset)
-            {
-                var sample = collection._GetSample(offset);
-                return Vector3.Lerp(sample.Item1, sample.Item2, sample.Item3);
-            }
-
-            return _sampler;
-        }
-
-        internal static CurveSampler<Quaternion> CreateLinearSamplerFunc(this IEnumerable<(float, Quaternion)> collection)
-        {
-            if (collection == null) return null;
-
-            Quaternion _sampler(float offset)
-            {
-                var sample = collection._GetSample(offset);
-                return Quaternion.Slerp(sample.Item1, sample.Item2, sample.Item3);
-            }
-
-            return _sampler;
-        }
-
-        internal static CurveSampler<float[]> CreateLinearSamplerFunc(this IEnumerable<(float, float[])> collection)
-        {
-            if (collection == null) return null;
-
-            float[] _sampler(float offset)
-            {
-                var sample = collection._GetSample(offset);
-                var result = new float[sample.Item1.Length];
-
-                for (int i = 0; i < result.Length; ++i)
-                {
-                    result[i] = (sample.Item1[i] * (1 - sample.Item3)) + (sample.Item2[i] * sample.Item3);
-                }
-
-                return result;
-            }
-
-            return _sampler;
-        }
-
-        internal static CurveSampler<Vector3> CreateCubicSamplerFunc(this IEnumerable<(float, (Vector3, Vector3, Vector3))> collection)
-        {
-            return CreateCubicSamplerFunc<Vector3>(collection, Hermite);
-        }
-
-        internal static CurveSampler<Quaternion> CreateCubicSamplerFunc(this IEnumerable<(float, (Quaternion, Quaternion, Quaternion))> collection)
-        {
-            return CreateCubicSamplerFunc<Quaternion>(collection, Hermite);
-        }
-
-        internal static CurveSampler<float[]> CreateCubicSamplerFunc(this IEnumerable<(float, (float[], float[], float[]))> collection)
-        {
-            return CreateCubicSamplerFunc<float[]>(collection, Hermite);
-        }
-
-        internal static CurveSampler<T> CreateCubicSamplerFunc<T>(this IEnumerable<(float, (T, T, T))> collection, Func<T, T, T, T, float, T> hermiteFunc)
-        {
-            if (collection == null) return null;
-
-            T _sampler(float offset)
-            {
-                var sample = collection._GetSample(offset);
-
-                return hermiteFunc(sample.Item1.Item2, sample.Item1.Item3, sample.Item2.Item2, sample.Item2.Item1, sample.Item3);
-            }
-
-            return _sampler;
-        }
-
-        internal static Vector3 Hermite(Vector3 value1, Vector3 tangent1, Vector3 value2, Vector3 tangent2, float amount)
-        {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
-
-            var squared = amount * amount;
-            var cubed = amount * squared;
-
-            var part1 = (2.0f * cubed) - (3.0f * squared) + 1.0f;
-            var part2 = (-2.0f * cubed) + (3.0f * squared);
-            var part3 = (cubed - (2.0f * squared)) + amount;
-            var part4 = cubed - squared;
-
-            return (value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4);
-        }
-
-        internal static Quaternion Hermite(Quaternion value1, Quaternion tangent1, Quaternion value2, Quaternion tangent2, float amount)
-        {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
-
-            var squared = amount * amount;
-            var cubed = amount * squared;
-
-            var part1 = (2.0f * cubed) - (3.0f * squared) + 1.0f;
-            var part2 = (-2.0f * cubed) + (3.0f * squared);
-            var part3 = (cubed - (2.0f * squared)) + amount;
-            var part4 = cubed - squared;
-
-            return Quaternion.Normalize((value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4));
-        }
-
-        internal static float[] Hermite(float[] value1, float[] tangent1, float[] value2, float[] tangent2, float amount)
-        {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
-
-            var squared = amount * amount;
-            var cubed = amount * squared;
-
-            var part1 = (2.0f * cubed) - (3.0f * squared) + 1.0f;
-            var part2 = (-2.0f * cubed) + (3.0f * squared);
-            var part3 = (cubed - (2.0f * squared)) + amount;
-            var part4 = cubed - squared;
-
-            var result = new float[value1.Length];
-
-            for (int i = 0; i < result.Length; ++i)
-            {
-                result[i] = (value1[i] * part1) + (value2[i] * part2) + (tangent1[i] * part3) + (tangent2[i] * part4);
-            }
-
-            return result;
-        }
-    }
-}

+ 98 - 0
src/SharpGLTF.Toolkit/Animations/AnimatableProperty.cs

@@ -0,0 +1,98 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF.Animations
+{
+    /// <summary>
+    /// Represents a property value that can be animated using <see cref="Animations.ICurveSampler{T}"/>.
+    /// </summary>
+    /// <typeparam name="T">The type of the value.</typeparam>
+    public class AnimatableProperty<T>
+    {
+        #region data
+
+        private Dictionary<string, ICurveSampler<T>> _Tracks;
+
+        /// <summary>
+        /// Gets or sets the default value of this instance.
+        /// When animations are disabled, or there's no animation track available, this will be the returned value.
+        /// </summary>
+        public T Value { get; set; }
+
+        #endregion
+
+        #region properties
+
+        public IReadOnlyDictionary<string, ICurveSampler<T>> Tracks => _Tracks == null ? Collections.EmptyDictionary<string, ICurveSampler<T>>.Instance : _Tracks;
+
+        #endregion
+
+        #region API
+
+        /// <summary>
+        /// Evaluates the value of this <see cref="AnimatableProperty{T}"/> at a given <paramref name="offset"/> for a given <paramref name="track"/>.
+        /// </summary>
+        /// <param name="track">An animation track name, or null.</param>
+        /// <param name="offset">A time offset within the given animation track.</param>
+        /// <returns>The evaluated value taken from the animation <paramref name="track"/>, or <see cref="Value"/> if a track was not found.</returns>
+        public T GetValueAt(string track, float offset)
+        {
+            if (_Tracks == null) return this.Value;
+
+            return _Tracks.TryGetValue(track, out ICurveSampler<T> sampler) ? sampler.GetPoint(offset) : this.Value;
+        }
+
+        /// <summary>
+        /// Assigns an animation curve to a given track.
+        /// </summary>
+        /// <param name="track">The name of the track.</param>
+        /// <param name="curve">A <see cref="ICurveSampler{T}"/> instance, or null to remove a track./param>
+        public void SetTrack(string track, ICurveSampler<T> curve)
+        {
+            Guard.NotNullOrEmpty(track, nameof(track));
+
+            if (curve != null)
+            {
+                var convertible = curve as IConvertibleCurve<T>;
+                Guard.NotNull(convertible, nameof(curve), $"Provided {nameof(ICurveSampler<T>)} {nameof(curve)} must implement {nameof(IConvertibleCurve<T>)} interface.");
+            }
+
+            // remove track
+            if (curve == null)
+            {
+                if (_Tracks == null) return;
+                _Tracks.Remove(track);
+                if (_Tracks.Count == 0) _Tracks = null;
+                return;
+            }
+
+            // insert track
+            if (_Tracks == null) _Tracks = new Dictionary<string, ICurveSampler<T>>();
+
+            _Tracks[track] = curve;
+        }
+
+        public CurveBuilder<T> UseTrackBuilder(string track)
+        {
+            Guard.NotNullOrEmpty(track, nameof(track));
+
+            if (_Tracks == null || !_Tracks.TryGetValue(track, out ICurveSampler<T> sampler))
+            {
+                sampler = CurveFactory.CreateCurveBuilder<T>() as ICurveSampler<T>;
+                SetTrack(track, sampler);
+            }
+
+            if (sampler is CurveBuilder<T> builder) return builder;
+
+            throw new NotImplementedException();
+
+            // TODO: CurveFactory.CreateCurveBuilder(sampler);
+        }
+
+        #endregion
+    }
+}

+ 316 - 0
src/SharpGLTF.Toolkit/Animations/CurveBuilder.cs

@@ -0,0 +1,316 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+using System.Linq;
+
+namespace SharpGLTF.Animations
+{
+    /// <summary>
+    /// Represents an editable curve of <typeparamref name="T"/> elements.
+    /// </summary>
+    /// <typeparam name="T">An element of the curve.</typeparam>
+    public abstract class CurveBuilder<T>
+        : ICurveSampler<T>,
+        IConvertibleCurve<T>
+    {
+        #region data
+
+        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
+        internal SortedDictionary<float, _CurveNode<T>> _Keys = new SortedDictionary<float, _CurveNode<T>>();
+
+        #endregion
+
+        #region properties
+
+        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)]
+        public IReadOnlyCollection<float> Keys => _Keys.Keys;
+
+        public int MaxDegree => _Keys.Count == 0 ? 0 : _Keys.Values.Max(item => item.Degree);
+
+        #endregion
+
+        #region abstract API
+
+        /// <summary>
+        /// Creates a <typeparamref name="T"/> instance from an <see cref="Single"/>[] array.
+        /// </summary>
+        /// <param name="values">An array of floats.</param>
+        /// <returns>A <typeparamref name="T"/> instance.</returns>
+        protected abstract T CreateValue(params float[] values);
+
+        /// <summary>
+        /// Ensures that the reference value is only referenced internally.
+        /// </summary>
+        /// <param name="value">A value.</param>
+        /// <returns>A copy of <paramref name="value"/>.</returns>
+        /// <remarks>
+        /// This is required for reference types like float[], where the user
+        /// can add a point using and array, and then reuse the same array to
+        /// define another point, so it can inadvertently overwrite previous
+        /// points already stored in the curve.
+        /// </remarks>
+        protected abstract T IsolateValue(T value);
+
+        /// <summary>
+        /// Samples the curve at a given <paramref name="offset"/>
+        /// </summary>
+        /// <param name="offset">The curve offset to sample.</param>
+        /// <returns>A curve <typeparamref name="T"/> point.</returns>
+        public abstract T GetPoint(float offset);
+
+        protected abstract T GetTangent(T fromValue, T toValue);
+
+        #endregion
+
+        #region API
+
+        public void RemoveKey(float offset) { _Keys.Remove(offset); }
+
+        public void SetPoint(float offset, T value, bool isLinear = true)
+        {
+            value = IsolateValue(value);
+
+            if (_Keys.TryGetValue(offset, out _CurveNode<T> existing))
+            {
+                existing.Point = value;
+            }
+            else
+            {
+                existing = new _CurveNode<T>(value, isLinear);
+            }
+
+            _Keys[offset] = existing;
+        }
+
+        /// <summary>
+        /// Sets the incoming tangent to an existing point.
+        /// </summary>
+        /// <param name="offset">The offset of the existing point.</param>
+        /// <param name="tangent">The tangent value.</param>
+        public void SetIncomingTangent(float offset, T tangent)
+        {
+            Guard.IsTrue(_Keys.ContainsKey(offset), nameof(offset));
+            tangent = IsolateValue(tangent);
+
+            offset -= float.Epsilon;
+
+            var offsets = SamplerFactory.FindPairContainingOffset(_Keys.Keys, offset);
+
+            var a = _Keys[offsets.Item1];
+            var b = _Keys[offsets.Item2];
+
+            if (a.Degree == 1) a.OutgoingTangent = GetTangent(a.Point, b.Point);
+
+            a.Degree = 3;
+            b.IncomingTangent = tangent;
+
+            _Keys[offsets.Item1] = a;
+            _Keys[offsets.Item2] = b;
+        }
+
+        /// <summary>
+        /// Sets the outgoing tangent to an existing point.
+        /// </summary>
+        /// <param name="offset">The offset of the existing point.</param>
+        /// <param name="tangent">The tangent value.</param>
+        public void SetOutgoingTangent(float offset, T tangent)
+        {
+            Guard.IsTrue(_Keys.ContainsKey(offset), nameof(offset));
+            tangent = IsolateValue(tangent);
+
+            var offsets = SamplerFactory.FindPairContainingOffset(_Keys.Keys, offset);
+
+            var a = _Keys[offsets.Item1];
+            var b = _Keys[offsets.Item2];
+
+            if (offsets.Item1 != offsets.Item2)
+            {
+                if (a.Degree == 1) b.IncomingTangent = GetTangent(a.Point, b.Point);
+                _Keys[offsets.Item2] = b;
+            }
+
+            a.Degree = 3;
+            a.OutgoingTangent = tangent;
+
+            _Keys[offsets.Item1] = a;
+        }
+
+        private protected (_CurveNode<T>, _CurveNode<T>, float) FindSample(float offset)
+        {
+            if (_Keys.Count == 0) return (default(_CurveNode<T>), default(_CurveNode<T>), 0);
+
+            var offsets = SamplerFactory.FindPairContainingOffset(_Keys.Keys, offset);
+
+            return (_Keys[offsets.Item1], _Keys[offsets.Item2], offsets.Item3);
+        }
+
+        #endregion
+
+        #region With* API
+
+        public CurveBuilder<T> WithPoint(float offset, T value, bool isLinear = true)
+        {
+            SetPoint(offset, value, isLinear);
+            return this;
+        }
+
+        public CurveBuilder<T> WithIncomingTangent(float offset, T tangent)
+        {
+            SetIncomingTangent(offset, tangent);
+            return this;
+        }
+
+        public CurveBuilder<T> WithOutgoingTangent(float offset, T tangent)
+        {
+            SetOutgoingTangent(offset, tangent);
+            return this;
+        }
+
+        public CurveBuilder<T> WithPoint(float offset, params float[] values)
+        {
+            return WithPoint(offset, CreateValue(values));
+        }
+
+        public CurveBuilder<T> WithOutgoingTangent(float offset, params float[] values)
+        {
+            return WithOutgoingTangent(offset, CreateValue(values));
+        }
+
+        public CurveBuilder<T> WithIncomingTangent(float offset, params float[] values)
+        {
+            return WithIncomingTangent(offset, CreateValue(values));
+        }
+
+        #endregion
+
+        #region IConvertibleCurve API
+
+        IReadOnlyDictionary<float, T> IConvertibleCurve<T>.ToStepCurve()
+        {
+            if (MaxDegree != 0) throw new NotSupportedException();
+
+            return _Keys.ToDictionary(item => item.Key, item => item.Value.Point);
+        }
+
+        IReadOnlyDictionary<float, T> IConvertibleCurve<T>.ToLinearCurve()
+        {
+            var d = new Dictionary<float, T>();
+
+            var orderedKeys = _Keys.Keys.ToList();
+
+            for (int i = 0; i < orderedKeys.Count - 1; ++i)
+            {
+                var a = orderedKeys[i + 0];
+                var b = orderedKeys[i + 1];
+
+                var sa = _Keys[a];
+                var sb = _Keys[b];
+
+                switch (sa.Degree)
+                {
+                    case 0: // simulate a step with an extra key
+                        d[a] = sa.Point;
+                        d[b - float.Epsilon] = sa.Point;
+                        d[b] = sb.Point;
+                        break;
+
+                    case 1:
+                        d[a] = sa.Point;
+                        d[b] = sb.Point;
+                        break;
+
+                    case 3:
+                        var t = a;
+                        while (t < b)
+                        {
+                            d[t] = this.GetPoint(t);
+                            t += 1.0f / 30.0f;
+                        }
+
+                        break;
+
+                    default: throw new NotImplementedException();
+                }
+            }
+
+            return d;
+        }
+
+        IReadOnlyDictionary<float, (T, T, T)> IConvertibleCurve<T>.ToSplineCurve()
+        {
+            var d = new Dictionary<float, (T, T, T)>();
+
+            var orderedKeys = _Keys.Keys.ToList();
+
+            for (int i = 0; i < orderedKeys.Count - 1; ++i)
+            {
+                var a = orderedKeys[i + 0];
+                var b = orderedKeys[i + 1];
+
+                var sa = _Keys[a];
+                var sb = _Keys[b];
+
+                if (!d.TryGetValue(a, out (T, T, T) da)) da = default;
+                if (!d.TryGetValue(b, out (T, T, T) db)) db = default;
+
+                da.Item2 = sa.Point;
+                db.Item2 = sb.Point;
+
+                var delta = GetTangent(da.Item2, db.Item2);
+
+                switch (sa.Degree)
+                {
+                    case 0: // simulate a step with an extra key
+                        da.Item3 = default;
+                        d[b - float.Epsilon] = (default, sa.Point, delta);
+                        db.Item1 = delta;
+                        break;
+
+                    case 1: // tangents are the delta between points
+                        da.Item3 = db.Item1 = delta;
+                        break;
+
+                    case 3: // actual tangents
+                        da.Item3 = sa.OutgoingTangent;
+                        db.Item1 = sb.IncomingTangent;
+                        break;
+
+                    default: throw new NotImplementedException();
+                }
+
+                d[a] = da;
+                d[b] = db;
+            }
+
+            return d;
+        }
+
+        #endregion
+    }
+
+    [System.Diagnostics.DebuggerDisplay("{IncomingTangent} -> {Point}[{Degree}] -> {OutgoingTangent}")]
+    struct _CurveNode<T>
+    {
+        public _CurveNode(T value, bool isLinear)
+        {
+            IncomingTangent = default;
+            Point = value;
+            OutgoingTangent = default;
+            Degree = isLinear ? 1 : 0;
+        }
+
+        public _CurveNode(T incoming, T value, T outgoing)
+        {
+            IncomingTangent = incoming;
+            Point = value;
+            OutgoingTangent = outgoing;
+            Degree = 3;
+        }
+
+        public T IncomingTangent;
+        public T Point;
+        public T OutgoingTangent;
+        public int Degree;
+    }
+}

+ 168 - 0
src/SharpGLTF.Toolkit/Animations/CurveFactory.cs

@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF.Animations
+{
+    static class CurveFactory
+    {
+        public static CurveBuilder<T> CreateCurveBuilder<T>()
+        {
+            if (typeof(T) == typeof(Vector3)) return new Vector3CurveBuilder() as CurveBuilder<T>;
+            if (typeof(T) == typeof(Quaternion)) return new QuaternionCurveBuilder() as CurveBuilder<T>;
+            if (typeof(T) == typeof(Single[])) return new ArrayCurveBuilder() as CurveBuilder<T>;
+
+            throw new ArgumentException(nameof(T), "Generic argument not supported");
+        }
+    }
+
+    [System.Diagnostics.DebuggerTypeProxy(typeof(Debug._CurveBuilderDebugProxyVector3))]
+    sealed class Vector3CurveBuilder : CurveBuilder<Vector3>, ICurveSampler<Vector3>
+    {
+        protected override Vector3 IsolateValue(Vector3 value)
+        {
+            return value;
+        }
+
+        protected override Vector3 CreateValue(params float[] values)
+        {
+            Guard.NotNull(values, nameof(values));
+            Guard.IsTrue(values.Length == 3, nameof(values));
+            return new Vector3(values[0], values[1], values[2]);
+        }
+
+        protected override Vector3 GetTangent(Vector3 fromValue, Vector3 toValue)
+        {
+            return SamplerFactory.CreateTangent(fromValue, toValue);
+        }
+
+        public override Vector3 GetPoint(Single offset)
+        {
+            var sample = FindSample(offset);
+
+            switch (sample.Item1.Degree)
+            {
+                case 0:
+                    return sample.Item1.Point;
+
+                case 1:
+                    return Vector3.Lerp(sample.Item1.Point, sample.Item2.Point, sample.Item3);
+
+                case 3:
+                    return SamplerFactory.CubicLerp
+                            (
+                            sample.Item1.Point, sample.Item1.OutgoingTangent,
+                            sample.Item2.Point, sample.Item2.IncomingTangent,
+                            sample.Item3
+                            );
+
+                default:
+                    throw new NotSupportedException();
+            }
+        }
+    }
+
+    [System.Diagnostics.DebuggerTypeProxy(typeof(Debug._CurveBuilderDebugProxyQuaternion))]
+    sealed class QuaternionCurveBuilder : CurveBuilder<Quaternion>, ICurveSampler<Quaternion>
+    {
+        protected override Quaternion IsolateValue(Quaternion value)
+        {
+            return value;
+        }
+
+        protected override Quaternion CreateValue(params float[] values)
+        {
+            Guard.NotNull(values, nameof(values));
+            Guard.IsTrue(values.Length == 4, nameof(values));
+            return new Quaternion(values[0], values[1], values[2], values[3]);
+        }
+
+        protected override Quaternion GetTangent(Quaternion fromValue, Quaternion toValue)
+        {
+            return SamplerFactory.CreateTangent(fromValue, toValue);
+        }
+
+        public override Quaternion GetPoint(float offset)
+        {
+            var sample = FindSample(offset);
+
+            switch (sample.Item1.Degree)
+            {
+                case 0:
+                    return sample.Item1.Point;
+
+                case 1:
+                    return Quaternion.Slerp(sample.Item1.Point, sample.Item2.Point, sample.Item3);
+
+                case 3:
+                    return SamplerFactory.CubicLerp
+                            (
+                            sample.Item1.Point, sample.Item1.OutgoingTangent,
+                            sample.Item2.Point, sample.Item2.IncomingTangent,
+                            sample.Item3
+                            );
+
+                default:
+                    throw new NotSupportedException();
+            }
+        }
+    }
+
+    [System.Diagnostics.DebuggerTypeProxy(typeof(Debug._CurveBuilderDebugProxyArray))]
+    sealed class ArrayCurveBuilder : CurveBuilder<Single[]>, ICurveSampler<Single[]>
+    {
+        // the first "CheckValue" will fix any further calls to this value.
+        private int _ValueLength = 0;
+
+        protected override Single[] IsolateValue(Single[] value)
+        {
+            Guard.NotNull(value, nameof(value));
+            Guard.MustBeGreaterThan(value.Length, 0, nameof(value));
+
+            if (_ValueLength == 0) _ValueLength = value.Length;
+
+            Guard.MustBeBetweenOrEqualTo(value.Length, _ValueLength, _ValueLength, nameof(value));
+
+            var clone = new Single[_ValueLength];
+            value.CopyTo(clone, 0);
+
+            return clone;
+        }
+
+        protected override Single[] CreateValue(params Single[] values)
+        {
+            return values;
+        }
+
+        protected override Single[] GetTangent(Single[] fromValue, Single[] toValue)
+        {
+            return SamplerFactory.CreateTangent(fromValue, toValue);
+        }
+
+        public override Single[] GetPoint(Single offset)
+        {
+            var sample = FindSample(offset);
+
+            switch (sample.Item1.Degree)
+            {
+                case 0:
+                    return sample.Item1.Point;
+
+                case 1:
+                    return SamplerFactory.Lerp(sample.Item1.Point, sample.Item2.Point, sample.Item3);
+
+                case 3:
+                    return SamplerFactory.CubicLerp
+                            (
+                            sample.Item1.Point, sample.Item1.OutgoingTangent,
+                            sample.Item2.Point, sample.Item2.IncomingTangent,
+                            sample.Item3
+                            );
+
+                default:
+                    throw new NotSupportedException();
+            }
+        }
+    }
+}

+ 48 - 0
src/SharpGLTF.Toolkit/Collections/EmptyDictionary.cs

@@ -0,0 +1,48 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace SharpGLTF.Collections
+{
+    /// <summary>
+    /// Represents an empty, read-only dictionary to use as a safe replacement of NULL.
+    /// </summary>
+    /// <typeparam name="TKey">The type of keys in the read-only dictionary.</typeparam>
+    /// <typeparam name="TValue">The type of values in the read-only dictionary.</typeparam>
+    sealed class EmptyDictionary<TKey, TValue> : IReadOnlyDictionary<TKey, TValue>
+    {
+        #region lifecycle
+
+        static EmptyDictionary() { }
+
+        private EmptyDictionary() { }
+
+        private static readonly EmptyDictionary<TKey, TValue> _Instance = new EmptyDictionary<TKey, TValue>();
+
+        public static IReadOnlyDictionary<TKey, TValue> Instance => _Instance;
+
+        #endregion
+
+        #region API
+
+        public TValue this[TKey key] => throw new KeyNotFoundException();
+
+        public IEnumerable<TKey> Keys => Enumerable.Empty<TKey>();
+
+        public IEnumerable<TValue> Values => Enumerable.Empty<TValue>();
+
+        public int Count => 0;
+
+        public bool ContainsKey(TKey key) { return false; }
+
+        public bool TryGetValue(TKey key, out TValue value) { value = default; return false; }
+
+        public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() { yield break; }
+
+        IEnumerator IEnumerable.GetEnumerator() { yield break; }
+
+        #endregion
+    }
+}

+ 108 - 1
src/SharpGLTF.Toolkit/Debug/DebugViews.cs

@@ -1,4 +1,4 @@
-using System;
+using System;
 using System.Collections.Generic;
 using System.Diagnostics;
 using System.Linq;
@@ -7,4 +7,111 @@ using System.Text;
 
 namespace SharpGLTF.Debug
 {
+    internal abstract class _CurveBuilderDebugProxy<T>
+    {
+        #region lifecycle
+
+        public _CurveBuilderDebugProxy(Animations.CurveBuilder<T> curve)
+        {
+            _Curve = curve;
+            _CreateItems(curve);
+        }
+
+        private void _CreateItems(Animations.CurveBuilder<T> curve)
+        {
+            Animations._CurveNode<T>? prev = null;
+
+            foreach (var kvp in curve._Keys)
+            {
+                if (prev.HasValue)
+                {
+                    var d = prev.Value.Degree;
+
+                    switch (d)
+                    {
+                        case 0:
+
+                            break;
+
+                        case 1:
+                            _Items.Add(new _OutTangent { Tangent = GetTangent(prev.Value.Point, kvp.Value.Point) });
+                            break;
+
+                        case 3:
+                            _Items.Add(new _OutTangent { Tangent = prev.Value.OutgoingTangent });
+                            _Items.Add(new _InTangent { Tangent = kvp.Value.IncomingTangent });
+                            break;
+
+                        default:
+                            _Items.Add("ERROR: {d}");
+                            break;
+                    }
+                }
+
+                _Items.Add(new _Point { Key = kvp.Key, Point = kvp.Value.Point });
+
+                prev = kvp.Value;
+            }
+        }
+
+        #endregion
+
+        #region data
+
+        [System.Diagnostics.DebuggerDisplay("{Key} => {Point}")]
+        private struct _Point
+        {
+            public float Key;
+            public T Point;
+        }
+
+        [System.Diagnostics.DebuggerDisplay("               🡖 {Tangent}")]
+        private struct _OutTangent { public T Tangent; }
+
+        [System.Diagnostics.DebuggerDisplay("               🡗 {Tangent}")]
+        private struct _InTangent { public T Tangent; }
+
+        private readonly Animations.CurveBuilder<T> _Curve;
+        private readonly List<Object> _Items = new List<object>();
+
+        #endregion
+
+        [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.RootHidden)]
+        public Object[] Items => _Items.ToArray();
+
+        protected abstract T GetTangent(T a, T b);
+    }
+
+    sealed class _CurveBuilderDebugProxyVector3 : _CurveBuilderDebugProxy<Vector3>
+    {
+        public _CurveBuilderDebugProxyVector3(Animations.CurveBuilder<Vector3> curve)
+            : base(curve) { }
+
+        protected override Vector3 GetTangent(Vector3 a, Vector3 b)
+        {
+            return b - a;
+        }
+    }
+
+    sealed class _CurveBuilderDebugProxyQuaternion : _CurveBuilderDebugProxy<Quaternion>
+    {
+        public _CurveBuilderDebugProxyQuaternion(Animations.CurveBuilder<Quaternion> curve)
+            : base(curve) { }
+
+        protected override Quaternion GetTangent(Quaternion a, Quaternion b)
+        {
+            return Animations.SamplerFactory.CreateTangent(a, b);
+        }
+    }
+
+    sealed class _CurveBuilderDebugProxyArray : _CurveBuilderDebugProxy<float[]>
+    {
+        public _CurveBuilderDebugProxyArray(Animations.CurveBuilder<float[]> curve)
+            : base(curve) { }
+
+        protected override float[] GetTangent(float[] a, float[] b)
+        {
+            return Animations.SamplerFactory.CreateTangent(a, b);
+        }
+    }
 }

+ 1 - 7
src/SharpGLTF.Toolkit/Geometry/PackedMeshBuilder.cs

@@ -21,15 +21,9 @@ namespace SharpGLTF.Geometry
         /// to a collection of <see cref="PackedMeshBuilder{TMaterial}"/>, trying to use
         /// a single vertex buffer and a single index buffer shared by all meshes.
         /// </summary>
-        /// <typeparam name="TvP">The vertex fragment type with Position, Normal and Tangent.</typeparam>
-        /// <typeparam name="TvM">The vertex fragment type with Colors and Texture Coordinates.</typeparam>
-        /// <typeparam name="TvS">The vertex fragment type with Skin Joint Weights.</typeparam>
         /// <param name="meshBuilders">A collection of <see cref="MeshBuilder{TMaterial, TvP, TvM, TvS}"/> instances.</param>
         /// <returns>A collection of <see cref="PackedMeshBuilder{TMaterial}"/> instances.</returns>
-        internal static IEnumerable<PackedMeshBuilder<TMaterial>> PackMeshes<TvP, TvM, TvS>(IEnumerable<MeshBuilder<TMaterial, TvP, TvM, TvS>> meshBuilders)
-            where TvP : struct, VertexTypes.IVertexGeometry
-            where TvM : struct, VertexTypes.IVertexMaterial
-            where TvS : struct, VertexTypes.IVertexSkinning
+        internal static IEnumerable<PackedMeshBuilder<TMaterial>> PackMeshes(IEnumerable<IMeshBuilder<TMaterial>> meshBuilders)
         {
             try
             {

+ 90 - 47
src/SharpGLTF.Toolkit/Geometry/PrimitiveBuilder.cs

@@ -11,35 +11,55 @@ namespace SharpGLTF.Geometry
 {
     public interface IPrimitive<TMaterial>
     {
+        /// <summary>
+        /// Gets the current <typeparamref name="TMaterial"/> instance used by this primitive.
+        /// </summary>
         TMaterial Material { get; }
 
+        /// <summary>
+        /// Gets the number of vertices used by each primitive shape.
+        /// </summary>
+        int VerticesPerPrimitive { get; }
+
+        /// <summary>
+        /// Gets the total number of vertices
+        /// </summary>
         int VertexCount { get; }
 
-        VertexBuilder<TvGG, TvMM, TvSS> GetVertex<TvGG, TvMM, TvSS>(int index)
-            where TvGG : struct, IVertexGeometry
-            where TvMM : struct, IVertexMaterial
-            where TvSS : struct, IVertexSkinning;
+        /// <summary>
+        /// Gets the list of <see cref="IVertexBuilder"/> vertices.
+        /// </summary>
+        IReadOnlyList<IVertexBuilder> Vertices { get; }
 
+        /// <summary>
+        /// Gets the plain list of indices.
+        /// </summary>
         IReadOnlyList<int> Indices { get; }
 
+        /// <summary>
+        /// Gets the indices of all points, given that <see cref="VerticesPerPrimitive"/> is 1.
+        /// </summary>
         IEnumerable<int> Points { get; }
 
+        /// <summary>
+        /// Gets the indices of all lines, given that <see cref="VerticesPerPrimitive"/> is 2.
+        /// </summary>
         IEnumerable<(int, int)> Lines { get; }
 
+        /// <summary>
+        /// Gets the indices of all triangles, given that <see cref="VerticesPerPrimitive"/> is 3.
+        /// </summary>
         IEnumerable<(int, int, int)> Triangles { get; }
     }
 
     public interface IPrimitiveBuilder
     {
-        void AddTriangle<TvGG, TvMM, TvSS>
-            (
-            VertexBuilder<TvGG, TvMM, TvSS> a,
-            VertexBuilder<TvGG, TvMM, TvSS> b,
-            VertexBuilder<TvGG, TvMM, TvSS> c
-            )
-            where TvGG : struct, IVertexGeometry
-            where TvMM : struct, IVertexMaterial
-            where TvSS : struct, IVertexSkinning;
+        /// <summary>
+        /// Gets the type of vertex used by this <see cref="IVertexBuilder"/>.
+        /// </summary>
+        Type VertexType { get; }
+
+        (int, int, int) AddTriangle(IVertexBuilder a, IVertexBuilder b, IVertexBuilder c);
     }
 
     /// <summary>
@@ -97,7 +117,19 @@ namespace SharpGLTF.Geometry
 
         private readonly int _PrimitiveVertexCount;
 
-        private readonly VertexList<VertexBuilder<TvG, TvM, TvS>> _Vertices = new VertexList<VertexBuilder<TvG, TvM, TvS>>();
+        class PrimitiveVertexList : VertexList<VertexBuilder<TvG, TvM, TvS>>, IReadOnlyList<IVertexBuilder>
+        {
+            #pragma warning disable SA1100 // Do not prefix calls with base unless local implementation exists
+            IVertexBuilder IReadOnlyList<IVertexBuilder>.this[int index] => base[index];
+            #pragma warning restore SA1100 // Do not prefix calls with base unless local implementation exists
+
+            IEnumerator<IVertexBuilder> IEnumerable<IVertexBuilder>.GetEnumerator()
+            {
+                foreach (var item in this) yield return item;
+            }
+        }
+
+        private readonly PrimitiveVertexList _Vertices = new PrimitiveVertexList();
         private readonly List<int> _Indices = new List<int>();
 
         #endregion
@@ -120,6 +152,8 @@ namespace SharpGLTF.Geometry
 
         public IReadOnlyList<VertexBuilder<TvG, TvM, TvS>> Vertices => _Vertices;
 
+        IReadOnlyList<IVertexBuilder> IPrimitive<TMaterial>.Vertices => _Vertices;
+
         public IReadOnlyList<int> Indices => _Indices;
 
         public IEnumerable<int> Points => _GetPointIndices();
@@ -128,6 +162,8 @@ namespace SharpGLTF.Geometry
 
         public IEnumerable<(int, int, int)> Triangles => _GetTriangleIndices();
 
+        public Type VertexType => typeof(VertexBuilder<TvG, TvM, TvS>);
+
         #endregion
 
         #region API
@@ -156,11 +192,12 @@ namespace SharpGLTF.Geometry
         /// Adds a point.
         /// </summary>
         /// <param name="a">vertex for this point.</param>
-        public void AddPoint(VertexBuilder<TvG, TvM, TvS> a)
+        /// <returns>The index of the vertex.</returns>
+        public int AddPoint(VertexBuilder<TvG, TvM, TvS> a)
         {
             Guard.IsTrue(_PrimitiveVertexCount == 1, nameof(VerticesPerPrimitive), "Points are not supported for this primitive");
 
-            UseVertex(a);
+            return UseVertex(a);
         }
 
         /// <summary>
@@ -168,26 +205,29 @@ namespace SharpGLTF.Geometry
         /// </summary>
         /// <param name="a">First corner of the line.</param>
         /// <param name="b">Second corner of the line.</param>
-        public void AddLine(VertexBuilder<TvG, TvM, TvS> a, VertexBuilder<TvG, TvM, TvS> b)
+        /// <returns>The indices of the vertices.</returns>
+        public (int, int) AddLine(VertexBuilder<TvG, TvM, TvS> a, VertexBuilder<TvG, TvM, TvS> b)
         {
             Guard.IsTrue(_PrimitiveVertexCount == 2, nameof(VerticesPerPrimitive), "Lines are not supported for this primitive");
 
             if (_Mesh.VertexPreprocessor != null)
             {
-                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref a)) return;
-                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref b)) return;
+                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref a)) return (-1,-1);
+                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref b)) return (-1, -1);
             }
 
             var aa = _Vertices.Use(a);
             var bb = _Vertices.Use(b);
 
             // check for degenerated line
-            if (aa == bb) return;
+            if (aa == bb) return (-1, -1);
 
             // TODO: check if a triangle with indices aa-bb-cc already exists.
 
             _Indices.Add(aa);
             _Indices.Add(bb);
+
+            return (aa, bb);
         }
 
         /// <summary>
@@ -196,19 +236,42 @@ namespace SharpGLTF.Geometry
         /// <param name="a">First corner of the triangle.</param>
         /// <param name="b">Second corner of the triangle.</param>
         /// <param name="c">Third corner of the triangle.</param>
-        public void AddTriangle(VertexBuilder<TvG, TvM, TvS> a, VertexBuilder<TvG, TvM, TvS> b, VertexBuilder<TvG, TvM, TvS> c)
+        /// <returns>The indices of the vertices.</returns>
+        public (int, int, int) AddTriangle(IVertexBuilder a, IVertexBuilder b, IVertexBuilder c)
+        {
+            Guard.NotNull(a, nameof(a));
+            Guard.NotNull(b, nameof(b));
+            Guard.NotNull(c, nameof(c));
+
+            var expectedType = typeof(VertexBuilder<TvG, TvM, TvS>);
+
+            var aa = a.GetType() != expectedType ? a.ConvertTo<TvG, TvM, TvS>() : (VertexBuilder<TvG, TvM, TvS>)a;
+            var bb = b.GetType() != expectedType ? b.ConvertTo<TvG, TvM, TvS>() : (VertexBuilder<TvG, TvM, TvS>)b;
+            var cc = c.GetType() != expectedType ? c.ConvertTo<TvG, TvM, TvS>() : (VertexBuilder<TvG, TvM, TvS>)c;
+
+            return AddTriangle(aa, bb, cc);
+        }
+
+        /// <summary>
+        /// Adds a triangle.
+        /// </summary>
+        /// <param name="a">First corner of the triangle.</param>
+        /// <param name="b">Second corner of the triangle.</param>
+        /// <param name="c">Third corner of the triangle.</param>
+        /// <returns>The indices of the vertices.</returns>
+        public (int, int, int) AddTriangle(VertexBuilder<TvG, TvM, TvS> a, VertexBuilder<TvG, TvM, TvS> b, VertexBuilder<TvG, TvM, TvS> c)
         {
             Guard.IsTrue(_PrimitiveVertexCount == 3, nameof(VerticesPerPrimitive), "Triangles are not supported for this primitive");
 
             if (_Mesh.VertexPreprocessor != null)
             {
-                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref a)) return;
-                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref b)) return;
-                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref c)) return;
+                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref a)) return (-1, -1, -1);
+                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref b)) return (-1, -1, -1);
+                if (!_Mesh.VertexPreprocessor.PreprocessVertex(ref c)) return (-1, -1, -1);
             }
 
             // check for degenerated triangle
-            if (a.Equals(b) || a.Equals(c) || b.Equals(c)) return;
+            if (a.Equals(b) || a.Equals(c) || b.Equals(c)) return (-1, -1, -1);
 
             var aa = _Vertices.Use(a);
             var bb = _Vertices.Use(b);
@@ -221,6 +284,8 @@ namespace SharpGLTF.Geometry
             _Indices.Add(aa);
             _Indices.Add(bb);
             _Indices.Add(cc);
+
+            return (aa, bb, cc);
         }
 
         internal void AddPrimitive(PrimitiveBuilder<TMaterial, TvG, TvM, TvS> primitive, Func<VertexBuilder<TvG, TvM, TvS>, VertexBuilder<TvG, TvM, TvS>> vertexTransform)
@@ -275,28 +340,6 @@ namespace SharpGLTF.Geometry
             }
         }
 
-        public void AddTriangle<TvPP, TvMM, TvSS>(VertexBuilder<TvPP, TvMM, TvSS> a, VertexBuilder<TvPP, TvMM, TvSS> b, VertexBuilder<TvPP, TvMM, TvSS> c)
-            where TvPP : struct, IVertexGeometry
-            where TvMM : struct, IVertexMaterial
-            where TvSS : struct, IVertexSkinning
-        {
-            var aa = a.ConvertTo<TvG, TvM, TvS>();
-            var bb = b.ConvertTo<TvG, TvM, TvS>();
-            var cc = c.ConvertTo<TvG, TvM, TvS>();
-
-            AddTriangle(aa, bb, cc);
-        }
-
-        public VertexBuilder<TvPP, TvMM, TvSS> GetVertex<TvPP, TvMM, TvSS>(int index)
-            where TvPP : struct, IVertexGeometry
-            where TvMM : struct, IVertexMaterial
-            where TvSS : struct, IVertexSkinning
-        {
-            var v = _Vertices[index];
-
-            return new VertexBuilder<TvPP, TvMM, TvSS>(v.Geometry.ConvertTo<TvPP>(), v.Material.ConvertTo<TvMM>(), v.Skinning.ConvertTo<TvSS>());
-        }
-
         private IEnumerable<int> _GetPointIndices()
         {
             if (_PrimitiveVertexCount != 1) return Enumerable.Empty<int>();

+ 40 - 1
src/SharpGLTF.Toolkit/Geometry/VertexBuilder.cs

@@ -7,6 +7,22 @@ using SharpGLTF.Geometry.VertexTypes;
 
 namespace SharpGLTF.Geometry
 {
+    public interface IVertexBuilder
+    {
+        IVertexGeometry GetGeometry();
+        IVertexMaterial GetMaterial();
+        IVertexSkinning GetSkinning();
+
+        void SetGeometry(IVertexGeometry geometry);
+        void SetMaterial(IVertexMaterial material);
+        void SetSkinning(IVertexSkinning skinning);
+
+        VertexBuilder<TvPP, TvMM, TvSS> ConvertTo<TvPP, TvMM, TvSS>()
+            where TvPP : struct, IVertexGeometry
+            where TvMM : struct, IVertexMaterial
+            where TvSS : struct, IVertexSkinning;
+    }
+
     /// <summary>
     /// Represents an individual vertex object.
     /// </summary>
@@ -37,7 +53,7 @@ namespace SharpGLTF.Geometry
     /// <see cref="VertexJoints16x8"/>.
     /// </typeparam>
     [System.Diagnostics.DebuggerDisplay("Vertex 𝐏:{Position} {_GetDebugWarnings()}")]
-    public partial struct VertexBuilder<TvG, TvM, TvS>
+    public partial struct VertexBuilder<TvG, TvM, TvS> : IVertexBuilder
         where TvG : struct, IVertexGeometry
         where TvM : struct, IVertexMaterial
         where TvS : struct, IVertexSkinning
@@ -296,6 +312,29 @@ namespace SharpGLTF.Geometry
             return sb.ToString();
         }
 
+        IVertexGeometry IVertexBuilder.GetGeometry() { return this.Geometry; }
+
+        IVertexMaterial IVertexBuilder.GetMaterial() { return this.Material; }
+
+        IVertexSkinning IVertexBuilder.GetSkinning() { return this.Skinning; }
+
+        void IVertexBuilder.SetGeometry(IVertexGeometry geometry)
+        {
+            Guard.NotNull(geometry, nameof(geometry));
+            this.Geometry = geometry.GetType() == typeof(TvG) ? (TvG)geometry : geometry.ConvertTo<TvG>();
+        }
+
+        void IVertexBuilder.SetMaterial(IVertexMaterial material)
+        {
+            Guard.NotNull(material, nameof(material));
+            this.Material = material.GetType() == typeof(TvM) ? (TvM)material : material.ConvertTo<TvM>();
+        }
+
+        void IVertexBuilder.SetSkinning(IVertexSkinning skinning)
+        {
+            Guard.NotNull(skinning, nameof(skinning));
+            this.Skinning = skinning.GetType() == typeof(TvS) ? (TvS)skinning : skinning.ConvertTo<TvS>();
+        }
         #endregion
     }
 }

+ 1 - 1
src/SharpGLTF.Toolkit/Geometry/VertexTypes/FragmentPreprocessors.cs

@@ -149,7 +149,7 @@ namespace SharpGLTF.Geometry.VertexTypes
 
             if (vertex.TryGetNormal(out Vector3 n))
             {
-                if (!n._IsReal()) return null;
+                if (!n._IsReal()) n = p;
                 if (n == Vector3.Zero) n = p;
                 if (n == Vector3.Zero) return null;
 

+ 8 - 0
src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexEmpty.cs

@@ -30,5 +30,13 @@ namespace SharpGLTF.Geometry.VertexTypes
         JointBinding IVertexSkinning.GetJointBinding(int index) { throw new NotSupportedException(); }
 
         public IEnumerable<JointBinding> JointBindings => Enumerable.Empty<JointBinding>();
+
+        public Vector4 JointsLow => Vector4.Zero;
+
+        public Vector4 JointsHigh => Vector4.Zero;
+
+        public Vector4 WeightsLow => Vector4.Zero;
+
+        public Vector4 Weightshigh => Vector4.Zero;
     }
 }

+ 5 - 0
src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexGeometry.cs

@@ -107,6 +107,11 @@ namespace SharpGLTF.Geometry.VertexTypes
             src.TryGetNormal(out this.Normal);
         }
 
+        public static implicit operator VertexPositionNormal((Vector3, Vector3) posnrm)
+        {
+            return new VertexPositionNormal(posnrm.Item1, posnrm.Item2);
+        }
+
         #endregion
 
         #region data

+ 5 - 0
src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexMaterial.cs

@@ -156,6 +156,11 @@ namespace SharpGLTF.Geometry.VertexTypes
             this.TexCoord = src.MaxTextCoords > 0 ? src.GetTexCoord(0) : Vector2.Zero;
         }
 
+        public static implicit operator VertexColor1Texture1((Vector4,Vector2) coloruv)
+        {
+            return new VertexColor1Texture1(coloruv.Item1, coloruv.Item2);
+        }
+
         #endregion
 
         #region data

+ 49 - 0
src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexSkinning.cs

@@ -126,6 +126,12 @@ namespace SharpGLTF.Geometry.VertexTypes
         void SetJointBinding(int index, int joint, float weight);
 
         IEnumerable<JointBinding> JointBindings { get; }
+
+        Vector4 JointsLow { get; }
+        Vector4 JointsHigh { get; }
+
+        Vector4 WeightsLow { get; }
+        Vector4 Weightshigh { get; }
     }
 
     /// <summary>
@@ -167,6 +173,9 @@ namespace SharpGLTF.Geometry.VertexTypes
 
         public VertexJoints8x4(params (int, float)[] bindings)
         {
+            Guard.NotNull(bindings, nameof(bindings));
+            Guard.MustBeBetweenOrEqualTo(bindings.Length, 1, 4, nameof(bindings));
+
             Joints = Vector4.Zero;
             Weights = Vector4.Zero;
 
@@ -190,6 +199,16 @@ namespace SharpGLTF.Geometry.VertexTypes
 
         #endregion
 
+        #region properties
+
+        public Vector4 JointsLow => this.Joints;
+        public Vector4 JointsHigh => Vector4.Zero;
+
+        public Vector4 WeightsLow => this.Weights;
+        public Vector4 Weightshigh => Vector4.Zero;
+
+        #endregion
+
         #region API
 
         public void Validate() { FragmentPreprocessors.ValidateVertexSkinning(this); }
@@ -289,6 +308,16 @@ namespace SharpGLTF.Geometry.VertexTypes
 
         #endregion
 
+        #region properties
+
+        public Vector4 JointsLow => this.Joints;
+        public Vector4 JointsHigh => Vector4.Zero;
+
+        public Vector4 WeightsLow => this.Weights;
+        public Vector4 Weightshigh => Vector4.Zero;
+
+        #endregion
+
         #region API
 
         public void Validate() { FragmentPreprocessors.ValidateVertexSkinning(this); }
@@ -380,6 +409,16 @@ namespace SharpGLTF.Geometry.VertexTypes
 
         #endregion
 
+        #region properties
+
+        public Vector4 JointsLow => this.Joints0;
+        public Vector4 JointsHigh => this.Joints1;
+
+        public Vector4 WeightsLow => this.Weights0;
+        public Vector4 Weightshigh => this.Joints1;
+
+        #endregion
+
         #region API
 
         public void Validate() { FragmentPreprocessors.ValidateVertexSkinning(this); }
@@ -464,6 +503,16 @@ namespace SharpGLTF.Geometry.VertexTypes
 
         #endregion
 
+        #region properties
+
+        public Vector4 JointsLow => this.Joints0;
+        public Vector4 JointsHigh => this.Joints1;
+
+        public Vector4 WeightsLow => this.Weights0;
+        public Vector4 Weightshigh => this.Joints1;
+
+        #endregion
+
         #region API
 
         public void Validate() { FragmentPreprocessors.ValidateVertexSkinning(this); }

+ 39 - 73
src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexUtils.cs

@@ -49,20 +49,27 @@ namespace SharpGLTF.Geometry.VertexTypes
             return true;
         }
 
-        public static IEnumerable<MemoryAccessor[]> CreateVertexMemoryAccessors<TvG, TvM, TvS>(this IEnumerable<IReadOnlyList<VertexBuilder<TvG, TvM, TvS>>> vertexBlocks)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
+        public static IEnumerable<MemoryAccessor[]> CreateVertexMemoryAccessors<TVertex>(this IEnumerable<IReadOnlyList<TVertex>> vertexBlocks)
+            where TVertex : IVertexBuilder
         {
             // total number of vertices
             var totalCount = vertexBlocks.Sum(item => item.Count);
 
-            // vertex attributes
-            var attributes = GetVertexAttributes(typeof(TvG), typeof(TvM), typeof(TvS), totalCount);
+            // determine the vertex attributes from the first vertex.
+            var firstVertex = vertexBlocks
+                .First(item => item.Count > 0)
+                .First();
+
+            var tvg = firstVertex.GetGeometry().GetType();
+            var tvm = firstVertex.GetMaterial().GetType();
+            var tvs = firstVertex.GetSkinning().GetType();
+            var attributes = _GetVertexAttributes(tvg, tvm, tvs, totalCount);
 
             // create master vertex buffer
             int byteStride = attributes[0].ByteStride;
-            var vbuffer = new ArraySegment<byte>( new Byte[byteStride * totalCount] );
+            var vbuffer = new ArraySegment<byte>(new Byte[byteStride * totalCount]);
+
+            // fill the buffer with the vertex blocks.
 
             var baseVertexIndex = 0;
 
@@ -75,12 +82,12 @@ namespace SharpGLTF.Geometry.VertexTypes
 
                 foreach (var accessor in accessors)
                 {
-                    var columnFunc = GetItemValueFunc<TvG, TvM, TvS>(accessor.Attribute.Name);
+                    var columnFunc = _GetVertexBuilderAttributeFunc(accessor.Attribute.Name);
 
-                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.SCALAR) accessor.AsScalarArray().Fill(block.GetScalarColumn(columnFunc));
-                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.VEC2) accessor.AsVector2Array().Fill(block.GetVector2Column(columnFunc));
-                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.VEC3) accessor.AsVector3Array().Fill(block.GetVector3Column(columnFunc));
-                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.VEC4) accessor.AsVector4Array().Fill(block.GetVector4Column(columnFunc));
+                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.SCALAR) accessor.AsScalarArray().Fill(block._GetColumn<TVertex, float>(columnFunc));
+                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.VEC2) accessor.AsVector2Array().Fill(block._GetColumn<TVertex, Vector2>(columnFunc));
+                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.VEC3) accessor.AsVector3Array().Fill(block._GetColumn<TVertex, Vector3>(columnFunc));
+                    if (accessor.Attribute.Dimensions == Schema2.DimensionType.VEC4) accessor.AsVector4Array().Fill(block._GetColumn<TVertex, Vector4>(columnFunc));
                 }
 
                 yield return accessors;
@@ -113,22 +120,7 @@ namespace SharpGLTF.Geometry.VertexTypes
             }
         }
 
-        private static System.Reflection.FieldInfo GetVertexField(Type vertexType, string attributeName)
-        {
-            foreach (var finfo in vertexType.GetFields())
-            {
-                var attribute = _GetMemoryAccessInfo(finfo);
-
-                if (attribute.HasValue)
-                {
-                    if (attribute.Value.Name == attributeName) return finfo;
-                }
-            }
-
-            return null;
-        }
-
-        private static MemoryAccessInfo[] GetVertexAttributes(Type vertexType, Type valuesType, Type jointsType, int itemsCount)
+        private static MemoryAccessInfo[] _GetVertexAttributes(Type vertexType, Type valuesType, Type jointsType, int itemsCount)
         {
             var attributes = new List<MemoryAccessInfo>();
 
@@ -179,59 +171,33 @@ namespace SharpGLTF.Geometry.VertexTypes
             return new MemoryAccessInfo(attribute.Name, 0, 0, 0, dimensions.Value, attribute.Encoding, attribute.Normalized);
         }
 
-        private static Func<VertexBuilder<TvG, TvM, TvS>, Object> GetItemValueFunc<TvG, TvM, TvS>(string attributeName)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
+        private static Func<IVertexBuilder, Object> _GetVertexBuilderAttributeFunc(string attributeName)
         {
-            var finfo = GetVertexField(typeof(TvG), attributeName);
-            if (finfo != null) return vertex => finfo.GetValue(vertex.Geometry);
-
-            finfo = GetVertexField(typeof(TvM), attributeName);
-            if (finfo != null) return vertex => finfo.GetValue(vertex.Material);
-
-            finfo = GetVertexField(typeof(TvS), attributeName);
-            if (finfo != null) return vertex => finfo.GetValue(vertex.Skinning);
+            if (attributeName == "POSITION") return v => v.GetGeometry().GetPosition();
+            if (attributeName == "NORMAL") return v => { return v.GetGeometry().TryGetNormal(out Vector3 n) ? n : Vector3.Zero; };
+            if (attributeName == "TANGENT") return v => { return v.GetGeometry().TryGetTangent(out Vector4 n) ? n : Vector4.Zero; };
 
-            throw new NotImplementedException();
-        }
+            if (attributeName == "COLOR_0") return v => { var m = v.GetMaterial(); return m.MaxColors <= 0 ? Vector4.One : m.GetColor(0); };
+            if (attributeName == "COLOR_1") return v => { var m = v.GetMaterial(); return m.MaxColors <= 1 ? Vector4.One : m.GetColor(1); };
+            if (attributeName == "COLOR_2") return v => { var m = v.GetMaterial(); return m.MaxColors <= 2 ? Vector4.One : m.GetColor(2); };
+            if (attributeName == "COLOR_3") return v => { var m = v.GetMaterial(); return m.MaxColors <= 3 ? Vector4.One : m.GetColor(3); };
 
-        private static Single[] GetScalarColumn<TvG, TvM, TvS>(this IReadOnlyList<VertexBuilder<TvG, TvM, TvS>> vertices, Func<VertexBuilder<TvG, TvM, TvS>, Object> func)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
-        {
-            return GetColumn<TvG, TvM, TvS, Single>(vertices, func);
-        }
+            if (attributeName == "TEXCOORD_0") return v => { var m = v.GetMaterial(); return m.MaxTextCoords <= 0 ? Vector2.Zero : m.GetTexCoord(0); };
+            if (attributeName == "TEXCOORD_1") return v => { var m = v.GetMaterial(); return m.MaxTextCoords <= 1 ? Vector2.Zero : m.GetTexCoord(1); };
+            if (attributeName == "TEXCOORD_2") return v => { var m = v.GetMaterial(); return m.MaxTextCoords <= 2 ? Vector2.Zero : m.GetTexCoord(2); };
+            if (attributeName == "TEXCOORD_3") return v => { var m = v.GetMaterial(); return m.MaxTextCoords <= 3 ? Vector2.Zero : m.GetTexCoord(3); };
 
-        private static Vector2[] GetVector2Column<TvG, TvM, TvS>(this IReadOnlyList<VertexBuilder<TvG, TvM, TvS>> vertices, Func<VertexBuilder<TvG, TvM, TvS>, Object> func)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
-        {
-            return GetColumn<TvG, TvM, TvS, Vector2>(vertices, func);
-        }
+            if (attributeName == "JOINTS_0") return v => v.GetSkinning().JointsLow;
+            if (attributeName == "JOINTS_1") return v => v.GetSkinning().JointsHigh;
 
-        private static Vector3[] GetVector3Column<TvG, TvM, TvS>(this IReadOnlyList<VertexBuilder<TvG, TvM, TvS>> vertices, Func<VertexBuilder<TvG, TvM, TvS>, Object> func)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
-        {
-            return GetColumn<TvG, TvM, TvS, Vector3>(vertices, func);
-        }
+            if (attributeName == "WEIGHTS_0") return v => v.GetSkinning().WeightsLow;
+            if (attributeName == "WEIGHTS_1") return v => v.GetSkinning().Weightshigh;
 
-        private static Vector4[] GetVector4Column<TvG, TvM, TvS>(this IReadOnlyList<VertexBuilder<TvG, TvM, TvS>> vertices, Func<VertexBuilder<TvG, TvM, TvS>, Object> func)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
-        {
-            return GetColumn<TvG, TvM, TvS, Vector4>(vertices, func);
+            throw new NotImplementedException();
         }
 
-        private static TColumn[] GetColumn<TvG, TvM, TvS, TColumn>(this IReadOnlyList<VertexBuilder<TvG, TvM, TvS>> vertices, Func<VertexBuilder<TvG, TvM, TvS>, Object> func)
-            where TvG : struct, IVertexGeometry
-            where TvM : struct, IVertexMaterial
-            where TvS : struct, IVertexSkinning
+        private static TColumn[] _GetColumn<TVertex, TColumn>(this IReadOnlyList<TVertex> vertices, Func<IVertexBuilder, Object> func)
+            where TVertex : IVertexBuilder
         {
             var dst = new TColumn[vertices.Count];
 

+ 58 - 17
src/SharpGLTF.Toolkit/Materials/ChannelBuilder.cs

@@ -31,6 +31,46 @@ namespace SharpGLTF.Materials
 
         private readonly String _Key;
 
+        /// <summary>
+        /// Gets or sets the <see cref="ChannelBuilder"/> paramenter.
+        /// Its meaning depends on <see cref="Key"/>.
+        /// </summary>
+        public Vector4 Parameter { get; set; }
+
+        public TextureBuilder Texture { get; private set; }
+
+        public static bool AreEqual(ChannelBuilder a, ChannelBuilder b)
+        {
+            #pragma warning disable IDE0041 // Use 'is null' check
+            if (Object.ReferenceEquals(a, b)) return true;
+            if (Object.ReferenceEquals(a, null)) return false;
+            if (Object.ReferenceEquals(b, null)) return false;
+            #pragma warning restore IDE0041 // Use 'is null' check
+
+            if (!Object.ReferenceEquals(a._Parent, b._Parent)) return false;
+
+            if (a._Key != b._Key) return false;
+
+            if (a.Parameter != b.Parameter) return false;
+
+            if (!TextureBuilder.AreEqual(a.Texture, b.Texture)) return false;
+
+            return true;
+        }
+
+        public static int GetContentHashCode(ChannelBuilder x)
+        {
+            if (x == null) return 0;
+
+            var h = x._Key.GetHashCode();
+
+            h ^= x.Parameter.GetHashCode();
+
+            h ^= TextureBuilder.GetContentHashCode(x.Texture);
+
+            return h;
+        }
+
         #endregion
 
         #region properties
@@ -40,13 +80,7 @@ namespace SharpGLTF.Materials
         /// </summary>
         public String Key => _Key;
 
-        /// <summary>
-        /// Gets or sets the <see cref="ChannelBuilder"/> paramenter.
-        /// Its meaning depends on <see cref="Key"/>.
-        /// </summary>
-        public Vector4 Parameter { get; set; }
-
-        public TextureBuilder Texture { get; private set; }
+        public static IEqualityComparer<ChannelBuilder> ContentComparer => _ContentComparer.Default;
 
         #endregion
 
@@ -81,18 +115,25 @@ namespace SharpGLTF.Materials
         public void RemoveTexture() { Texture = null; }
 
         #endregion
-    }
 
-    public enum KnownChannels
-    {
-        Normal,
-        Occlusion,
-        Emissive,
+        #region Support types
 
-        BaseColor,
-        MetallicRoughness,
+        sealed class _ContentComparer : IEqualityComparer<ChannelBuilder>
+        {
+            public static readonly _ContentComparer Default = new _ContentComparer();
+
+            public bool Equals(ChannelBuilder x, ChannelBuilder y)
+            {
+                return ChannelBuilder.AreEqual(x, y);
+            }
+
+            public int GetHashCode(ChannelBuilder obj)
+            {
+                return ChannelBuilder.GetContentHashCode(obj);
+            }
+        }
 
-        Diffuse,
-        SpecularGlossiness,
+        #endregion
     }
+
 }

+ 85 - 10
src/SharpGLTF.Toolkit/Materials/MaterialBuilder.cs

@@ -4,8 +4,6 @@ using System.Linq;
 using System.Numerics;
 using System.Text;
 
-using ALPHA = SharpGLTF.Schema2.AlphaMode;
-
 namespace SharpGLTF.Materials
 {
     [System.Diagnostics.DebuggerDisplay("{Name} {ShaderStyle}")]
@@ -18,6 +16,11 @@ namespace SharpGLTF.Materials
             Name = name;
         }
 
+        public static MaterialBuilder CreateDefault()
+        {
+            return new MaterialBuilder("Default");
+        }
+
         #endregion
 
         #region data
@@ -30,22 +33,73 @@ namespace SharpGLTF.Materials
 
         private MaterialBuilder _CompatibilityFallbackMaterial;
 
-        #endregion
-
-        #region properties
-
+        /// <summary>
+        /// Gets or sets the name of this <see cref="MaterialBuilder"/> instance.
+        /// </summary>
         public string Name { get; set; }
 
-        public IReadOnlyCollection<ChannelBuilder> Channels => _Channels;
-
-        public ALPHA AlphaMode { get; set; } = ALPHA.OPAQUE;
+        public AlphaMode AlphaMode { get; set; } = AlphaMode.OPAQUE;
 
         public Single AlphaCutoff { get; set; } = 0.5f;
 
+        /// <summary>
+        /// Gets or sets a value indicating whether triangles must be rendered from both sides.
+        /// </summary>
         public Boolean DoubleSided { get; set; } = false;
 
         public String ShaderStyle { get; set; } = SHADERPBRMETALLICROUGHNESS;
 
+        public static bool AreEqual(MaterialBuilder x, MaterialBuilder y)
+        {
+            #pragma warning disable IDE0041 // Use 'is null' check
+            if (Object.ReferenceEquals(x, y)) return true;
+            if (Object.ReferenceEquals(x, null)) return false;
+            if (Object.ReferenceEquals(y, null)) return false;
+            #pragma warning restore IDE0041 // Use 'is null' check
+
+            // Although .Name is not strictly a material property,
+            // it identifies a specific material during Runtime that
+            // might be relevant and needs to be preserved.
+            // If an author needs materials to be merged, it's better
+            // to keep the Name as null, or to use a common name like "Default".
+
+            if (x.Name != y.Name) return false;
+            if (x.AlphaMode != y.AlphaMode) return false;
+            if (x.AlphaCutoff != y.AlphaCutoff) return false;
+            if (x.DoubleSided != y.DoubleSided) return false;
+            if (x.ShaderStyle != y.ShaderStyle) return false;
+
+            if (!AreEqual(x._CompatibilityFallbackMaterial, y._CompatibilityFallbackMaterial)) return false;
+
+            return Enumerable.SequenceEqual(x._Channels, y._Channels, ChannelBuilder.ContentComparer);
+        }
+
+        public static int GetContentHashCode(MaterialBuilder x)
+        {
+            if (x == null) return 0;
+
+            var h = x.Name == null ? 0 : x.Name.GetHashCode();
+
+            h ^= x.AlphaMode.GetHashCode();
+            h ^= x.AlphaCutoff.GetHashCode();
+            h ^= x.DoubleSided.GetHashCode();
+            h ^= x.ShaderStyle.GetHashCode();
+
+            h ^= x._Channels
+                .Select(item => ChannelBuilder.GetContentHashCode(item))
+                .GetContentHashCode();
+
+            h ^= GetContentHashCode(x._CompatibilityFallbackMaterial);
+
+            return h;
+        }
+
+        #endregion
+
+        #region properties
+
+        public IReadOnlyCollection<ChannelBuilder> Channels => _Channels;
+
         public MaterialBuilder CompatibilityFallback
         {
             get => _CompatibilityFallbackMaterial;
@@ -56,6 +110,8 @@ namespace SharpGLTF.Materials
             }
         }
 
+        public static IEqualityComparer<MaterialBuilder> ContentComparer => _ContentComparer.Default;
+
         #endregion
 
         #region API
@@ -126,7 +182,7 @@ namespace SharpGLTF.Materials
             return ch;
         }
 
-        public MaterialBuilder WithAlpha(ALPHA alphaMode = ALPHA.OPAQUE, Single alphaCutoff = 0.5f)
+        public MaterialBuilder WithAlpha(AlphaMode alphaMode = AlphaMode.OPAQUE, Single alphaCutoff = 0.5f)
         {
             this.AlphaMode = alphaMode;
             this.AlphaCutoff = alphaCutoff;
@@ -234,5 +290,24 @@ namespace SharpGLTF.Materials
         public MaterialBuilder WithEmissive(Vector3 emissiveFactor) { return WithChannelParam("Emissive", new Vector4(emissiveFactor, 0)); }
 
         #endregion
+
+        #region support types
+
+        sealed class _ContentComparer : IEqualityComparer<MaterialBuilder>
+        {
+            public static readonly _ContentComparer Default = new _ContentComparer();
+
+            public bool Equals(MaterialBuilder x, MaterialBuilder y)
+            {
+                return MaterialBuilder.AreEqual(x, y);
+            }
+
+            public int GetHashCode(MaterialBuilder obj)
+            {
+                return MaterialBuilder.GetContentHashCode(obj);
+            }
+        }
+
+        #endregion
     }
 }

+ 29 - 0
src/SharpGLTF.Toolkit/Materials/MaterialEnums.cs

@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace SharpGLTF.Materials
+{
+    /// <summary>
+    /// The alpha rendering mode of the material.
+    /// </summary>
+    public enum AlphaMode
+    {
+        OPAQUE,
+        MASK,
+        BLEND,
+    }
+
+    public enum KnownChannels
+    {
+        Normal,
+        Occlusion,
+        Emissive,
+
+        BaseColor,
+        MetallicRoughness,
+
+        Diffuse,
+        SpecularGlossiness,
+    }
+}

+ 110 - 11
src/SharpGLTF.Toolkit/Materials/TextureBuilder.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Numerics;
 using System.Text;
 
@@ -32,14 +33,70 @@ namespace SharpGLTF.Materials
         private BYTES _PrimaryImageContent;
         private BYTES _FallbackImageContent;
 
+        public int CoordinateSet { get; set; } = 0;
+
+        public TEXMIPMAP MinFilter { get; set; } = TEXMIPMAP.DEFAULT;
+
+        public TEXLERP MagFilter { get; set; } = TEXLERP.DEFAULT;
+
+        public TEXWRAP WrapS { get; set; } = TEXWRAP.REPEAT;
+
+        public TEXWRAP WrapT { get; set; } = TEXWRAP.REPEAT;
+
         private TextureTransformBuilder _Transform;
 
+        public static bool AreEqual(TextureBuilder a, TextureBuilder b)
+        {
+            #pragma warning disable IDE0041 // Use 'is null' check
+            if (Object.ReferenceEquals(a, b)) return true;
+            if (Object.ReferenceEquals(a, null)) return false;
+            if (Object.ReferenceEquals(b, null)) return false;
+            #pragma warning restore IDE0041 // Use 'is null' check
+
+            if (!Object.ReferenceEquals(a._Parent, b._Parent)) return false;
+
+            if (a.CoordinateSet != b.CoordinateSet) return false;
+
+            if (a.MinFilter != b.MinFilter) return false;
+            if (a.MagFilter != b.MagFilter) return false;
+            if (a.WrapS != b.WrapS) return false;
+            if (a.WrapT != b.WrapT) return false;
+
+            if (!_AreArraysContentEqual(a._PrimaryImageContent, b._PrimaryImageContent)) return false;
+            if (!_AreArraysContentEqual(a._FallbackImageContent, b._FallbackImageContent)) return false;
+
+            if (TextureTransformBuilder.AreEqual(a._Transform, b._Transform)) return false;
+
+            return true;
+        }
+
+        private static bool _AreArraysContentEqual(BYTES a, BYTES b)
+        {
+            if (a.Equals(b)) return true;
+
+            return Enumerable.SequenceEqual(a, b);
+        }
+
+        public static int GetContentHashCode(TextureBuilder x)
+        {
+            if (x == null) return 0;
+
+            var h = x.CoordinateSet.GetHashCode();
+            h ^= x.MinFilter.GetHashCode();
+            h ^= x.MagFilter.GetHashCode();
+            h ^= x.WrapS.GetHashCode();
+            h ^= x.WrapT.GetHashCode();
+
+            h ^= x._PrimaryImageContent.GetContentHashCode(16);
+            h ^= x._FallbackImageContent.GetContentHashCode(16);
+
+            return h;
+        }
+
         #endregion
 
         #region properties
 
-        public int CoordinateSet { get; set; } = 0;
-
         /// <summary>
         /// Gets or sets the default image bytes to use by this <see cref="TextureBuilder"/>,
         /// Supported formats are: PNG, JPG, DDS and WEBP
@@ -60,16 +117,10 @@ namespace SharpGLTF.Materials
             set => WithFallbackImage(value);
         }
 
-        public TEXMIPMAP MinFilter { get; set; } = TEXMIPMAP.DEFAULT;
-
-        public TEXLERP MagFilter { get; set; } = TEXLERP.DEFAULT;
-
-        public TEXWRAP WrapS { get; set; } = TEXWRAP.REPEAT;
-
-        public TEXWRAP WrapT { get; set; } = TEXWRAP.REPEAT;
-
         public TextureTransformBuilder Transform => _Transform;
 
+        public static IEqualityComparer<TextureBuilder> ContentComparer => _ContentComparer.Default;
+
         #endregion
 
         #region API
@@ -113,7 +164,7 @@ namespace SharpGLTF.Materials
         {
             if (image.Count > 0)
             {
-                Guard.IsTrue(image._IsJpgImage() || image._IsPngImage(), nameof(image), "Must be JPG, PNG");
+                Guard.IsTrue(image._IsJpgImage() || image._IsPngImage(), nameof(image), "Must be JPG or PNG");
             }
             else
             {
@@ -150,10 +201,31 @@ namespace SharpGLTF.Materials
         }
 
         #endregion
+
+        #region support types
+
+        sealed class _ContentComparer : IEqualityComparer<TextureBuilder>
+        {
+            public static readonly _ContentComparer Default = new _ContentComparer();
+
+            public bool Equals(TextureBuilder x, TextureBuilder y)
+            {
+                return TextureBuilder.AreEqual(x, y);
+            }
+
+            public int GetHashCode(TextureBuilder obj)
+            {
+                return TextureBuilder.GetContentHashCode(obj);
+            }
+        }
+
+        #endregion
     }
 
     public class TextureTransformBuilder
     {
+        #region lifecycle
+
         internal TextureTransformBuilder(Vector2 offset, Vector2 scale, float rotation = 0, int? coordSetOverride = null)
         {
             this.Offset = offset;
@@ -162,6 +234,10 @@ namespace SharpGLTF.Materials
             this.CoordinateSetOverride = coordSetOverride;
         }
 
+        #endregion
+
+        #region data
+
         public Vector2 Offset { get; set; }
 
         public Vector2 Scale { get; set; } = Vector2.One;
@@ -174,6 +250,27 @@ namespace SharpGLTF.Materials
         /// </summary>
         public int? CoordinateSetOverride { get; set; }
 
+        public static bool AreEqual(TextureTransformBuilder a, TextureTransformBuilder b)
+        {
+            #pragma warning disable IDE0041 // Use 'is null' check
+            if (Object.ReferenceEquals(a, b)) return true;
+            if (Object.ReferenceEquals(a, null)) return false;
+            if (Object.ReferenceEquals(b, null)) return false;
+            #pragma warning restore IDE0041 // Use 'is null' check
+
+            if (a.Offset != b.Offset) return false;
+            if (a.Scale != b.Scale) return false;
+            if (a.Rotation != b.Rotation) return false;
+
+            if (a.CoordinateSetOverride != b.CoordinateSetOverride) return false;
+
+            return true;
+        }
+
+        #endregion
+
+        #region properties
+
         internal bool IsDefault
         {
             get
@@ -185,5 +282,7 @@ namespace SharpGLTF.Materials
                 return false;
             }
         }
+
+        #endregion
     }
 }

+ 26 - 12
src/SharpGLTF.Toolkit/README.md

@@ -1,21 +1,35 @@
 # SharpGLTF Toolkit
 
-#### Features
+#### Overview
 
-- Scene Evaluation.
-- Mesh Building utilities.
-- Material Building utilities
-- Animation extension helpers.
-- Skinning helpers.
+SharpGLTF.Toolkit is a collection of classes and utilities aimed to help
+the developer in creating and editing glTF files in the easiest way possible.
+
+Althought the Schema2 namespace API does support some editing capabilities, in
+practice it stands for the word _"The JPEG of 3D"_ , which essentially means
+that its internal structure is not designed to be easily editable.
+
+so, although you can build glTF scenes directly with the Schema2 namespace API,
+doing so is not trivial, and you will need to do a lot of trickery on your own,
+specially for complex scenes and optimizing assets.
+
+So the Toolkit API comes to cover the gap, and make things a bit easier to
+create glTF assets programatically.
+
+A lof ot the classes in the toolkit follow the `StringBuilder` API paradigm, which
+is, you start from scratch and you keep adding elements to it until you finish.
+
+
+#### Toolkit Namespaces
+
+- [MeshBuilder.](Geometry/readme.md)
+  - [Vertex formats.](Geometry/VertexTypes/readme.md)
+- [MaterialBuilder.](Materials/readme.md)
+- [SceneBuilder](Scenes/readme.md)
 
 #### Roadmap
 
-- Morphing utilities
+- Morphing support.
 - [Mikktspace](https://github.com/tcoppex/ext-mikktspace) Tangent space calculation. *Help Need*
 - GPU Evaluation.
 
-#### Namespaces
-
-[Geometry](Geometry/readme.md)
-
-[Materials](Materials/readme.md)

+ 219 - 0
src/SharpGLTF.Toolkit/Scenes/Content.cs

@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+using SharpGLTF.Geometry;
+using SharpGLTF.Materials;
+using SharpGLTF.Schema2;
+
+namespace SharpGLTF.Scenes
+{
+    using MESHBUILDER = IMeshBuilder<MaterialBuilder>;
+
+    interface IContentRoot
+    {
+        MESHBUILDER GetGeometryAsset();
+
+        NodeBuilder GetArmatureAsset();
+
+        void Setup(Scene dstScene, Schema2SceneBuilder context);
+    }
+
+    interface IContent
+    {
+        void Setup(Node dstNode, Schema2SceneBuilder context);
+    }
+
+    interface IRenderableContent : IContent
+    {
+        MESHBUILDER GetGeometryAsset();
+    }
+
+    class StaticTransformer : IContentRoot
+    {
+        #region lifecycle
+
+        public StaticTransformer(MESHBUILDER mesh, Matrix4x4 xform)
+        {
+            _Transform = xform;
+            _Target = new MeshContent(mesh);
+        }
+
+        #endregion
+
+        #region data
+
+        private IContent _Target;// Can be either a morphController or a mesh, or light or camera
+
+        private Matrix4x4 _Transform;
+
+        #endregion
+
+        #region API
+
+        public NodeBuilder GetArmatureAsset() { return null; }
+
+        public MESHBUILDER GetGeometryAsset() { return (_Target as IRenderableContent)?.GetGeometryAsset(); }
+
+        public void Setup(Scene dstScene, Schema2SceneBuilder context)
+        {
+            var node = dstScene.CreateNode();
+            node.LocalMatrix = _Transform;
+
+            _Target.Setup(node, context);
+        }
+
+        #endregion
+    }
+
+    class NodeTransformer : IContentRoot
+    {
+        #region lifecycle
+
+        public NodeTransformer(MESHBUILDER mesh, NodeBuilder node)
+        {
+            _Node = node;
+            _Target = new MeshContent(mesh);
+        }
+
+        #endregion
+
+        #region data
+
+        private IContent _Target; // Can be either a morphController or a mesh, or light or camera
+
+        private NodeBuilder _Node;
+
+        #endregion
+
+        #region API
+
+        public NodeBuilder GetArmatureAsset() { return _Node.Root; }
+
+        public MESHBUILDER GetGeometryAsset() { return (_Target as IRenderableContent)?.GetGeometryAsset(); }
+
+        public void Setup(Schema2.Scene dstScene, Schema2SceneBuilder context)
+        {
+            var node = context.GetNode(_Node);
+
+            if (node == null) dstScene.CreateNode();
+
+            _Target.Setup(node, context);
+        }
+
+        #endregion
+    }
+
+    class SkinTransformer : IContentRoot
+    {
+        #region lifecycle
+
+        public SkinTransformer(MESHBUILDER mesh, NodeBuilder[] joints)
+        {
+            _Target = new MeshContent(mesh);
+            _Joints.AddRange(joints);
+        }
+
+        #endregion
+
+        #region data
+
+        private IRenderableContent _Target; // Can be either a morphController or a mesh
+
+        // condition: all NodeBuilder objects must have the same root.
+        private readonly List<NodeBuilder> _Joints = new List<NodeBuilder>();
+
+        #endregion
+
+        #region API
+
+        public MESHBUILDER GetGeometryAsset() { return (_Target as IRenderableContent)?.GetGeometryAsset(); }
+
+        public NodeBuilder GetArmatureAsset() { return _Joints.Select(item => item.Root).Distinct().FirstOrDefault(); }
+
+        public void Setup(Scene dstScene, Schema2SceneBuilder context)
+        {
+            var skinnedMeshNode = dstScene.CreateNode();
+
+            var skinnedJoints = _Joints.Select(j => context.GetNode(j)).ToArray();
+
+            skinnedMeshNode.WithSkinBinding(skinnedJoints);
+
+            _Target.Setup(skinnedMeshNode, context);
+        }
+
+        #endregion
+    }
+
+    // We really have two options here: Either implement this here, or as a derived of IMeshBuilder<MaterialBuilder>
+
+    class MorphModifier : IRenderableContent // must be a child of a controller, and the parent of a mesh
+    {
+        #region data
+
+        private IRenderableContent _Target;
+
+        private readonly List<Animations.AnimatableProperty<float>> _MorphWeights = new List<Animations.AnimatableProperty<float>>();
+
+        #endregion
+
+        #region API
+
+        public MESHBUILDER GetGeometryAsset() => _Target?.GetGeometryAsset();
+
+        public void Setup(Node dstNode, Schema2SceneBuilder context)
+        {
+            _Target.Setup(dstNode, context);
+
+            // setup morphs here!
+        }
+
+        #endregion
+    }
+
+    class MeshContent : IRenderableContent
+    {
+        #region lifecycle
+
+        public MeshContent(MESHBUILDER mesh)
+        {
+            _Mesh = mesh;
+        }
+
+        #endregion
+
+        #region data
+
+        private MESHBUILDER _Mesh;
+
+        #endregion
+
+        #region API
+
+        public MESHBUILDER GetGeometryAsset() => _Mesh;
+
+        public void Setup(Node dstNode, Schema2SceneBuilder context)
+        {
+            dstNode.Mesh = context.GetMesh(_Mesh);
+        }
+
+        #endregion
+    }
+
+    class LightContent : IContent
+    {
+        public void Setup(Node dstNode, Schema2SceneBuilder context)
+        {
+            throw new NotImplementedException();
+        }
+    }
+
+    class CameraContent : IContent
+    {
+        public void Setup(Node dstNode, Schema2SceneBuilder context)
+        {
+            throw new NotImplementedException();
+        }
+    }
+}

+ 55 - 0
src/SharpGLTF.Toolkit/Scenes/InstanceBuilder.cs

@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace SharpGLTF.Scenes
+{
+    public class InstanceBuilder
+    {
+        #region lifecycle
+
+        internal InstanceBuilder(SceneBuilder parent)
+        {
+            _Parent = parent;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly SceneBuilder _Parent;
+
+        private IContentRoot _Content;
+
+        #endregion
+
+        #region public
+
+        internal IContentRoot Content
+        {
+            get => _Content;
+            set => _Content = value;
+        }
+
+        #endregion
+
+        #region API
+
+        internal Geometry.IMeshBuilder<Materials.MaterialBuilder> GetGeometryAsset()
+        {
+            return _Content?.GetGeometryAsset();
+        }
+
+        internal NodeBuilder GetArmatureAsset()
+        {
+            return _Content?.GetArmatureAsset();
+        }
+
+        internal void Setup(Schema2.Scene dstScene, Schema2SceneBuilder context)
+        {
+            _Content.Setup(dstScene, context);
+        }
+
+        #endregion
+    }
+}

+ 232 - 0
src/SharpGLTF.Toolkit/Scenes/NodeBuilder.cs

@@ -0,0 +1,232 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF.Scenes
+{
+    /// <summary>
+    /// Defines a node object within an armature.
+    /// </summary>
+    public class NodeBuilder
+    {
+        #region lifecycle
+
+        public NodeBuilder() { }
+
+        public NodeBuilder(string name) { Name = name; }
+
+        private NodeBuilder(NodeBuilder parent)
+        {
+            _Parent = parent;
+        }
+
+        #endregion
+
+        #region data
+
+        private readonly NodeBuilder _Parent;
+
+        private readonly List<NodeBuilder> _Children = new List<NodeBuilder>();
+
+        private Matrix4x4? _Matrix;
+
+        #endregion
+
+        #region properties - hierarchy
+
+        public String Name { get; set; }
+
+        public NodeBuilder Parent => _Parent;
+
+        public NodeBuilder Root => _Parent == null ? this : _Parent.Root;
+
+        public IReadOnlyList<NodeBuilder> Children => _Children;
+
+        #endregion
+
+        #region properties - transform
+
+        public bool HasAnimations => Scale?.Tracks.Count > 0 || Rotation?.Tracks.Count > 0 || Translation?.Tracks.Count > 0;
+
+        public Animations.AnimatableProperty<Vector3> Scale { get; private set; }
+
+        public Animations.AnimatableProperty<Quaternion> Rotation { get; private set; }
+
+        public Animations.AnimatableProperty<Vector3> Translation { get; private set; }
+
+        /// <summary>
+        /// Gets or sets the local transform <see cref="Matrix4x4"/> of this <see cref="NodeBuilder"/>.
+        /// </summary>
+        public Matrix4x4 LocalMatrix
+        {
+            get => Transforms.AffineTransform.Evaluate(_Matrix, Scale?.Value, Rotation?.Value, Translation?.Value);
+            set
+            {
+                if (value == Matrix4x4.Identity)
+                {
+                    _Matrix = null;
+                }
+                else
+                {
+                    _Matrix = value;
+                }
+
+                Scale = null;
+                Rotation = null;
+                Translation = null;
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the local Scale, Rotation and Translation of this <see cref="NodeBuilder"/>.
+        /// </summary>
+        public Transforms.AffineTransform LocalTransform
+        {
+            get => _Matrix.HasValue
+                ?
+                Transforms.AffineTransform.Create(_Matrix.Value)
+                :
+                Transforms.AffineTransform.Create(Translation?.Value, Rotation?.Value, Scale?.Value);
+            set
+            {
+                Guard.IsTrue(value.IsValid, nameof(value));
+
+                _Matrix = null;
+
+                if (value.Scale != Vector3.One)
+                {
+                    if (Scale == null) Scale = new Animations.AnimatableProperty<Vector3>();
+                    Scale.Value = value.Scale;
+                }
+
+                if (value.Rotation != Quaternion.Identity)
+                {
+                    if (Rotation == null) Rotation = new Animations.AnimatableProperty<Quaternion>();
+                    Rotation.Value = value.Rotation;
+                }
+
+                if (value.Translation != Vector3.Zero)
+                {
+                    if (Translation == null) Translation = new Animations.AnimatableProperty<Vector3>();
+                    Translation.Value = value.Scale;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the world transform <see cref="Matrix4x4"/> of this <see cref="NodeBuilder"/>.
+        /// </summary>
+        public Matrix4x4 WorldMatrix
+        {
+            get
+            {
+                var vs = this.Parent;
+                return vs == null ? LocalMatrix : Transforms.AffineTransform.LocalToWorld(vs.WorldMatrix, LocalMatrix);
+            }
+            set
+            {
+                var vs = this.Parent;
+                LocalMatrix = vs == null ? value : Transforms.AffineTransform.WorldToLocal(vs.WorldMatrix, value);
+            }
+        }
+
+        #endregion
+
+        #region API
+
+        public NodeBuilder CreateNode(string name = null)
+        {
+            var c = new NodeBuilder(this);
+            _Children.Add(c);
+            c.Name = name;
+            return c;
+        }
+
+        public Animations.AnimatableProperty<Vector3> UseScale()
+        {
+            if (Scale == null)
+            {
+                Scale = new Animations.AnimatableProperty<Vector3>();
+                Scale.Value = Vector3.One;
+            }
+
+            return Scale;
+        }
+
+        public Animations.CurveBuilder<Vector3> UseScale(string animationTrack)
+        {
+            return UseScale().UseTrackBuilder(animationTrack);
+        }
+
+        public Animations.AnimatableProperty<Quaternion> UseRotation()
+        {
+            if (Rotation == null)
+            {
+                Rotation = new Animations.AnimatableProperty<Quaternion>();
+                Rotation.Value = Quaternion.Identity;
+            }
+
+            return Rotation;
+        }
+
+        public Animations.CurveBuilder<Quaternion> UseRotation(string animationTrack)
+        {
+            return UseRotation().UseTrackBuilder(animationTrack);
+        }
+
+        public Animations.AnimatableProperty<Vector3> UseTranslation()
+        {
+            if (Translation == null)
+            {
+                Translation = new Animations.AnimatableProperty<Vector3>();
+                Translation.Value = Vector3.One;
+            }
+
+            return Translation;
+        }
+
+        public Animations.CurveBuilder<Vector3> UseTranslation(string animationTrack)
+        {
+            return UseTranslation().UseTrackBuilder(animationTrack);
+        }
+
+        public void SetScaleTrack(string track, Animations.ICurveSampler<Vector3> curve) { UseScale().SetTrack(track, curve); }
+
+        public void SetTranslationTrack(string track, Animations.ICurveSampler<Vector3> curve) { UseTranslation().SetTrack(track, curve); }
+
+        public void SetRotationTrack(string track, Animations.ICurveSampler<Quaternion> curve) { UseRotation().SetTrack(track, curve); }
+
+        public Transforms.AffineTransform GetLocalTransform(string animationTrack, float time)
+        {
+            if (animationTrack == null) return this.LocalTransform;
+
+            var scale = Scale?.GetValueAt(animationTrack, time);
+            var rotation = Rotation?.GetValueAt(animationTrack, time);
+            var translation = Translation?.GetValueAt(animationTrack, time);
+
+            return Transforms.AffineTransform.Create(translation, rotation, scale);
+        }
+
+        public Matrix4x4 GetWorldMatrix(string animationTrack, float time)
+        {
+            if (animationTrack == null) return this.WorldMatrix;
+
+            var vs = Parent;
+            var lm = GetLocalTransform(animationTrack, time).Matrix;
+            return vs == null ? lm : Transforms.AffineTransform.LocalToWorld(vs.GetWorldMatrix(animationTrack, time), lm);
+        }
+
+        #endregion
+
+        #region With* API
+
+        public NodeBuilder WithLocalTranslation(Vector3 translation)
+        {
+            this.UseTranslation().Value = translation;
+            return this;
+        }
+
+        #endregion
+    }
+}

+ 123 - 0
src/SharpGLTF.Toolkit/Scenes/SceneBuilder.Schema2.cs

@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Numerics;
+
+namespace SharpGLTF.Scenes
+{
+    using Schema2;
+
+    using MESHBUILDER = Geometry.IMeshBuilder<Materials.MaterialBuilder>;
+
+    class Schema2SceneBuilder
+    {
+        #region data
+
+        private readonly Dictionary<MESHBUILDER, Mesh> _Meshes = new Dictionary<MESHBUILDER, Mesh>();
+
+        private readonly Dictionary<NodeBuilder, Node> _Nodes = new Dictionary<NodeBuilder, Node>();
+
+        #endregion
+
+        #region API
+
+        public Mesh GetMesh(MESHBUILDER key) { return key == null ? null : _Meshes.TryGetValue(key, out Mesh val) ? val : null; }
+
+        public Node GetNode(NodeBuilder key) { return key == null ? null : _Nodes.TryGetValue(key, out Node val) ? val : null; }
+
+        public void AddScene(Scene dstScene, SceneBuilder srcScene)
+        {
+            // gather all MeshBuilder unique instances
+            // and group them by their vertex attribute layout.
+
+            var meshGroups = srcScene.Instances
+                .Select(item => item.GetGeometryAsset())
+                .Where(item => item != null)
+                .Distinct()
+                .ToList()
+                .GroupBy(item => item.GetType());
+
+            // create Schema2.Mesh collections for every gathered group.
+
+            foreach (var meshGroup in meshGroups)
+            {
+                var meshArray = meshGroup.ToArray();
+
+                var meshDst = dstScene.LogicalParent.CreateMeshes(meshArray);
+
+                for (int i = 0; i < meshArray.Length; ++i)
+                {
+                    _Meshes[meshArray[i]] = meshDst[i];
+                }
+            }
+
+            // gather all NodeBuilder unique armatures
+
+            var armatures = srcScene.Instances
+                .Select(item => item.GetArmatureAsset())
+                .Where(item => item != null)
+                .Select(item => item.Root)
+                .Distinct()
+                .ToList();
+
+            // create Schema2.Node trees for every armature
+
+            foreach (var armature in armatures)
+            {
+                CreateArmature(dstScene,  armature);
+            }
+
+            // process instances
+
+            foreach (var inst in srcScene.Instances)
+            {
+                inst.Setup(dstScene, this);
+            }
+        }
+
+        /// <summary>
+        /// Recursively converts all the <see cref="NodeBuilder"/> instances into <see cref="Schema2.Node"/> instances.
+        /// </summary>
+        /// <param name="container">The target <see cref="Schema2.Scene"/> or <see cref="Schema2.Node"/>.</param>
+        /// <param name="srcNode">The source <see cref="NodeBuilder"/> instance.</param>
+        private void CreateArmature(IVisualNodeContainer container, NodeBuilder srcNode)
+        {
+            var dstNode = container.CreateNode(srcNode.Name);
+            _Nodes[srcNode] = dstNode;
+
+            if (srcNode.HasAnimations)
+            {
+                dstNode.LocalTransform = srcNode.LocalTransform;
+
+                // Copies all the animations to the target node.
+                if (srcNode.Scale != null) foreach (var t in srcNode.Scale.Tracks) dstNode.WithScaleAnimation(t.Key, t.Value);
+                if (srcNode.Rotation != null) foreach (var t in srcNode.Rotation.Tracks) dstNode.WithRotationAnimation(t.Key, t.Value);
+                if (srcNode.Translation != null) foreach (var t in srcNode.Translation.Tracks) dstNode.WithTranslationAnimation(t.Key, t.Value);
+            }
+            else
+            {
+                dstNode.LocalMatrix = srcNode.LocalMatrix;
+            }
+
+            foreach (var c in srcNode.Children) CreateArmature(dstNode, c);
+        }
+
+        #endregion
+    }
+
+    public partial class SceneBuilder
+    {
+        public ModelRoot ToSchema2()
+        {
+            var dstModel = ModelRoot.CreateModel();
+
+            var dstScene = dstModel.UseScene(0);
+
+            var context = new Schema2SceneBuilder();
+            context.AddScene(dstScene, this);
+
+            return dstModel;
+        }
+    }
+}

+ 52 - 0
src/SharpGLTF.Toolkit/Scenes/SceneBuilder.cs

@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF.Scenes
+{
+    using MESHBUILDER = Geometry.IMeshBuilder<Materials.MaterialBuilder>;
+
+    public partial class SceneBuilder
+    {
+        #region data
+
+        private readonly List<InstanceBuilder> _Instances = new List<InstanceBuilder>();
+
+        #endregion
+
+        #region properties
+
+        public IReadOnlyList<InstanceBuilder> Instances => _Instances;
+
+        #endregion
+
+        #region API
+
+        public void AddMesh(MESHBUILDER mesh, Matrix4x4 transform)
+        {
+            var instance = new InstanceBuilder(this);
+            instance.Content = new StaticTransformer(mesh, transform);
+
+            _Instances.Add(instance);
+        }
+
+        public void AddMesh(MESHBUILDER mesh, NodeBuilder node)
+        {
+            var instance = new InstanceBuilder(this);
+            instance.Content = new NodeTransformer(mesh, node);
+
+            _Instances.Add(instance);
+        }
+
+        public void AddSkinnedMesh(MESHBUILDER mesh, params NodeBuilder[] joints)
+        {
+            var instance = new InstanceBuilder(this);
+            instance.Content = new SkinTransformer(mesh, joints);
+
+            _Instances.Add(instance);
+        }
+
+        #endregion
+    }
+}

+ 46 - 0
src/SharpGLTF.Toolkit/Scenes/readme.md

@@ -0,0 +1,46 @@
+# Toolkit Scene API
+
+#### Overview
+
+Creating scenes with the toolkit API is slightly different than creating them
+directly with the glTF Schema2 API.
+
+In the glTF Schema2 API, you create a `Scene`, then you add some `Node` children to the
+`Scene`, and then you fill some `Node` instances them with `Mesh` and `Skin` references.
+
+```c#
+scene = model.UseScene(0);
+var n1 = scene.CreateNode();
+n1.Mesh = ...
+var n2 = scene.CreateNode();
+n2.Mesh = ...
+var n3 = scene.CreateNode();
+n3.Mesh = ...
+n3.Skin = ...
+scene.SaveGLB("scene.glb");
+
+```
+
+The Toolkit API uses a more visual approach; you just add what you want to render
+and how you want to render it, so every AddMesh method adds an mesh instance to render.
+
+```c#
+scene = new SceneBuilder();
+scene.AddMesh(...);
+scene.AddMesh(...);
+scene.AddSkinnedMesh(...);
+scene.SaveGLB("scene.glb");
+```
+
+In order to have a hierarchical tree of nodes, you use the NodeBuilder object, with
+which you can create standalone nodes, or whole skeleton armatures.
+
+In this way, NodeBuilder armatures become just another asset, like a mesh or a material,
+and a scene is just a collection of instances to be rendered.
+
+When you save the scene, all assets are gathered and matched, including meshes, materials
+and nodes, and the appropiate glTF Schema2 Scenes and Nodes are created under the hood to
+match the SceneBuilder rendering intent.
+
+Additionally, `NodeBuilder` instances support defining animation curves, which are contained
+internally inside every `NodeBuilder` instance.

+ 54 - 3
src/SharpGLTF.Toolkit/Schema2/AnimationExtensions.cs

@@ -15,19 +15,70 @@ namespace SharpGLTF.Schema2
             return animation ?? root.CreateAnimation(name);
         }
 
+        public static Node WithScaleAnimation(this Node node, string animationName, Animations.ICurveSampler<Vector3> sampler)
+        {
+            if (sampler is Animations.IConvertibleCurve<Vector3> curve)
+            {
+                var animation = node.LogicalParent.UseAnimation(animationName);
+
+                var degree = curve.MaxDegree;
+                if (degree == 0) animation.CreateScaleChannel(node, curve.ToStepCurve(), false);
+                if (degree == 1) animation.CreateScaleChannel(node, curve.ToLinearCurve(), true);
+                if (degree == 3) animation.CreateScaleChannel(node, curve.ToSplineCurve());
+            }
+
+            return node;
+        }
+
+        public static Node WithTranslationAnimation(this Node node, string animationName, Animations.ICurveSampler<Vector3> sampler)
+        {
+            if (sampler is Animations.IConvertibleCurve<Vector3> curve)
+            {
+                var animation = node.LogicalParent.UseAnimation(animationName);
+
+                var degree = curve.MaxDegree;
+                if (degree == 0) animation.CreateTranslationChannel(node, curve.ToStepCurve(), false);
+                if (degree == 1) animation.CreateTranslationChannel(node, curve.ToLinearCurve(), true);
+                if (degree == 3) animation.CreateTranslationChannel(node, curve.ToSplineCurve());
+            }
+
+            return node;
+        }
+
+        public static Node WithRotationAnimation(this Node node, string animationName, Animations.ICurveSampler<Quaternion> sampler)
+        {
+            if (sampler is Animations.IConvertibleCurve<Quaternion> curve)
+            {
+                var animation = node.LogicalParent.UseAnimation(animationName);
+
+                var degree = curve.MaxDegree;
+                if (degree == 0) animation.CreateRotationChannel(node, curve.ToStepCurve(), false);
+                if (degree == 1) animation.CreateRotationChannel(node, curve.ToLinearCurve(), true);
+                if (degree == 3) animation.CreateRotationChannel(node, curve.ToSplineCurve());
+            }
+
+            return node;
+        }
+
         public static Node WithScaleAnimation(this Node node, string animationName, params (Single, Vector3)[] keyframes)
         {
-            return node.WithScaleAnimation(animationName, keyframes.ToDictionary(kvp => kvp.Item1, kvp => kvp.Item2));
+            var keys = keyframes.ToDictionary(kvp => kvp.Item1, kvp => kvp.Item2);
+
+            return node.WithScaleAnimation(animationName, keys);
         }
 
         public static Node WithRotationAnimation(this Node node, string animationName, params (Single, Quaternion)[] keyframes)
         {
-            return node.WithRotationAnimation(animationName, keyframes.ToDictionary(kvp => kvp.Item1, kvp => kvp.Item2));
+            var keys = keyframes.ToDictionary(kvp => kvp.Item1, kvp => kvp.Item2);
+
+            return node.WithRotationAnimation(animationName, keys);
         }
 
         public static Node WithTranslationAnimation(this Node node, string animationName, params (Single, Vector3)[] keyframes)
         {
-            return node.WithTranslationAnimation(animationName, keyframes.ToDictionary(kvp => kvp.Item1, kvp => kvp.Item2));
+            var keys = keyframes.ToDictionary(kvp => kvp.Item1, kvp => kvp.Item2);
+
+            return node.WithTranslationAnimation(animationName, keys);
         }
 
         public static Node WithScaleAnimation(this Node node, string animationName, IReadOnlyDictionary<Single, Vector3> keyframes)

+ 25 - 3
src/SharpGLTF.Toolkit/Schema2/MaterialExtensions.cs

@@ -163,13 +163,35 @@ namespace SharpGLTF.Schema2
 
         #region transfer API
 
+        public static Schema2.AlphaMode ToSchema2(this Materials.AlphaMode alpha)
+        {
+            switch (alpha)
+            {
+                case Materials.AlphaMode.BLEND: return Schema2.AlphaMode.BLEND;
+                case Materials.AlphaMode.MASK: return Schema2.AlphaMode.MASK;
+                case Materials.AlphaMode.OPAQUE: return Schema2.AlphaMode.OPAQUE;
+                default: throw new NotImplementedException(alpha.ToString());
+            }
+        }
+
+        public static Materials.AlphaMode ToToolkit(this Schema2.AlphaMode alpha)
+        {
+            switch (alpha)
+            {
+                case Schema2.AlphaMode.BLEND: return Materials.AlphaMode.BLEND;
+                case Schema2.AlphaMode.MASK: return Materials.AlphaMode.MASK;
+                case Schema2.AlphaMode.OPAQUE: return Materials.AlphaMode.OPAQUE;
+                default: throw new NotImplementedException(alpha.ToString());
+            }
+        }
+
         public static void CopyTo(this Material srcMaterial, Materials.MaterialBuilder dstMaterial)
         {
             Guard.NotNull(srcMaterial, nameof(srcMaterial));
             Guard.NotNull(dstMaterial, nameof(dstMaterial));
 
             dstMaterial.Name = srcMaterial.Name;
-            dstMaterial.AlphaMode = srcMaterial.Alpha;
+            dstMaterial.AlphaMode = srcMaterial.Alpha.ToToolkit();
             dstMaterial.AlphaCutoff = srcMaterial.AlphaCutoff;
             dstMaterial.DoubleSided = srcMaterial.DoubleSided;
 
@@ -188,7 +210,7 @@ namespace SharpGLTF.Schema2
                 dstMaterial = new Materials.MaterialBuilder(srcMaterial.Name).WithFallback(dstMaterial);
 
                 dstMaterial.Name = srcMaterial.Name;
-                dstMaterial.AlphaMode = srcMaterial.Alpha;
+                dstMaterial.AlphaMode = srcMaterial.Alpha.ToToolkit();
                 dstMaterial.AlphaCutoff = srcMaterial.AlphaCutoff;
                 dstMaterial.DoubleSided = srcMaterial.DoubleSided;
 
@@ -254,7 +276,7 @@ namespace SharpGLTF.Schema2
 
             srcMaterial.ValidateForSchema2();
 
-            dstMaterial.Alpha = srcMaterial.AlphaMode;
+            dstMaterial.Alpha = srcMaterial.AlphaMode.ToSchema2();
             dstMaterial.AlphaCutoff = srcMaterial.AlphaCutoff;
             dstMaterial.DoubleSided = srcMaterial.DoubleSided;
 

+ 58 - 68
src/SharpGLTF.Toolkit/Schema2/MeshExtensions.cs

@@ -14,50 +14,22 @@ namespace SharpGLTF.Schema2
     {
         #region meshes
 
-        public static Mesh CreateMesh<TvP, TvM, TvS>(this ModelRoot root, Geometry.MeshBuilder<Materials.MaterialBuilder, TvP, TvM, TvS> meshBuilder)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
+        public static Mesh CreateMesh(this ModelRoot root, IMeshBuilder<Materials.MaterialBuilder> mesh)
         {
-            return root.CreateMeshes(meshBuilder).First();
+            return root.CreateMeshes(mesh).First();
         }
 
-        public static Mesh CreateMesh<TvP, TvM, TvS>(this ModelRoot root, Geometry.MeshBuilder<Material, TvP, TvM, TvS> meshBuilder)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
+        public static Mesh CreateMesh<TMaterial>(this ModelRoot root, Func<TMaterial, Material> materialEvaluator, IMeshBuilder<TMaterial> mesh)
         {
-            return root.CreateMeshes(meshBuilder).First();
+            return root.CreateMeshes<TMaterial>(materialEvaluator, mesh).First();
         }
 
-        public static Mesh CreateMesh<TMaterial, TvP, TvM, TvS>(this ModelRoot root, Func<TMaterial, Material> materialEvaluator, Geometry.MeshBuilder<TMaterial, TvP, TvM, TvS> meshBuilder)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
-        {
-            return root.CreateMeshes(materialEvaluator, meshBuilder).First();
-        }
-
-        public static IReadOnlyList<Mesh> CreateMeshes<TvP, TvM, TvS>(this ModelRoot root, params Geometry.MeshBuilder<Material, TvP, TvM, TvS>[] meshBuilders)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
-        {
-            return root.CreateMeshes(m => m, meshBuilders);
-        }
-
-        public static IReadOnlyList<Mesh> CreateMeshes<TvP, TvM, TvS>(this ModelRoot root, params Geometry.MeshBuilder<Materials.MaterialBuilder, TvP, TvM, TvS>[] meshBuilders)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
+        public static IReadOnlyList<Mesh> CreateMeshes(this ModelRoot root, params IMeshBuilder<Materials.MaterialBuilder>[] meshBuilders)
         {
             return root.CreateMeshes(mb => root.CreateMaterial(mb), meshBuilders);
         }
 
-        public static IReadOnlyList<Mesh> CreateMeshes<TMaterial, TvP, TvM, TvS>(this ModelRoot root, Func<TMaterial, Material> materialEvaluator, params Geometry.MeshBuilder<TMaterial, TvP, TvM, TvS>[] meshBuilders)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
+        public static IReadOnlyList<Mesh> CreateMeshes<TMaterial>(this ModelRoot root, Func<TMaterial, Material> materialEvaluator, params IMeshBuilder<TMaterial>[] meshBuilders)
         {
             Guard.NotNull(root, nameof(root));
             Guard.NotNull(materialEvaluator, nameof(materialEvaluator));
@@ -73,7 +45,7 @@ namespace SharpGLTF.Schema2
                 .ToDictionary(m => m, m => materialEvaluator(m));
 
             // creates meshes and primitives using MemoryAccessors using a single, shared vertex and index buffer
-            var srcMeshes = Geometry.PackedMeshBuilder<TMaterial>
+            var srcMeshes = PackedMeshBuilder<TMaterial>
                 .PackMeshes(meshBuilders)
                 .ToList();
 
@@ -93,6 +65,35 @@ namespace SharpGLTF.Schema2
 
         #region accessors
 
+        public static MeshPrimitive WithIndicesAutomatic(this MeshPrimitive primitive, PrimitiveType primitiveType)
+        {
+            var root = primitive.LogicalParent.LogicalParent;
+
+            primitive.DrawPrimitiveType = primitiveType;
+            primitive.SetIndexAccessor(null);
+
+            return primitive;
+        }
+
+        public static MeshPrimitive WithIndicesAccessor(this MeshPrimitive primitive, PrimitiveType primitiveType, IReadOnlyList<Int32> values)
+        {
+            var root = primitive.LogicalParent.LogicalParent;
+
+            // create an index buffer and fill it
+            var view = root.UseBufferView(new Byte[4 * values.Count], 0, null, 0, BufferMode.ELEMENT_ARRAY_BUFFER);
+            var array = new IntegerArray(view.Content);
+            array.Fill(values);
+
+            var accessor = root.CreateAccessor();
+
+            accessor.SetIndexData(view, 0, values.Count, IndexEncodingType.UNSIGNED_INT);
+
+            primitive.DrawPrimitiveType = primitiveType;
+            primitive.SetIndexAccessor(accessor);
+
+            return primitive;
+        }
+
         public static MeshPrimitive WithVertexAccessor(this MeshPrimitive primitive, string attribute, IReadOnlyList<Single> values)
         {
             var root = primitive.LogicalParent.LogicalParent;
@@ -163,62 +164,51 @@ namespace SharpGLTF.Schema2
             return primitive;
         }
 
-        public static MeshPrimitive WithIndicesAutomatic(this MeshPrimitive primitive, PrimitiveType primitiveType)
+        public static MeshPrimitive WithVertexAccessors(this MeshPrimitive primitive, IReadOnlyList<VertexPosition> vertices)
         {
-            var root = primitive.LogicalParent.LogicalParent;
-
-            primitive.DrawPrimitiveType = primitiveType;
-            primitive.SetIndexAccessor(null);
+            var xvertices = vertices
+                .Select(item => new VertexBuilder<VertexPosition, VertexEmpty, VertexEmpty>(item))
+                .ToList();
 
-            return primitive;
+            return primitive.WithVertexAccessors(xvertices);
         }
 
-        public static MeshPrimitive WithIndicesAccessor(this MeshPrimitive primitive, PrimitiveType primitiveType, IReadOnlyList<Int32> values)
+        public static MeshPrimitive WithVertexAccessors(this MeshPrimitive primitive, IReadOnlyList<VertexPositionNormal> vertices)
         {
-            var root = primitive.LogicalParent.LogicalParent;
-
-            // create an index buffer and fill it
-            var view = root.UseBufferView(new Byte[4 * values.Count], 0, null, 0, BufferMode.ELEMENT_ARRAY_BUFFER);
-            var array = new IntegerArray(view.Content);
-            array.Fill(values);
-
-            var accessor = root.CreateAccessor();
-
-            accessor.SetIndexData(view, 0, values.Count, IndexEncodingType.UNSIGNED_INT);
-
-            primitive.DrawPrimitiveType = primitiveType;
-            primitive.SetIndexAccessor(accessor);
+            var xvertices = vertices
+                .Select(item => new VertexBuilder<VertexPositionNormal, VertexEmpty, VertexEmpty>(item))
+                .ToList();
 
-            return primitive;
+            return primitive.WithVertexAccessors(xvertices);
         }
 
-        public static MeshPrimitive WithVertexAccessors<TvP>(this MeshPrimitive primitive, IReadOnlyList<TvP> vertices)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
+        public static MeshPrimitive WithVertexAccessors<TvP, TvM>(this MeshPrimitive primitive, IReadOnlyList<(TvP, TvM)> vertices)
+            where TvP : struct, IVertexGeometry
+            where TvM : struct, IVertexMaterial
         {
             var xvertices = vertices
-                .Select(item => new Geometry.VertexBuilder<TvP, Geometry.VertexTypes.VertexEmpty, Geometry.VertexTypes.VertexEmpty>(item))
+                .Select(item => new VertexBuilder<TvP, TvM, VertexEmpty>(item.Item1, item.Item2))
                 .ToList();
 
             return primitive.WithVertexAccessors(xvertices);
         }
 
-        public static MeshPrimitive WithVertexAccessors<TvP, TvM>(this MeshPrimitive primitive, IReadOnlyList<(TvP, TvM)> vertices)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
+        public static MeshPrimitive WithVertexAccessors<TvP, TvM, TvS>(this MeshPrimitive primitive, IReadOnlyList<(TvP, TvM, TvS)> vertices)
+            where TvP : struct, IVertexGeometry
+            where TvM : struct, IVertexMaterial
+            where TvS : struct, IVertexSkinning
         {
             var xvertices = vertices
-                .Select(item => new Geometry.VertexBuilder<TvP, TvM, Geometry.VertexTypes.VertexEmpty>(item.Item1, item.Item2))
+                .Select(item => new VertexBuilder<TvP, TvM, TvS>(item.Item1, item.Item2, item.Item3))
                 .ToList();
 
             return primitive.WithVertexAccessors(xvertices);
         }
 
-        public static MeshPrimitive WithVertexAccessors<TvP, TvM, TvS>(this MeshPrimitive primitive, IReadOnlyList<Geometry.VertexBuilder<TvP, TvM, TvS>> vertices)
-            where TvP : struct, Geometry.VertexTypes.IVertexGeometry
-            where TvM : struct, Geometry.VertexTypes.IVertexMaterial
-            where TvS : struct, Geometry.VertexTypes.IVertexSkinning
+        public static MeshPrimitive WithVertexAccessors<TVertex>(this MeshPrimitive primitive, IReadOnlyList<TVertex> vertices)
+            where TVertex : IVertexBuilder
         {
-            var memAccessors = Geometry.VertexTypes.VertexUtils.CreateVertexMemoryAccessors(new[] { vertices }).First();
+            var memAccessors = VertexUtils.CreateVertexMemoryAccessors(new[] { vertices }).First();
 
             return primitive.WithVertexAccessors(memAccessors);
         }

+ 1 - 1
src/SharpGLTF.Toolkit/SharpGLTF.Toolkit.csproj

@@ -39,7 +39,7 @@
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>
-    <PackageReference Include="Microsoft.CodeQuality.Analyzers" Version="2.9.2" >
+    <PackageReference Include="Microsoft.CodeQuality.Analyzers" Version="2.9.3">
       <PrivateAssets>all</PrivateAssets>
       <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
     </PackageReference>

+ 146 - 15
tests/SharpGLTF.Tests/AnimationSamplingTests.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Numerics;
 using System.Text;
 
@@ -8,14 +9,138 @@ using NUnit.Framework;
 namespace SharpGLTF
 {
     [TestFixture]
-    [Category("Core")]
+    [Category("Core.Animations")]
     public class AnimationSamplingTests
     {
+        [TestCase(0, 0, 0, 1, 1, 1, 1, 0)]
+        [TestCase(0, 0, 0.1f, 5, 0.7f, 3, 1, 0)]
+        public void TestHermiteInterpolation1(float p1x, float p1y, float p2x, float p2y, float p3x, float p3y, float p4x, float p4y)
+        {
+            var p1 = new Vector2(p1x, p1y);
+            var p2 = new Vector2(p2x, p2y);
+            var p3 = new Vector2(p3x, p3y);
+            var p4 = new Vector2(p4x, p4y);
+
+            var ppp = new List<Vector2>();
+
+            for (float amount = 0; amount <= 1; amount += 0.01f)
+            {
+                var hermite = Animations.SamplerFactory.CreateHermitePointWeights(amount);
+
+                var p = Vector2.Zero;
+
+                p += p1 * hermite.Item1;
+                p += p4 * hermite.Item2;
+                p += (p2 - p1) * 4 * hermite.Item3;
+                p += (p4 - p3) * 4 * hermite.Item4;
+
+                ppp.Add(p);
+            }
+
+            // now lets calculate an arbitrary point and tangent
+
+            float k = 0.3f;
+
+            var hb = Animations.SamplerFactory.CreateHermitePointWeights(k);
+            var ht = Animations.SamplerFactory.CreateHermiteTangentWeights(k);
+
+            var pp = p1 * hb.Item1 + p4 * hb.Item2 + (p2 - p1) * 4 * hb.Item3 + (p4 - p3) * 4 * hb.Item4;
+            var pt = p1 * ht.Item1 + p4 * ht.Item2 + (p2 - p1) * 4 * ht.Item3 + (p4 - p3) * 4 * ht.Item4;
+
+            // plotting
+
+            var series1 = ppp.ToPointSeries();
+            var series2 = new[] { p1, p2, p3, p4 }.ToLineSeries();
+            var series3 = new[] { pp, pp + pt }.ToLineSeries();
+
+            new[] { series1, series2, series3 }.AttachToCurrentTest("plot.png");
+        }
+
+        [Test]
+        public void TestHermiteAsLinearInterpolation()
+        {
+            var p1 = new Vector2(1, 0);
+            var p2 = new Vector2(3, 1);
+            var t = p2 - p1;
+
+            var ppp = new List<Vector2>();
+
+            for (float amount = 0; amount <= 1; amount += 0.1f)
+            {
+                var hermite = Animations.SamplerFactory.CreateHermitePointWeights(amount);
+
+                var p = Vector2.Zero;
+
+                p += p1 * hermite.Item1;
+                p += p2 * hermite.Item2;
+                p += t * hermite.Item3;
+                p += t * hermite.Item4;
+
+                ppp.Add(p);
+            }
+
+            var series1 = ppp.ToPointSeries().WithLineType(Plotting.LineType.Star);
+
+            new[] { series1 }.AttachToCurrentTest("plot.png");
+        }
+
+        [Test]
+        public void TestHermiteAsSphericalInterpolation()
+        {
+            // given two quaternions, we must find a tangent quaternion so that the quaternion
+            // hermite interpolation gives roughly the same results as a plain spherical interpolation.
+
+            // reference implementation with matrices
+            var m1 = Matrix4x4.CreateFromAxisAngle(Vector3.UnitX, 1);            
+            var m2 = Matrix4x4.CreateFromAxisAngle(Vector3.UnitY, 2);            
+            var mt = Matrix4x4.Multiply(m2, Matrix4x4.Transpose(m1));            
+            var m2bis = Matrix4x4.Multiply(mt, m1); // roundtrip; M2 == M2BIS
+
+            // implementation with quaternions
+            var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1);
+            var q2 = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 2);
+            var qt = Quaternion.Concatenate(q2, Quaternion.Conjugate(q1));            
+            var q2bis = Quaternion.Concatenate(qt, q1); // roundtrip; Q2 == Q2BIS
+
+            NumericsAssert.AreEqual(qt, Animations.SamplerFactory.CreateTangent(q1, q2), 0.000001f);
+
+            var angles = new List<Vector2>();
+
+            for (float amount = 0; amount <= 1; amount += 0.025f)
+            {
+                // slerp interpolation
+                var sq = Quaternion.Normalize(Quaternion.Slerp(q1, q2, amount));
+
+                // hermite interpolation with a unit tangent
+                var hermite = Animations.SamplerFactory.CreateHermitePointWeights(amount);
+                var hq = default(Quaternion);
+                hq += q1 * hermite.Item1;
+                hq += q2 * hermite.Item2;
+                hq += qt * hermite.Item3;
+                hq += qt * hermite.Item4;
+                hq = Quaternion.Normalize(hq);
+                
+                // check
+                NumericsAssert.AreEqual(sq, hq, 0.1f);
+                NumericsAssert.AngleLessOrEqual(sq, hq, 0.22f);
+
+                // diff
+                var a = VectorsUtils.GetAngle(sq, hq) * 180.0f / 3.141592f;
+                angles.Add(new Vector2(amount, a));                          
+            }
+
+            angles.ToPointSeries()
+                .WithLineType(Plotting.LineType.Continuous)
+                .AttachToCurrentTest("plot.png");
+        
+        }
+
         private static (float, (Vector3, Vector3, Vector3))[] _TransAnim = new []
         {
-            (0.0f, (new Vector3(0, 0, 0), new Vector3(-1, 0, 0),new Vector3(0, 0, 0))),
-            (1.0f, (new Vector3(0, 0, 0), new Vector3(+1, 0, 0),new Vector3(0, 3, 0))),
-            (2.0f, (new Vector3(0, 0, 0), new Vector3(-1, 0, 0),new Vector3(0, 0, 0)))
+            (0.0f, (        Vector3.Zero, new Vector3(0, 0, 0),new Vector3(0, 0, 0))),
+            (1.0f, (new Vector3(0, 0, 0), new Vector3(1, 0, 0),new Vector3(0, 1, 0))),
+            (2.0f, (new Vector3(0, -1, 0), new Vector3(2, 0, 0),new Vector3(0, 0, 0))),
+            (3.0f, (new Vector3(0, 0, 0), new Vector3(3, 0, 0),       Vector3.Zero ))
         };
 
         private static (float, (Quaternion, Quaternion, Quaternion))[] _RotAnim = new[]
@@ -30,25 +155,31 @@ namespace SharpGLTF
         [Test]
         public void TestVector3CubicSplineSampling()
         {
-            var hermite = Transforms.AnimationSamplerFactory.Hermite(new Vector3(1, 0, 0), new Vector3(0, 2, 0), new Vector3(3, 0, 0), new Vector3(0, -2, 0), 0.5f);
+            var sampler = Animations.SamplerFactory.CreateSampler(_TransAnim);
+
+            var points = new List<Vector3>();
 
-            var sampler = Transforms.AnimationSamplerFactory.CreateCubicSamplerFunc(_TransAnim);
+            for(int i=0; i < 300; ++i)
+            {
+                var sample = sampler.GetPoint(((float)i) / 100.0f);
+                points.Add( sample );
+            }
 
-            var a = sampler(0);
-            var b = sampler(1);
-            var bc = sampler(1.5f);
-            var c = sampler(2);
+            points
+                .Select(p => new Vector2(p.X, p.Y))
+                .ToPointSeries()                
+                .AttachToCurrentTest("plot.png");            
         }
 
         [Test]
         public void TestQuaternionCubicSplineSampling()
         {
-            var sampler = Transforms.AnimationSamplerFactory.CreateCubicSamplerFunc(_RotAnim);
+            var sampler = Animations.SamplerFactory.CreateSampler(_RotAnim);
 
-            var a = sampler(0);
-            var b = sampler(1);
-            var bc = sampler(1.5f);
-            var c = sampler(2);
+            var a = sampler.GetPoint(0);
+            var b = sampler.GetPoint(1);
+            var bc = sampler.GetPoint(1.5f);
+            var c = sampler.GetPoint(2);
         }
 
     }

+ 166 - 0
tests/SharpGLTF.Tests/Animations/CurveBuilderTests.cs

@@ -0,0 +1,166 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+using NUnit.Framework;
+
+namespace SharpGLTF.Animations
+{
+    [Category("Toolkit.Animations")]
+    public class CurveBuilderTests
+    {
+        [Test]
+        public void CreateTranslationCurve1()
+        {
+            // Create a Vector3 curve
+
+            var curve = CurveFactory.CreateCurveBuilder<Vector3>();
+
+            curve
+                .WithPoint(0, 0, 0, 0);
+
+            curve
+                .WithPoint(1, 1, 1, 1)
+                .WithOutgoingTangent(1, 0, 4, 0);
+
+            curve
+                .WithPoint(2, 2, 1, 1)
+                .WithIncomingTangent(2, 0, -4, 0);
+
+            // convert and resample the curve to a linear and cubic curves.
+
+            var convertible = curve as IConvertibleCurve<Vector3>;
+            Assert.NotNull(convertible);
+
+            var linear = convertible.ToLinearCurve().Select(kvp => (kvp.Key, kvp.Value)).CreateSampler();
+            var spline = convertible.ToSplineCurve().Select(kvp => (kvp.Key, kvp.Value)).CreateSampler();
+
+            // check if both curves are roughly the same.
+
+            for (float t=0; t < 2; t+=0.01f)
+            {
+                var cc = curve.GetPoint(t);
+                var ls = linear.GetPoint(t);
+                var ss = spline.GetPoint(t);
+
+                NumericsAssert.AreEqual(cc, ls, 0.002f);
+                NumericsAssert.AreEqual(cc, ss, 0.002f);
+                NumericsAssert.AreEqual(ls, ss, 0.002f);
+            }
+
+            // plot the curve.
+
+            convertible
+                .ToLinearCurve()
+                .Select(kvp => new Vector2(kvp.Value.X, kvp.Value.Y))
+                .ToPointSeries()
+                .WithLineType(Plotting.LineType.Continuous)
+                .AttachToCurrentTest("plot.png");
+        }
+
+        [Test]
+        public void CreateRotationCurve1()
+        {
+            // Create a Quaternion curve
+
+            var curve = CurveFactory.CreateCurveBuilder<Quaternion>();
+
+            curve
+                .WithPoint(0, Quaternion.Identity);
+
+            curve
+                .WithPoint(1, Quaternion.CreateFromAxisAngle(Vector3.UnitX,1) )
+                .WithOutgoingTangent(1, Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1) );
+
+            curve
+                .WithPoint(2, Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1) )
+                .WithIncomingTangent(2, Quaternion.CreateFromAxisAngle(Vector3.UnitX, -1) );
+
+            // convert and resample the curve to a linear and cubic curves.
+
+            var convertible = curve as IConvertibleCurve<Quaternion>;
+            Assert.NotNull(convertible);
+
+            var linear = convertible.ToLinearCurve().Select(kvp => (kvp.Key, kvp.Value)).CreateSampler();
+            var spline = convertible.ToSplineCurve().Select(kvp => (kvp.Key, kvp.Value)).CreateSampler();
+
+            // check if both curves are roughly the same.
+
+            for (float t = 0; t < 2; t += 0.01f)
+            {
+                var cc = curve.GetPoint(t);
+                var ls = linear.GetPoint(t);
+                var ss = spline.GetPoint(t);
+
+                NumericsAssert.AreEqual(cc, ss, 0.05f);
+                NumericsAssert.AreEqual(cc, ls, 0.05f);
+                NumericsAssert.AreEqual(ls, ss, 0.05f);
+            }
+
+            // plot the curve.
+
+            convertible
+                .ToLinearCurve()
+                .Select(kvp => new Vector2(kvp.Key, kvp.Value.W))
+                .ToPointSeries()
+                .WithLineType(Plotting.LineType.Continuous)
+                .AttachToCurrentTest("plot.png");
+        }
+
+        [Test]
+        public void CreateMorphCurve1()
+        {
+            // Create a Quaternion curve
+
+            var curve = CurveFactory.CreateCurveBuilder<Single[]>();
+
+            curve
+                .WithPoint(0, 0f, 0f);
+
+            curve
+                .WithPoint(1, 1f, 1f)
+                .WithOutgoingTangent(1, 0f, 4f);
+
+            curve
+                .WithPoint(2, 2f, 1f)
+                .WithIncomingTangent(2, 0f, -4f);
+
+            // convert and resample the curve to a linear and cubic curves.
+
+            var convertible = curve as IConvertibleCurve<Single[]>;
+            Assert.NotNull(convertible);
+
+            var linear = convertible.ToLinearCurve().Select(kvp => (kvp.Key, kvp.Value)).CreateSampler();
+            var spline = convertible.ToSplineCurve().Select(kvp => (kvp.Key, kvp.Value)).CreateSampler();
+
+            // check if both curves are roughly the same.
+
+            for (float t = 0; t < 2; t += 0.01f)
+            {
+                var cc = curve.GetPoint(t);
+                var ls = linear.GetPoint(t);
+                var ss = spline.GetPoint(t);
+
+                Assert.AreEqual(cc[0], ls[0], 0.02f);
+                Assert.AreEqual(cc[1], ls[1], 0.02f);
+
+                Assert.AreEqual(cc[0], ss[0], 0.02f);
+                Assert.AreEqual(cc[1], ss[1], 0.02f);
+
+                Assert.AreEqual(ls[0], ss[0], 0.02f);
+                Assert.AreEqual(ls[1], ss[1], 0.02f);
+            }
+
+            // plot the curve.
+
+            convertible
+                .ToLinearCurve()
+                .Select(kvp => new Vector2(kvp.Value[0], kvp.Value[1]))
+                .ToPointSeries()
+                .WithLineType(Plotting.LineType.Continuous)
+                .AttachToCurrentTest("plot.png");
+        }
+    }
+}

+ 26 - 2
tests/SharpGLTF.Tests/Geometry/MeshBuilderTests.cs

@@ -1,10 +1,9 @@
 using System;
-using System.Collections.Generic;
 using System.Linq;
 using System.Numerics;
-using System.Text;
 
 using NUnit.Framework;
+
 using SharpGLTF.Geometry.VertexTypes;
 
 namespace SharpGLTF.Geometry
@@ -98,5 +97,30 @@ namespace SharpGLTF.Geometry
             Assert.AreEqual(1, TriangleCounter());
         }
 
+        [Test]
+        public void CreateMeshInSanitizedMode()
+        {
+            var mesh = VERTEX2.CreateCompatibleMesh();
+
+            mesh.VertexPreprocessor.SetSanitizerPreprocessors();
+
+            var prim = mesh.UsePrimitive(Materials.MaterialBuilder.CreateDefault(), 1);
+
+            var p = new VertexPositionNormal(Vector3.UnitX, new Vector3(float.NaN));
+            var m = new VertexColor1Texture1(Vector4.One * 7, new Vector2(float.NaN));
+            var s = new VertexJoints8x4((0, 2), (1, 7), (2, 6), (3, 5));
+
+            var v1 = new VERTEX2(p, m, s);
+            var v1Idx = prim.AddPoint(new VERTEX2(p, m, s));
+            var v1Bis = prim.Vertices[v1Idx];
+
+            NumericsAssert.AreEqual(v1Bis.Geometry.Position, Vector3.UnitX);
+            NumericsAssert.AreEqual(v1Bis.Geometry.Normal, Vector3.UnitX);
+            NumericsAssert.AreEqual(v1Bis.Material.Color, Vector4.One);
+            NumericsAssert.AreEqual(v1Bis.Material.TexCoord, Vector2.Zero);
+            NumericsAssert.AreEqual(v1Bis.Skinning.Joints, new Vector4(1, 2, 3, 0));
+            NumericsAssert.AreEqual(v1Bis.Skinning.Weights, new Vector4(7, 6, 5, 2) / (7f + 6f + 5f + 2f));
+        }
+
     }
 }

+ 271 - 0
tests/SharpGLTF.Tests/Geometry/Parametric/SolidMeshUtils.cs

@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+
+using SharpGLTF.Geometry;
+using SharpGLTF.Geometry.VertexTypes;
+
+namespace SharpGLTF.Geometry.Parametric
+{
+    using VERTEX = VertexBuilder<VertexPositionNormal, VertexColor1Texture1, VertexEmpty>;
+
+    interface IParametricShape<TMaterial>
+    {
+        void AddTo(IMeshBuilder<TMaterial> meshBuilder, Matrix4x4 xform);
+    }
+
+    class Cube<TMaterial> : IParametricShape<TMaterial>
+    {
+        #region lifecycle
+
+        public Cube(TMaterial material)
+        {
+            _Front = _Back = _Left = _Right = _Top = _Bottom = material;
+        }
+
+        #endregion
+
+        #region data
+
+        private Vector3 _Size = Vector3.One;
+
+        private TMaterial _Front;
+        private TMaterial _Back;
+
+        private TMaterial _Left;
+        private TMaterial _Right;
+
+        private TMaterial _Top;
+        private TMaterial _Bottom;
+
+        #endregion
+
+        #region API
+
+        public void AddTo(IMeshBuilder<TMaterial> meshBuilder, Matrix4x4 xform)
+        {
+            var x = Vector3.UnitX * _Size.X * 0.5f;
+            var y = Vector3.UnitY * _Size.Y * 0.5f;
+            var z = Vector3.UnitZ * _Size.Z * 0.5f;
+
+            _AddCubeFace(meshBuilder.UsePrimitive(_Right), x, y, z, xform);
+            _AddCubeFace(meshBuilder.UsePrimitive(_Left), -x, z, y, xform);
+
+            _AddCubeFace(meshBuilder.UsePrimitive(_Top), y, z, x, xform);
+            _AddCubeFace(meshBuilder.UsePrimitive(_Bottom), -y, x, z, xform);
+
+            _AddCubeFace(meshBuilder.UsePrimitive(_Front), z, x, y, xform);
+            _AddCubeFace(meshBuilder.UsePrimitive(_Back), -z, y, x, xform);
+        }
+
+        private static void _AddCubeFace(IPrimitiveBuilder primitiveBuilder, Vector3 origin, Vector3 axisX, Vector3 axisY, Matrix4x4 xform)
+        {
+            var p1 = Vector3.Transform(origin - axisX - axisY, xform);
+            var p2 = Vector3.Transform(origin + axisX - axisY, xform);
+            var p3 = Vector3.Transform(origin + axisX + axisY, xform);
+            var p4 = Vector3.Transform(origin - axisX + axisY, xform);
+            var n = Vector3.Normalize(Vector3.TransformNormal(origin, xform));
+
+            primitiveBuilder.AddConvexPolygon
+                (
+                new VERTEX( (p1, n), (Vector4.One, Vector2.Zero)  ),
+                new VERTEX( (p2, n), (Vector4.One, Vector2.UnitX) ),
+                new VERTEX( (p3, n), (Vector4.One, Vector2.One)   ),
+                new VERTEX( (p4, n), (Vector4.One, Vector2.UnitY) )
+                );
+        }
+
+        public MeshBuilder<TMaterial, VertexPositionNormal, VertexColor1Texture1, VertexEmpty> ToMesh(Matrix4x4 xform)
+        {
+            var mesh = new MeshBuilder<TMaterial, VertexPositionNormal, VertexColor1Texture1, VertexEmpty>();
+
+            AddTo(mesh, xform);
+
+            return mesh;
+        }
+
+        #endregion
+    }
+
+    class IcoSphere<TMaterial> : IParametricShape<TMaterial>
+    {
+        #region lifecycle
+
+        public IcoSphere(TMaterial material, float radius = 0.5f)
+        {
+            _Material = material;
+            _Radius = radius;
+        }
+        
+        #endregion
+
+        #region data
+
+        private float _Radius = 0.5f;
+        private TMaterial _Material;
+        private int _Subdivision = 3;
+
+        #endregion
+
+        #region API        
+
+        public void AddTo(IMeshBuilder<TMaterial> meshBuilder, Matrix4x4 xform)
+        {
+            // http://blog.andreaskahler.com/2009/06/creating-icosphere-mesh-in-code.html
+
+            var t = 1 + (float)(Math.Sqrt(5.0) / 2);
+
+            var v0 = new Vector3(-1, t, 0) * _Radius;
+            var v1 = new Vector3(1, t, 0) * _Radius;
+            var v2 = new Vector3(-1, -t, 0) * _Radius;
+            var v3 = new Vector3(1, -t, 0) * _Radius;
+
+            var v4 = new Vector3(0, -1, t) * _Radius;
+            var v5 = new Vector3(0, 1, t) * _Radius;
+            var v6 = new Vector3(0, -1, -t) * _Radius;
+            var v7 = new Vector3(0, 1, -t) * _Radius;
+
+            var v8 = new Vector3(t, 0, -1) * _Radius;
+            var v9 = new Vector3(t, 0, 1) * _Radius;
+            var v10 = new Vector3(-t, 0, -1) * _Radius;
+            var v11 = new Vector3(-t, 0, 1) * _Radius;
+
+            var prim = meshBuilder.UsePrimitive(_Material);
+
+            // 5 faces around point 0
+            _AddSphereFace(prim, xform, v0, v11, v5, _Subdivision);
+            _AddSphereFace(prim, xform, v0, v5, v1, _Subdivision);
+            _AddSphereFace(prim, xform, v0, v1, v7, _Subdivision);
+            _AddSphereFace(prim, xform, v0, v7, v10, _Subdivision);
+            _AddSphereFace(prim, xform, v0, v10, v11, _Subdivision);
+
+            // 5 adjacent faces
+            _AddSphereFace(prim, xform, v1, v5, v9, _Subdivision);
+            _AddSphereFace(prim, xform, v5, v11, v4, _Subdivision);
+            _AddSphereFace(prim, xform, v11, v10, v2, _Subdivision);
+            _AddSphereFace(prim, xform, v10, v7, v6, _Subdivision);
+            _AddSphereFace(prim, xform, v7, v1, v8, _Subdivision);
+
+            // 5 faces around point 3
+            _AddSphereFace(prim, xform, v3, v9, v4, _Subdivision);
+            _AddSphereFace(prim, xform, v3, v4, v2, _Subdivision);
+            _AddSphereFace(prim, xform, v3, v2, v6, _Subdivision);
+            _AddSphereFace(prim, xform, v3, v6, v8, _Subdivision);
+            _AddSphereFace(prim, xform, v3, v8, v9, _Subdivision);
+
+            // 5 adjacent faces
+            _AddSphereFace(prim, xform, v4, v9, v5, _Subdivision);
+            _AddSphereFace(prim, xform, v2, v4, v11, _Subdivision);
+            _AddSphereFace(prim, xform, v6, v2, v10, _Subdivision);
+            _AddSphereFace(prim, xform, v8, v6, v7, _Subdivision);
+            _AddSphereFace(prim, xform, v9, v8, v1, _Subdivision);
+        }
+
+        private static void _AddSphereFace(IPrimitiveBuilder primitiveBuilder, Matrix4x4 xform, Vector3 a, Vector3 b, Vector3 c, int iterations = 0)
+        {
+            if (iterations <= 0)
+            {
+                var tt = (a + b + c) / 3.0f;
+
+                var aa = _CreateVertex(a, xform);
+                var bb = _CreateVertex(b, xform);
+                var cc = _CreateVertex(c, xform);
+                primitiveBuilder.AddTriangle(aa, bb, cc);
+                return;
+            }
+
+            --iterations;
+
+            var ab = Vector3.Normalize(a + b) * a.Length();
+            var bc = Vector3.Normalize(b + c) * b.Length();
+            var ca = Vector3.Normalize(c + a) * c.Length();
+
+            // central triangle
+            _AddSphereFace(primitiveBuilder, xform, ab, bc, ca, iterations);
+
+            // vertex triangles
+            _AddSphereFace(primitiveBuilder, xform, a, ab, ca, iterations);
+            _AddSphereFace(primitiveBuilder, xform, b, bc, ab, iterations);
+            _AddSphereFace(primitiveBuilder, xform, c, ca, bc, iterations);
+        }
+
+        private static VERTEX _CreateVertex(Vector3 position, Matrix4x4 xform)
+        {
+            var v = new VERTEX();
+
+            v.Geometry.Position = Vector3.Transform(position, xform);
+            v.Geometry.Normal = Vector3.Normalize(Vector3.TransformNormal(position, xform));
+            v.Material.Color = Vector4.One;
+            v.Material.TexCoord = Vector2.Zero;
+
+            return v;
+        }
+
+        #endregion
+    }
+
+    static class SolidMeshUtils
+    {
+        public static void AddCube<TMaterial>(this IMeshBuilder<TMaterial> meshBuilder, TMaterial material, Matrix4x4 xform)
+        {
+            var cube = new Cube<TMaterial>(material);
+
+            cube.AddTo(meshBuilder, xform);
+        }
+
+        public static void AddSphere<TMaterial>(this IMeshBuilder<TMaterial> meshBuilder, TMaterial material, Single radius, Matrix4x4 xform)
+        {
+            var sphere = new IcoSphere<TMaterial>(material, radius);
+
+            sphere.AddTo(meshBuilder, xform);
+        }
+
+        public static MeshBuilder<VertexPosition, VertexTexture1> CreateTerrainMesh(int width, int length, Func<int,int,float> heightFunction, string terrainColorImagePath)
+        {
+            // we create a new material to use with the terrain mesh
+            var material = new Materials.MaterialBuilder("TerrainMaterial")
+                .WithChannelImage(Materials.KnownChannels.BaseColor, terrainColorImagePath);
+
+            // we create a MeshBuilder
+            var terrainMesh = new MeshBuilder<VertexPosition, VertexTexture1>("terrain");
+
+            var texScale = new Vector2(width, length);
+
+            // fill the MeshBuilder with quads using the heightFunction.
+            for (int y = 1; y < length; ++y)
+            {
+                for (int x = 1; x < width; ++x)
+                {
+                    // quad vertex positions
+
+                    var a = new Vector3(x - 1, heightFunction(x - 1, y + 0), y + 0);
+                    var b = new Vector3(x + 0, heightFunction(x + 0, y + 0), y + 0);
+                    var c = new Vector3(x + 0, heightFunction(x + 0, y - 1), y - 1);
+                    var d = new Vector3(x - 1, heightFunction(x - 1, y - 1), y - 1);
+
+                    // quad UV coordinates
+
+                    var at = new Vector2(a.X, a.Z) / texScale;
+                    var bt = new Vector2(b.X, b.Z) / texScale;
+                    var ct = new Vector2(c.X, c.Z) / texScale;
+                    var dt = new Vector2(d.X, d.Z) / texScale;
+
+                    terrainMesh
+                        .UsePrimitive(material)
+                        .AddConvexPolygon
+                        (
+                            (a, at),
+                            (b, bt),
+                            (c, ct),
+                            (d, dt)
+                        );
+                }
+            }
+
+            terrainMesh.Validate();
+
+            return terrainMesh;
+        }
+    }
+}

+ 1 - 1
tests/SharpGLTF.Tests/Geometry/VertexTypes/JointWeightPairTests.cs

@@ -11,7 +11,7 @@ namespace SharpGLTF.Geometry.VertexTypes
     public class JointWeightPairTests
     {
         [Test]
-        public void TestSorting()
+        public void TestJointWeightSorting()
         {
             var pairs = new[]
             {

+ 7 - 5
tests/SharpGLTF.Tests/Geometry/VertexTypes/VertexSkinningTests.cs

@@ -11,8 +11,9 @@ namespace SharpGLTF.Geometry.VertexTypes
     public class VertexSkinningTests
     {
         [Test]
-        public void TestCloneAs()
+        public void TestVertexSkinningDowngradeFrom8To4Joints()
         {
+            // vertex with 5 bindings
             var v8 = new VertexJoints8x8();
             v8.SetJointBinding(0, 1, 0.2f);
             v8.SetJointBinding(1, 2, 0.15f);
@@ -20,6 +21,7 @@ namespace SharpGLTF.Geometry.VertexTypes
             v8.SetJointBinding(3, 4, 0.10f);
             v8.SetJointBinding(4, 5, 0.30f);
 
+            // we downgrade to 4 bindings; remaining bindings should be interpolated to keep weighting 1.
             var v4 = v8.ConvertTo<VertexJoints8x4>();
 
             Assert.AreEqual(5, v4.GetJointBinding(0).Joint);
@@ -27,10 +29,10 @@ namespace SharpGLTF.Geometry.VertexTypes
             Assert.AreEqual(1, v4.GetJointBinding(2).Joint);
             Assert.AreEqual(2, v4.GetJointBinding(3).Joint);
 
-            Assert.AreEqual(0.333333f, v4.GetJointBinding(0).Weight, 0.01f);
-            Assert.AreEqual(0.277777f, v4.GetJointBinding(1).Weight, 0.01f);
-            Assert.AreEqual(0.222222f, v4.GetJointBinding(2).Weight, 0.01f);
-            Assert.AreEqual(0.166666f, v4.GetJointBinding(3).Weight, 0.01f);
+            Assert.AreEqual(0.333333f, v4.GetJointBinding(0).Weight, 0.0001f);
+            Assert.AreEqual(0.277777f, v4.GetJointBinding(1).Weight, 0.0001f);
+            Assert.AreEqual(0.222222f, v4.GetJointBinding(2).Weight, 0.0001f);
+            Assert.AreEqual(0.166666f, v4.GetJointBinding(3).Weight, 0.0001f);
         }
     }
 }

+ 4 - 4
tests/SharpGLTF.Tests/Memory/MemoryArrayTests.cs

@@ -46,11 +46,11 @@ namespace SharpGLTF.Memory
 
             var v4n = new Vector4Array(bytes, 0, Schema2.EncodingType.UNSIGNED_BYTE, true);
             v4n[1] = v1;
-            VectorUtils.AreEqual(v4n[1], v1, 0.1f);
+            NumericsAssert.AreEqual(v4n[1], v1, 0.1f);
 
             var v4u = new Vector4Array(bytes, 0, Schema2.EncodingType.UNSIGNED_BYTE, false);
             v4u[1] = v2;
-            VectorUtils.AreEqual(v4u[1], v2);
+            NumericsAssert.AreEqual(v4u[1], v2);
         }
 
         [Test]
@@ -65,11 +65,11 @@ namespace SharpGLTF.Memory
             var v4u = new Vector4Array(bytes, 4, 5, 8, Schema2.EncodingType.UNSIGNED_BYTE, false);
 
             v4n[1] = v1;
-            VectorUtils.AreEqual(v4n[1], v1, 0.1f);
+            NumericsAssert.AreEqual(v4n[1], v1, 0.1f);
 
             
             v4u[1] = v2;
-            VectorUtils.AreEqual(v4u[1], v2);
+            NumericsAssert.AreEqual(v4u[1], v2);
         }
 
         [Test]

+ 272 - 0
tests/SharpGLTF.Tests/NumericsAssert.cs

@@ -0,0 +1,272 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using System.Text;
+
+using NUnit.Framework;
+
+namespace SharpGLTF
+{
+    static class NumericsAssert
+    {
+        public static void IsFinite(Vector2 vector)
+        {
+            Assert.IsTrue(float.IsFinite(vector.X), "X");
+            Assert.IsTrue(float.IsFinite(vector.Y), "Y");
+        }
+
+        public static void IsFinite(Vector3 vector)
+        {
+            Assert.IsTrue(float.IsFinite(vector.X), "X");
+            Assert.IsTrue(float.IsFinite(vector.Y), "Y");
+            Assert.IsTrue(float.IsFinite(vector.Z), "Z");
+        }
+
+        public static void IsFinite(Vector4 vector)
+        {
+            Assert.IsTrue(float.IsFinite(vector.X), "X");
+            Assert.IsTrue(float.IsFinite(vector.Y), "Y");
+            Assert.IsTrue(float.IsFinite(vector.Z), "Z");
+            Assert.IsTrue(float.IsFinite(vector.W), "W");
+        }
+
+        public static void IsFinite(Quaternion quaternion)
+        {
+            Assert.IsTrue(float.IsFinite(quaternion.X), "X");
+            Assert.IsTrue(float.IsFinite(quaternion.Y), "Y");
+            Assert.IsTrue(float.IsFinite(quaternion.Z), "Z");
+            Assert.IsTrue(float.IsFinite(quaternion.W), "W");
+        }
+
+        public static void IsFinite(Matrix4x4 matrix)
+        {
+            Assert.IsTrue(float.IsFinite(matrix.M11), "M11");
+            Assert.IsTrue(float.IsFinite(matrix.M12), "M12");
+            Assert.IsTrue(float.IsFinite(matrix.M13), "M13");
+            Assert.IsTrue(float.IsFinite(matrix.M14), "M14");
+
+            Assert.IsTrue(float.IsFinite(matrix.M21), "M21");
+            Assert.IsTrue(float.IsFinite(matrix.M22), "M22");
+            Assert.IsTrue(float.IsFinite(matrix.M23), "M23");
+            Assert.IsTrue(float.IsFinite(matrix.M24), "M24");
+
+            Assert.IsTrue(float.IsFinite(matrix.M31), "M31");
+            Assert.IsTrue(float.IsFinite(matrix.M32), "M32");
+            Assert.IsTrue(float.IsFinite(matrix.M33), "M33");
+            Assert.IsTrue(float.IsFinite(matrix.M34), "M34");
+
+            Assert.IsTrue(float.IsFinite(matrix.M41), "M41");
+            Assert.IsTrue(float.IsFinite(matrix.M42), "M42");
+            Assert.IsTrue(float.IsFinite(matrix.M43), "M43");
+            Assert.IsTrue(float.IsFinite(matrix.M44), "M44");
+        }
+
+        public static void AreEqual(Vector2 expected, Vector2 actual, double tolerance = 0)
+        {
+            Assert.AreEqual(expected.X, actual.X, tolerance, "X");
+            Assert.AreEqual(expected.Y, actual.Y, tolerance, "Y");
+        }
+
+        public static void AreEqual(Vector3 expected, Vector3 actual, double tolerance = 0)
+        {
+            Assert.AreEqual(expected.X, actual.X, tolerance, "X");
+            Assert.AreEqual(expected.Y, actual.Y, tolerance, "Y");
+            Assert.AreEqual(expected.Z, actual.Z, tolerance, "Z");
+        }
+
+        public static void AreEqual(Vector4 expected, Vector4 actual, double tolerance = 0)
+        {
+            Assert.AreEqual(expected.X, actual.X, tolerance, "X");
+            Assert.AreEqual(expected.Y, actual.Y, tolerance, "Y");
+            Assert.AreEqual(expected.Z, actual.Z, tolerance, "Z");
+            Assert.AreEqual(expected.W, actual.W, tolerance, "W");
+        }
+
+        public static void AreEqual(Quaternion expected, Quaternion actual, double tolerance = 0)
+        {
+            Assert.AreEqual(expected.X, actual.X, tolerance, "X");
+            Assert.AreEqual(expected.Y, actual.Y, tolerance, "Y");
+            Assert.AreEqual(expected.Z, actual.Z, tolerance, "Z");
+            Assert.AreEqual(expected.W, actual.W, tolerance, "W");
+        }
+
+        public static void AreEqual(Matrix4x4 expected, Matrix4x4 actual, double delta = 0)
+        {
+            Assert.AreEqual(expected.M11, actual.M11, delta, "M11");
+            Assert.AreEqual(expected.M12, actual.M12, delta, "M12");
+            Assert.AreEqual(expected.M13, actual.M13, delta, "M13");
+            Assert.AreEqual(expected.M14, actual.M14, delta, "M14");
+
+            Assert.AreEqual(expected.M21, actual.M21, delta, "M21");
+            Assert.AreEqual(expected.M22, actual.M22, delta, "M22");
+            Assert.AreEqual(expected.M23, actual.M23, delta, "M23");
+            Assert.AreEqual(expected.M24, actual.M24, delta, "M24");
+
+            Assert.AreEqual(expected.M31, actual.M31, delta, "M31");
+            Assert.AreEqual(expected.M32, actual.M32, delta, "M32");
+            Assert.AreEqual(expected.M33, actual.M33, delta, "M33");
+            Assert.AreEqual(expected.M34, actual.M34, delta, "M34");
+
+            Assert.AreEqual(expected.M41, actual.M41, delta, "M41");
+            Assert.AreEqual(expected.M42, actual.M42, delta, "M42");
+            Assert.AreEqual(expected.M43, actual.M43, delta, "M43");
+            Assert.AreEqual(expected.M44, actual.M44, delta, "M44");
+        }
+
+        public static void IsInvertible(Matrix4x4 matrix)
+        {
+            Assert.IsTrue(Matrix4x4.Invert(matrix, out Matrix4x4 inverted));
+        }
+
+        public static void IsNormalized(Vector2 vector, double delta = 0)
+        {
+            var lenSquared = vector.X * vector.X + vector.Y * vector.Y;
+
+            Assert.AreEqual(1, lenSquared, delta * delta, "Length");
+        }
+
+        public static void IsNormalized(Vector3 vector, double delta = 0)
+        {
+            var lenSquared = vector.X * vector.X + vector.Y * vector.Y + vector.Z * vector.Z;
+
+            Assert.AreEqual(1, lenSquared, delta * delta, "Length");
+        }
+
+        public static void IsNormalized(Vector4 vector, double delta = 0)
+        {
+            var lenSquared = vector.X * vector.X + vector.Y * vector.Y + vector.Z * vector.Z + vector.W * vector.W;
+
+            Assert.AreEqual(1, lenSquared, delta * delta, "Length");
+        }
+
+        public static void IsNormalized(Quaternion vector, double delta = 0)
+        {
+            var lenSquared = vector.X * vector.X + vector.Y * vector.Y + vector.Z * vector.Z + vector.W * vector.W;
+
+            Assert.AreEqual(1, lenSquared, delta * delta, "Length");
+        }
+
+        public static void InRange(Vector2 value, Vector2 min, Vector2 max)
+        {
+            GreaterOrEqual(value, min);
+            LessOrEqual(value, max);
+        }
+
+        public static void InRange(Vector3 value, Vector3 min, Vector3 max)
+        {
+            GreaterOrEqual(value, min);
+            LessOrEqual(value, max);
+        }
+
+        public static void InRange(Vector4 value, Vector4 min, Vector4 max)
+        {
+            GreaterOrEqual(value, min);
+            LessOrEqual(value, max);
+        }
+
+        public static void Less(Vector2 arg1, Vector2 arg2)
+        {
+            Assert.Less(arg1.X, arg2.X, "X");
+            Assert.Less(arg1.Y, arg2.Y, "Y");
+        }
+
+        public static void Less(Vector3 arg1, Vector3 arg2)
+        {
+            Assert.Less(arg1.X, arg2.X, "X");
+            Assert.Less(arg1.Y, arg2.Y, "Y");
+            Assert.Less(arg1.Z, arg2.Z, "Z");
+        }
+
+        public static void Less(Vector4 arg1, Vector4 arg2)
+        {
+            Assert.Less(arg1.X, arg2.X, "X");
+            Assert.Less(arg1.Y, arg2.Y, "Y");
+            Assert.Less(arg1.Z, arg2.Z, "Z");
+            Assert.Less(arg1.W, arg2.W, "W");
+        }
+
+        public static void LessOrEqual(Vector2 arg1, Vector2 arg2)
+        {
+            Assert.LessOrEqual(arg1.X, arg2.X, "X");
+            Assert.LessOrEqual(arg1.Y, arg2.Y, "Y");
+        }
+
+        public static void LessOrEqual(Vector3 arg1, Vector3 arg2)
+        {
+            Assert.LessOrEqual(arg1.X, arg2.X, "X");
+            Assert.LessOrEqual(arg1.Y, arg2.Y, "Y");
+            Assert.LessOrEqual(arg1.Z, arg2.Z, "Z");            
+        }
+
+        public static void LessOrEqual(Vector4 arg1, Vector4 arg2)
+        {
+            Assert.LessOrEqual(arg1.X, arg2.X, "X");
+            Assert.LessOrEqual(arg1.Y, arg2.Y, "Y");
+            Assert.LessOrEqual(arg1.Z, arg2.Z, "Z");
+            Assert.LessOrEqual(arg1.W, arg2.W, "W");
+        }
+
+        public static void Greater(Vector2 arg1, Vector2 arg2)
+        {
+            Assert.Greater(arg1.X, arg2.X, "X");
+            Assert.Greater(arg1.Y, arg2.Y, "Y");
+        }
+
+        public static void Greater(Vector3 arg1, Vector3 arg2)
+        {
+            Assert.Greater(arg1.X, arg2.X, "X");
+            Assert.Greater(arg1.Y, arg2.Y, "Y");
+            Assert.Greater(arg1.Z, arg2.Z, "Z");
+        }
+
+        public static void Greater(Vector4 arg1, Vector4 arg2)
+        {
+            Assert.Greater(arg1.X, arg2.X, "X");
+            Assert.Greater(arg1.Y, arg2.Y, "Y");
+            Assert.Greater(arg1.Z, arg2.Z, "Z");
+            Assert.Greater(arg1.W, arg2.W, "W");
+        }
+
+        public static void GreaterOrEqual(Vector2 arg1, Vector2 arg2)
+        {
+            Assert.GreaterOrEqual(arg1.X, arg2.X, "X");
+            Assert.GreaterOrEqual(arg1.Y, arg2.Y, "Y");
+        }
+
+        public static void GreaterOrEqual(Vector3 arg1, Vector3 arg2)
+        {
+            Assert.GreaterOrEqual(arg1.X, arg2.X, "X");
+            Assert.GreaterOrEqual(arg1.Y, arg2.Y, "Y");
+            Assert.GreaterOrEqual(arg1.Z, arg2.Z, "Z");
+        }
+
+        public static void GreaterOrEqual(Vector4 arg1, Vector4 arg2)
+        {
+            Assert.GreaterOrEqual(arg1.X, arg2.X, "X");
+            Assert.GreaterOrEqual(arg1.Y, arg2.Y, "Y");
+            Assert.GreaterOrEqual(arg1.Z, arg2.Z, "Z");
+            Assert.GreaterOrEqual(arg1.W, arg2.W, "W");
+        }
+
+        public static void AngleLessOrEqual(Vector2 a, Vector2 b, float radians)
+        {
+            var angle = VectorsUtils.GetAngle(a, b);
+
+            Assert.LessOrEqual(angle, radians, "Angle");
+        }
+
+        public static void AngleLessOrEqual(Vector3 a, Vector3 b, float radians)
+        {
+            var angle = VectorsUtils.GetAngle(a, b);
+
+            Assert.LessOrEqual(angle, radians, "Angle");
+        }
+
+        public static void AngleLessOrEqual(Quaternion a, Quaternion b, float radians)
+        {
+            var angle = VectorsUtils.GetAngle(a, b);
+
+            Assert.LessOrEqual(angle, radians, "Angle");
+        }
+    }
+}

+ 303 - 0
tests/SharpGLTF.Tests/Plotting.cs

@@ -0,0 +1,303 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Numerics;
+using System.Text;
+
+namespace SharpGLTF
+{
+    public static class Plotting
+    {
+        public static Point2Series ToPointSeries(this IEnumerable<Single> points) { return Point2Series.Create(points); }
+
+        public static Point2Series ToPointSeries(this IEnumerable<Double> points) { return Point2Series.Create(points); }
+
+        public static Point2Series ToPointSeries(this IEnumerable<Vector2> points) { return Point2Series.Create(points); }
+
+        public static Point2Series ToLineSeries(this IEnumerable<Vector2> points) { return Point2Series.Create(points, LineType.Continuous); }
+
+        public enum LineType
+        {
+            Square = 0,
+            Dot = 1,
+            Cross = 2,
+            Star = 3,
+            Circle = 4,
+            X = 5,            
+            Square2 = 6,
+            Triangle = 7,
+            CircleWithCross = 8,
+            CircleWithDot = 9,
+
+            CHAR_X = 88,
+            CHAR_Y = 89,
+            CHAR_Z = 90,
+            CHAR_W = 87,
+
+            Continuous = 65536                
+        }
+
+        public struct Point2
+        {
+            public Point2(Double x, Double y)
+            {
+                this.X = x; this.Y = y;
+            }
+
+            public Point2(Vector2 v)
+            {
+                this.X = v.X; this.Y = v.Y;
+            }
+
+            public Double X;
+            public Double Y;
+        }
+
+        public class Point2Series
+        {
+            #region lifecycle
+
+            public static Point2Series Create(IEnumerable<float> series, LineType lt = LineType.Continuous)
+            {
+                var points = series
+                    .Select((y, x) => (x, y))
+                    .Where(item => float.IsFinite(item.Item2))
+                    .Select(item => new Vector2(item.Item1, item.Item2));
+
+                return Create(points, lt);
+            }
+
+            public static Point2Series Create(IEnumerable<double> series, LineType lt = LineType.Continuous)
+            {
+                var points = series
+                    .Select((y, x) => (x, (float)y))
+                    .Where(item => float.IsFinite(item.Item2))
+                    .Select(item => new Vector2(item.Item1, item.Item2));
+
+                return Create(points, lt);
+            }
+
+            public static Point2Series Create(IEnumerable<Vector2> points, LineType lt = LineType.Dot)
+            {
+                points = points.Where(item => float.IsFinite(item.X) && float.IsFinite(item.Y));
+
+                var ps = new Point2Series();
+                ps._Points.AddRange(points.Select(item => new Point2(item)));
+
+                ps.LineType = lt;
+
+                return ps;
+            }
+
+            #endregion
+
+            #region data
+
+            private readonly List<Point2> _Points = new List<Point2>();            
+
+            #endregion
+
+            #region properties
+
+            public LineType LineType { get; set; }
+
+            #endregion
+
+            #region API
+
+            public Point2Series WithLineType(LineType t) { LineType = t; return this; }
+
+            public void DrawToFile(string filePath)
+            {
+                DrawToFile(filePath, this);
+            }
+
+            public static (Point2, Point2) GetBounds(params Point2Series[] series)
+            {
+                var xmin = series.SelectMany(item => item._Points).Min(item => item.X);
+                var xmax = series.SelectMany(item => item._Points).Max(item => item.X);
+                if (xmin == xmax) { xmin -= 1; xmax += 1; }
+
+                var ymin = series.SelectMany(item => item._Points).Min(item => item.Y);
+                var ymax = series.SelectMany(item => item._Points).Max(item => item.Y);
+                if (ymin == ymax) { ymin -= 1; ymax += 1; }
+
+                return (new Point2(xmin, ymin), new Point2(xmax, ymax));
+            }
+
+            public static void DrawToFile(string filePath, params Point2Series[] series)
+            {
+                // arguments check
+                if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException(nameof(filePath));
+
+                var bounds = GetBounds(series);
+
+                using (var pl = new PLplot.PLStream())
+                {
+                    pl.sdev("pngcairo");
+                    pl.sfnam(filePath);
+                    pl.spal0("cmap0_alternate.pal");
+
+                    pl.init();
+
+                    pl.env(bounds.Item1.X, bounds.Item2.X, bounds.Item1.Y, bounds.Item2.Y, PLplot.AxesScale.Independent, PLplot.AxisBox.BoxTicksLabelsAxes);
+
+                    for (int i = 0; i < series.Length; ++i)
+                    {
+                        var ps = series[i];
+                        var s = ps._Points;
+
+                        var seriesX = new double[s.Count];
+                        var seriesY = new double[s.Count];
+
+                        for(int j=0; j < s.Count; ++j)
+                        {
+                            seriesX[j] = s[j].X;
+                            seriesY[j] = s[j].Y;
+                        }
+
+                        pl.col0(i + 2);
+
+                        if (ps.LineType == LineType.Continuous) pl.line(seriesX, seriesY);
+                        else pl.poin(seriesX, seriesY, (char)ps.LineType);
+                    }
+
+                    pl.eop(); // write to disk
+                }
+            }
+
+            #endregion
+        }
+
+        public class Point3Series
+        {
+            #region lifecycle            
+
+            public static Point3Series Create(IEnumerable<Vector3> points)
+            {
+                points = points.Where(item => float.IsFinite(item.X) && float.IsFinite(item.Y) && float.IsFinite(item.Z) );
+
+                var ps = new Point3Series();
+                ps._Points.AddRange(points);
+                return ps;
+            }
+
+            #endregion
+
+            #region data
+
+            private readonly List<Vector3> _Points = new List<Vector3>();
+            private char _PointGlyph = '+';
+            private bool _Lines = false;
+
+            #endregion
+
+            #region API
+
+            public void DrawToFile(string filePath)
+            {
+                DrawToFile(filePath, this);
+            }
+
+            public static (Vector3, Vector3) GetBounds(params Point3Series[] series)
+            {
+                var xmin = series.SelectMany(item => item._Points).Min(item => item.X);
+                var xmax = series.SelectMany(item => item._Points).Max(item => item.X);
+                if (xmin == xmax) { xmin -= 1; xmax += 1; }
+
+                var ymin = series.SelectMany(item => item._Points).Min(item => item.Y);
+                var ymax = series.SelectMany(item => item._Points).Max(item => item.Y);
+                if (ymin == ymax) { ymin -= 1; ymax += 1; }
+
+                var zmin = series.SelectMany(item => item._Points).Min(item => item.Z);
+                var zmax = series.SelectMany(item => item._Points).Max(item => item.Z);
+                if (zmin == zmax) { zmin -= 1; zmax += 1; }
+
+                return (new Vector3(xmin, ymin,zmin), new Vector3(xmax, ymax, zmax));
+            }
+
+            public static void DrawToFile(string filePath, params Point3Series[] series)
+            {
+                // arguments check
+                if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentNullException(nameof(filePath));
+
+                var plen = series
+                    .Select(item => item._Points.Count)
+                    .Max();
+
+                if (plen < 1) throw new ArgumentOutOfRangeException($"The series only has {plen} values", nameof(series));
+
+                if (series.Any(item => item._Lines))
+                {
+                    plen = series
+                        .Where(item => item._Lines)
+                        .Select(item => item._Points.Count)
+                        .Max();
+
+                    if (plen < 2) throw new ArgumentOutOfRangeException($"The series only has {plen} values", nameof(series));
+                }
+
+                var bounds = GetBounds(series);
+
+                using (var pl = new PLplot.PLStream())
+                {
+                    pl.sdev("pngcairo");
+                    pl.sfnam(filePath);
+                    pl.spal0("cmap0_alternate.pal");
+
+                    pl.init();
+
+                    pl.env(bounds.Item1.X, bounds.Item2.X, bounds.Item1.Y, bounds.Item2.Y, PLplot.AxesScale.Independent, PLplot.AxisBox.BoxTicksLabelsAxes);
+
+                    for (int i = 0; i < series.Length; ++i)
+                    {
+                        var ps = series[i];
+                        var s = ps._Points;
+
+                        var seriesX = new double[s.Count];
+                        var seriesY = new double[s.Count];
+                        var seriesZ = new double[s.Count];
+
+                        for (int j = 0; j < s.Count; ++i)
+                        {
+                            seriesX[j] = s[j].X;
+                            seriesY[j] = s[j].Y;
+                            seriesZ[j] = s[j].Z;
+                        }
+
+                        pl.col0(i + 2);
+
+                        if (ps._Lines) pl.line3(seriesX, seriesY, seriesZ);
+                        else pl.poin3(seriesX, seriesY, seriesZ, ps._PointGlyph);
+                    }
+
+                    pl.eop(); // write to disk
+                }
+            }
+
+            #endregion
+        }
+    }
+
+
+    public static class PlottingNUnit
+    {
+        public static void AttachToCurrentTest(this Plotting.Point2Series points, string fileName)
+        {
+            fileName = NUnit.Framework.TestContext.CurrentContext.GetAttachmentPath(fileName);
+
+            points.DrawToFile(fileName);
+
+            NUnit.Framework.TestContext.AddTestAttachment(fileName);
+        }
+
+        public static void AttachToCurrentTest(this IEnumerable<Plotting.Point2Series> series, string fileName)
+        {
+            fileName = NUnit.Framework.TestContext.CurrentContext.GetAttachmentPath(fileName);
+
+            Plotting.Point2Series.DrawToFile(fileName, series.ToArray());
+
+            NUnit.Framework.TestContext.AddTestAttachment(fileName);
+        }
+    }
+}

+ 203 - 0
tests/SharpGLTF.Tests/Scenes/SceneBuilderTests.cs

@@ -0,0 +1,203 @@
+using System;
+using System.Linq;
+using System.Numerics;
+
+using NUnit.Framework;
+
+using SharpGLTF.Geometry;
+using SharpGLTF.Geometry.VertexTypes;
+using SharpGLTF.Geometry.Parametric;
+using SharpGLTF.Materials;
+
+namespace SharpGLTF.Scenes
+{
+    using VPOSNRM = VertexBuilder<VertexPositionNormal, VertexEmpty, VertexEmpty>;
+
+    using SKINNEDVERTEX = VertexBuilder<VertexPosition, VertexEmpty, VertexJoints8x4>;
+
+
+    [Category("Toolkit.Scenes")]
+    public class SceneBuilderTests
+    {
+        [Test]
+        public void CreateCubeScene()
+        {
+            TestContext.CurrentContext.AttachShowDirLink();
+            TestContext.CurrentContext.AttachGltfValidatorLinks();
+
+            var mesh = new Cube<MaterialBuilder>(new MaterialBuilder())
+                .ToMesh(Matrix4x4.Identity);
+
+            var scene = new SceneBuilder();
+
+            scene.AddMesh(mesh, Matrix4x4.Identity);
+
+            scene.AttachToCurrentTest("cube.glb");
+        }
+
+        [Test]
+        public void CreateAnimatedCubeScene()
+        {
+            TestContext.CurrentContext.AttachShowDirLink();
+            TestContext.CurrentContext.AttachGltfValidatorLinks();
+
+            var mesh = new Cube<MaterialBuilder>(MaterialBuilder.CreateDefault())
+                .ToMesh(Matrix4x4.Identity);
+
+            var pivot = new NodeBuilder();
+
+            pivot.UseTranslation("track1")
+                .WithPoint(0, Vector3.Zero)
+                .WithPoint(1, Vector3.One);
+
+            pivot.UseRotation("track1")
+                .WithPoint(0, Quaternion.Identity)
+                .WithPoint(1, Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.5f));
+
+            pivot.UseScale("track1")
+                .WithPoint(0, Vector3.One)
+                .WithPoint(1, new Vector3(0.5f));            
+
+            var scene = new SceneBuilder();            
+
+            scene.AddMesh(mesh, pivot);
+
+            scene.AttachToCurrentTest("animated.glb");
+            scene.AttachToCurrentTest("animated.gltf");
+        }
+
+        [Test]
+        public void CreateSceneWithRandomShapes()
+        {
+            TestContext.CurrentContext.AttachShowDirLink();
+            TestContext.CurrentContext.AttachGltfValidatorLinks();
+
+            var rnd = new Random();
+
+            // create materials
+            var materials = Enumerable
+                .Range(0, 10)
+                .Select(idx => new Materials.MaterialBuilder()
+                .WithChannelParam("BaseColor", new Vector4(rnd.NextVector3(), 1)))
+                .ToList();
+            
+            // create scene            
+
+            var scene = new SceneBuilder();
+
+            for (int i = 0; i < 100; ++i)
+            {
+                // create mesh
+                var mat = materials[rnd.Next(0, 10)];
+                var mesh = VPOSNRM.CreateCompatibleMesh("shape");
+
+                #if DEBUG
+                mesh.VertexPreprocessor.SetDebugPreprocessors();
+                #else
+                s.VertexPreprocessor.SetSanitizerPreprocessors();
+                #endif
+
+                if ((i & 1) == 0) mesh.AddCube(mat, Matrix4x4.Identity);
+                else mesh.AddSphere(mat, 0.5f, Matrix4x4.Identity);
+
+                mesh.Validate();
+
+                // create random transform
+                var r = rnd.NextVector3() * 5;
+                var xform = Matrix4x4.CreateFromYawPitchRoll(r.X, r.Y, r.Z) * Matrix4x4.CreateTranslation(rnd.NextVector3() * 25);
+
+                scene.AddMesh(mesh, xform);                
+            }
+
+            // save the model as GLB
+
+            scene.AttachToCurrentTest("shapes.glb");
+        }
+
+        [Test]
+        public void CreateSkinnedScene()
+        {
+            TestContext.CurrentContext.AttachShowDirLink();
+            TestContext.CurrentContext.AttachGltfValidatorLinks();
+            
+            // create two materials
+
+            var pink = new MaterialBuilder("material1")
+                .WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 0, 1, 1))
+                .WithDoubleSide(true);
+
+            var yellow = new MaterialBuilder("material2")
+                .WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 1, 0, 1))
+                .WithDoubleSide(true);
+
+            // create the mesh            
+
+            const int jointIdx0 = 0; // index of joint node 0
+            const int jointIdx1 = 1; // index of joint node 1
+            const int jointIdx2 = 2; // index of joint node 2
+
+            var v1 = new SKINNEDVERTEX(new Vector3(-10, 0, +10), (jointIdx0, 1));
+            var v2 = new SKINNEDVERTEX(new Vector3(+10, 0, +10), (jointIdx0, 1));
+            var v3 = new SKINNEDVERTEX(new Vector3(+10, 0, -10), (jointIdx0, 1));
+            var v4 = new SKINNEDVERTEX(new Vector3(-10, 0, -10), (jointIdx0, 1));
+
+            var v5 = new SKINNEDVERTEX(new Vector3(-10, 40, +10), (jointIdx0, 0.5f), (jointIdx1, 0.5f));
+            var v6 = new SKINNEDVERTEX(new Vector3(+10, 40, +10), (jointIdx0, 0.5f), (jointIdx1, 0.5f));
+            var v7 = new SKINNEDVERTEX(new Vector3(+10, 40, -10), (jointIdx0, 0.5f), (jointIdx1, 0.5f));
+            var v8 = new SKINNEDVERTEX(new Vector3(-10, 40, -10), (jointIdx0, 0.5f), (jointIdx1, 0.5f));
+
+            var v9  = new SKINNEDVERTEX(new Vector3(-5, 80, +5), (jointIdx2, 1));
+            var v10 = new SKINNEDVERTEX(new Vector3(+5, 80, +5), (jointIdx2, 1));
+            var v11 = new SKINNEDVERTEX(new Vector3(+5, 80, -5), (jointIdx2, 1));
+            var v12 = new SKINNEDVERTEX(new Vector3(-5, 80, -5), (jointIdx2, 1));
+
+            var mesh = SKINNEDVERTEX.CreateCompatibleMesh("mesh1");
+
+            #if DEBUG
+            mesh.VertexPreprocessor.SetDebugPreprocessors();
+            #else
+            mesh.VertexPreprocessor.SetSanitizerPreprocessors();
+            #endif
+
+            mesh.UsePrimitive(pink).AddConvexPolygon(v1, v2, v6, v5);
+            mesh.UsePrimitive(pink).AddConvexPolygon(v2, v3, v7, v6);
+            mesh.UsePrimitive(pink).AddConvexPolygon(v3, v4, v8, v7);
+            mesh.UsePrimitive(pink).AddConvexPolygon(v4, v1, v5, v8);
+
+            mesh.UsePrimitive(yellow).AddConvexPolygon(v5, v6, v10, v9);
+            mesh.UsePrimitive(yellow).AddConvexPolygon(v6, v7, v11, v10);
+            mesh.UsePrimitive(yellow).AddConvexPolygon(v7, v8, v12, v11);
+            mesh.UsePrimitive(yellow).AddConvexPolygon(v8, v5, v9, v12);
+
+            mesh.Validate();
+            
+            // create the skeleton armature for the skinned mesh.
+
+            var armature = new NodeBuilder("Skeleton");
+            var joint0 = armature.CreateNode("Joint 0").WithLocalTranslation(new Vector3(0, 0, 0)); // jointIdx0
+            var joint1 = joint0.CreateNode("Joint 1").WithLocalTranslation(new Vector3(0, 40, 0));  // jointIdx1
+            var joint2 = joint1.CreateNode("Joint 2").WithLocalTranslation(new Vector3(0, 40, 0));  // jointIdx2
+
+            joint1.UseRotation("Base Track")
+                .WithPoint(1, Quaternion.Identity)
+                .WithPoint(2, Quaternion.CreateFromYawPitchRoll(0, 1, 0))
+                .WithPoint(3, Quaternion.CreateFromYawPitchRoll(0, 0, 1))
+                .WithPoint(4, Quaternion.Identity);
+
+            // create scene
+
+            var scene = new SceneBuilder();
+
+            scene.AddSkinnedMesh
+                (
+                mesh,
+                joint0, // joint used for skinning joint index 0
+                joint1, // joint used for skinning joint index 1
+                joint2  // joint used for skinning joint index 2
+                );
+
+            scene.AttachToCurrentTest("skinned.glb");
+            scene.AttachToCurrentTest("skinned.gltf");
+        }
+    }
+}

+ 2 - 0
tests/SharpGLTF.Tests/Schema2/Authoring/BasicSceneCreationTests.cs

@@ -2,6 +2,8 @@
 
 using NUnit.Framework;
 
+using SharpGLTF.Geometry.Parametric;
+
 namespace SharpGLTF.Schema2.Authoring
 {
     using VPOSNRM = Geometry.VertexBuilder<Geometry.VertexTypes.VertexPositionNormal,Geometry.VertexTypes.VertexEmpty,Geometry.VertexTypes.VertexEmpty>;

+ 43 - 25
tests/SharpGLTF.Tests/Schema2/Authoring/MeshBuilderCreationTests.cs

@@ -5,11 +5,12 @@ using System.Linq;
 
 using NUnit.Framework;
 
+using SharpGLTF.Geometry;
+using SharpGLTF.Materials;
+using SharpGLTF.Geometry.Parametric;
+
 namespace SharpGLTF.Schema2.Authoring
 {
-    using Geometry;
-    using Materials;
-
     using VEMPTY = Geometry.VertexTypes.VertexEmpty;
     using VPOSNRM = Geometry.VertexTypes.VertexPositionNormal;
     using VPOS = Geometry.VertexTypes.VertexPosition;
@@ -59,8 +60,11 @@ namespace SharpGLTF.Schema2.Authoring
             TestContext.CurrentContext.AttachGltfValidatorLinks();
 
             // create materials
-            var material1 = new MaterialBuilder("material1").WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 1, 0, 1));
-            var material2 = new MaterialBuilder("material1").WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 0, 1, 1));            
+            var material1 = new MaterialBuilder("material1")
+                .WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 1, 0, 1));
+
+            var material2 = new MaterialBuilder("material1")
+                .WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 0, 1, 1));            
 
             // create several meshes
             var meshBuilder1 = new MeshBuilder<VPOSNRM>("mesh1");
@@ -161,27 +165,41 @@ namespace SharpGLTF.Schema2.Authoring
             };
 
             // create two materials
-            var pink = new MaterialBuilder("material1").WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 0, 1, 1)).WithDoubleSide(true);
-            var yellow = new MaterialBuilder("material2").WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 1, 0, 1)).WithDoubleSide(true);
+            var pink = new MaterialBuilder("material1")
+                .WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 0, 1, 1))
+                .WithDoubleSide(true);
+
+            var yellow = new MaterialBuilder("material2")
+                .WithChannelParam(KnownChannels.BaseColor, new Vector4(1, 1, 0, 1))
+                .WithDoubleSide(true);
 
             // create the mesh
             var meshBuilder = new MeshBuilder<VPOS, VEMPTY, VSKIN4>("mesh1");
+
+            #if DEBUG
             meshBuilder.VertexPreprocessor.SetDebugPreprocessors();
+            #else
+            meshBuilder.VertexPreprocessor.SetSanitizerPreprocessors();
+            #endif
+
+            const int jointIdx0 = 0;
+            const int jointIdx1 = 1;
+            const int jointIdx2 = 2;
 
-            var v1 = (new VPOS(-10, 0, +10), new VSKIN4(0));
-            var v2 = (new VPOS(+10, 0, +10), new VSKIN4(0));
-            var v3 = (new VPOS(+10, 0, -10), new VSKIN4(0));
-            var v4 = (new VPOS(-10, 0, -10), new VSKIN4(0));
+            var v1 = (new VPOS(-10, 0, +10), new VSKIN4(jointIdx0));
+            var v2 = (new VPOS(+10, 0, +10), new VSKIN4(jointIdx0));
+            var v3 = (new VPOS(+10, 0, -10), new VSKIN4(jointIdx0));
+            var v4 = (new VPOS(-10, 0, -10), new VSKIN4(jointIdx0));
 
-            var v5 = (new VPOS(-10, 40, +10), new VSKIN4((0,0.5f), (1, 0.5f)));
-            var v6 = (new VPOS(+10, 40, +10), new VSKIN4((0, 0.5f), (1, 0.5f)));
-            var v7 = (new VPOS(+10, 40, -10), new VSKIN4((0, 0.5f), (1, 0.5f)));
-            var v8 = (new VPOS(-10, 40, -10), new VSKIN4((0, 0.5f), (1, 0.5f)));
+            var v5 = (new VPOS(-10, 40, +10), new VSKIN4((jointIdx0, 0.5f), (jointIdx1, 0.5f)));
+            var v6 = (new VPOS(+10, 40, +10), new VSKIN4((jointIdx0, 0.5f), (jointIdx1, 0.5f)));
+            var v7 = (new VPOS(+10, 40, -10), new VSKIN4((jointIdx0, 0.5f), (jointIdx1, 0.5f)));
+            var v8 = (new VPOS(-10, 40, -10), new VSKIN4((jointIdx0, 0.5f), (jointIdx1, 0.5f)));
 
-            var v9  = (new VPOS(-5, 80, +5), new VSKIN4(2));
-            var v10 = (new VPOS(+5, 80, +5), new VSKIN4(2));
-            var v11 = (new VPOS(+5, 80, -5), new VSKIN4(2));
-            var v12 = (new VPOS(-5, 80, -5), new VSKIN4(2));
+            var v9  = (new VPOS(-5, 80, +5), new VSKIN4(jointIdx2));
+            var v10 = (new VPOS(+5, 80, +5), new VSKIN4(jointIdx2));
+            var v11 = (new VPOS(+5, 80, -5), new VSKIN4(jointIdx2));
+            var v12 = (new VPOS(-5, 80, -5), new VSKIN4(jointIdx2));
 
             meshBuilder.UsePrimitive(pink).AddConvexPolygon(v1, v2, v6, v5);
             meshBuilder.UsePrimitive(pink).AddConvexPolygon(v2, v3, v7, v6);
@@ -201,14 +219,14 @@ namespace SharpGLTF.Schema2.Authoring
 
             // create the three joints that will affect the mesh
             var skelet = scene.CreateNode("Skeleton");
-            var joint1 = skelet.CreateNode("Joint 1").WithLocalTranslation(new Vector3(0, 0, 0));
-            var joint2 = joint1.CreateNode("Joint 2").WithLocalTranslation(new Vector3(0, 40, 0)).WithRotationAnimation("Base Track", keyframes);
-            var joint3 = joint2.CreateNode("Joint 3").WithLocalTranslation(new Vector3(0, 40, 0));
+            var joint0 = skelet.CreateNode("Joint 0").WithLocalTranslation(new Vector3(0, 0, 0));
+            var joint1 = joint0.CreateNode("Joint 1").WithLocalTranslation(new Vector3(0, 40, 0)).WithRotationAnimation("Base Track", keyframes);
+            var joint2 = joint1.CreateNode("Joint 2").WithLocalTranslation(new Vector3(0, 40, 0));
 
             // setup skin
             var snode = scene.CreateNode("Skeleton Node");
             snode.Skin = model.CreateSkin();            
-            snode.Skin.BindJoints(joint1, joint2, joint3);
+            snode.Skin.BindJoints(joint0, joint1, joint2);
 
             snode.WithMesh( model.CreateMesh(meshBuilder) );
 
@@ -328,7 +346,7 @@ namespace SharpGLTF.Schema2.Authoring
 
             var materials = Enumerable
                 .Range(0, 10)
-                .Select(idx => new MaterialBuilder()
+                .Select(idx => MaterialBuilder.CreateDefault()
                 .WithChannelParam("BaseColor", new Vector4(rnd.NextVector3(),1)))
                 .ToList();
 
@@ -369,7 +387,7 @@ namespace SharpGLTF.Schema2.Authoring
             // create a mesh
             var cube = new MeshBuilder<VPOSNRM>("cube");
             cube.VertexPreprocessor.SetDebugPreprocessors();
-            cube.AddCube(new MaterialBuilder(), Matrix4x4.Identity);
+            cube.AddCube(MaterialBuilder.CreateDefault(), Matrix4x4.Identity);
             cube.Validate();
 
             // create a new gltf model

+ 0 - 171
tests/SharpGLTF.Tests/Schema2/Authoring/SolidMeshUtils.cs

@@ -1,171 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Numerics;
-using System.Text;
-
-namespace SharpGLTF.Schema2.Authoring
-{
-    using Geometry;
-
-    using VEMPTY = Geometry.VertexTypes.VertexEmpty;
-    using VPOS = Geometry.VertexTypes.VertexPosition;
-    using VPOSNRM = Geometry.VertexTypes.VertexPositionNormal;
-    using VTEX1 = Geometry.VertexTypes.VertexTexture1;    
-
-    static class SolidMeshUtils
-    {
-        public static void AddCube<TMaterial>(this MeshBuilder<TMaterial, VPOSNRM, VEMPTY, VEMPTY> meshBuilder, TMaterial material, Matrix4x4 xform)
-        {
-            meshBuilder._AddCubeFace(material, Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ, xform);
-            meshBuilder._AddCubeFace(material, -Vector3.UnitX, Vector3.UnitZ, Vector3.UnitY, xform);
-
-            meshBuilder._AddCubeFace(material, Vector3.UnitY, Vector3.UnitZ, Vector3.UnitX, xform);
-            meshBuilder._AddCubeFace(material, -Vector3.UnitY, Vector3.UnitX, Vector3.UnitZ, xform);
-
-            meshBuilder._AddCubeFace(material, Vector3.UnitZ, Vector3.UnitX, Vector3.UnitY, xform);
-            meshBuilder._AddCubeFace(material, -Vector3.UnitZ, Vector3.UnitY, Vector3.UnitX, xform);
-        }
-
-        private static void _AddCubeFace<TMaterial>(this MeshBuilder<TMaterial, VPOSNRM, VEMPTY, VEMPTY> meshBuilder, TMaterial material, Vector3 origin, Vector3 axisX, Vector3 axisY, Matrix4x4 xform)
-        {
-            var p1 = Vector3.Transform(origin - axisX - axisY, xform);
-            var p2 = Vector3.Transform(origin + axisX - axisY, xform);
-            var p3 = Vector3.Transform(origin + axisX + axisY, xform);
-            var p4 = Vector3.Transform(origin - axisX + axisY, xform);
-            var n = Vector3.Normalize(Vector3.TransformNormal(origin, xform));
-
-            meshBuilder.UsePrimitive(material)
-                .AddConvexPolygon
-                (
-                new VPOSNRM(p1, n),
-                new VPOSNRM(p2, n),
-                new VPOSNRM(p3, n),
-                new VPOSNRM(p4, n)
-                );
-        }
-
-        public static void AddSphere<TMaterial>(this MeshBuilder<TMaterial, VPOSNRM, VEMPTY, VEMPTY> meshBuilder, TMaterial material, Single radius, Matrix4x4 xform)
-        {
-            // http://blog.andreaskahler.com/2009/06/creating-icosphere-mesh-in-code.html
-
-            var t = 1 + (float)(Math.Sqrt(5.0) / 2);
-
-            var v0 = new Vector3(-1, t, 0) * radius;
-            var v1 = new Vector3(1, t, 0) * radius;
-            var v2 = new Vector3(-1, -t, 0) * radius;
-            var v3 = new Vector3(1, -t, 0) * radius;
-
-            var v4 = new Vector3(0, -1, t) * radius;
-            var v5 = new Vector3(0, 1, t) * radius;
-            var v6 = new Vector3(0, -1, -t) * radius;
-            var v7 = new Vector3(0, 1, -t) * radius;
-
-            var v8 = new Vector3(t, 0, -1) * radius;
-            var v9 = new Vector3(t, 0, 1) * radius;
-            var v10 = new Vector3(-t, 0, -1) * radius;
-            var v11 = new Vector3(-t, 0, 1) * radius;
-
-            // 5 faces around point 0
-            meshBuilder._AddSphereTriangle(material, xform, v0, v11, v5, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v0, v5, v1, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v0, v1, v7, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v0, v7, v10, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v0, v10, v11, 3);
-
-            // 5 adjacent faces
-            meshBuilder._AddSphereTriangle(material, xform, v1, v5, v9, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v5, v11, v4, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v11, v10, v2, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v10, v7, v6, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v7, v1, v8, 3);
-
-            // 5 faces around point 3
-            meshBuilder._AddSphereTriangle(material, xform, v3, v9, v4, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v3, v4, v2, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v3, v2, v6, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v3, v6, v8, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v3, v8, v9, 3);
-
-            // 5 adjacent faces
-            meshBuilder._AddSphereTriangle(material, xform, v4, v9, v5, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v2, v4, v11, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v6, v2, v10, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v8, v6, v7, 3);
-            meshBuilder._AddSphereTriangle(material, xform, v9, v8, v1, 3);
-        }
-
-        private static void _AddSphereTriangle<TMaterial>(this MeshBuilder<TMaterial, VPOSNRM, VEMPTY, VEMPTY> meshBuilder, TMaterial material, Matrix4x4 xform, Vector3 a, Vector3 b, Vector3 c, int iterations = 0)
-        {
-            if (iterations <=0)
-            {
-                var aa = new VPOSNRM(Vector3.Transform(a, xform), Vector3.Normalize(Vector3.TransformNormal(a, xform)));
-                var bb = new VPOSNRM(Vector3.Transform(b, xform), Vector3.Normalize(Vector3.TransformNormal(b, xform)));
-                var cc = new VPOSNRM(Vector3.Transform(c, xform), Vector3.Normalize(Vector3.TransformNormal(c, xform)));
-
-                meshBuilder.UsePrimitive(material).AddTriangle(aa, bb, cc);
-                return;
-            }
-
-            --iterations;
-
-            var ab = Vector3.Normalize(a + b) * a.Length();
-            var bc = Vector3.Normalize(b + c) * b.Length();
-            var ca = Vector3.Normalize(c + a) * c.Length();
-
-            // central triangle
-            _AddSphereTriangle(meshBuilder, material, xform, ab, bc, ca, iterations);
-
-            // vertex triangles
-            _AddSphereTriangle(meshBuilder, material, xform, a, ab, ca, iterations);
-            _AddSphereTriangle(meshBuilder, material, xform, b, bc, ab, iterations);
-            _AddSphereTriangle(meshBuilder, material, xform, c, ca, bc, iterations);
-        }
-
-        public static MeshBuilder<VPOS, VTEX1> CreateTerrainMesh(int width, int length, Func<int,int,float> heightFunction, string terrainColorImagePath)
-        {
-            // we create a new material to use with the terrain mesh
-            var material = new Materials.MaterialBuilder("TerrainMaterial")
-                .WithChannelImage(Materials.KnownChannels.BaseColor, terrainColorImagePath);
-
-            // we create a MeshBuilder
-            var terrainMesh = new MeshBuilder<VPOS, VTEX1>("terrain");
-
-            var texScale = new Vector2(width, length);
-
-            // fill the MeshBuilder with quads using the heightFunction.
-            for (int y = 1; y < length; ++y)
-            {
-                for (int x = 1; x < width; ++x)
-                {
-                    // quad vertex positions
-
-                    var a = new Vector3(x - 1, heightFunction(x - 1, y + 0), y + 0);
-                    var b = new Vector3(x + 0, heightFunction(x + 0, y + 0), y + 0);
-                    var c = new Vector3(x + 0, heightFunction(x + 0, y - 1), y - 1);
-                    var d = new Vector3(x - 1, heightFunction(x - 1, y - 1), y - 1);
-
-                    // quad UV coordinates
-
-                    var at = new Vector2(a.X, a.Z) / texScale;
-                    var bt = new Vector2(b.X, b.Z) / texScale;
-                    var ct = new Vector2(c.X, c.Z) / texScale;
-                    var dt = new Vector2(d.X, d.Z) / texScale;
-
-                    terrainMesh
-                        .UsePrimitive(material)
-                        .AddConvexPolygon
-                        (
-                            (a, at),
-                            (b, bt),
-                            (c, ct),
-                            (d, dt)
-                        );
-                }
-            }
-
-            terrainMesh.Validate();
-
-            return terrainMesh;
-        }
-    }
-}

+ 2 - 1
tests/SharpGLTF.Tests/SharpGLTF.Tests.csproj

@@ -12,7 +12,8 @@
     <PackageReference Include="LibGit2Sharp" Version="0.26.0" />
     <PackageReference Include="nunit" Version="3.12.0" />
     <PackageReference Include="NUnit3TestAdapter" Version="3.13.0" />
-    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.1.1" />    
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
+    <PackageReference Include="PLplot" Version="5.13.7" />    
   </ItemGroup>
 
   <ItemGroup>

+ 12 - 0
tests/SharpGLTF.Tests/ToolkitUtils.cs

@@ -24,5 +24,17 @@ namespace SharpGLTF
                 primitive.AddTriangle(a, b, c);
             }
         }
+
+        public static void AddConvexPolygon(this IPrimitiveBuilder primitive, params IVertexBuilder[] vertices)
+        {
+            for (int i = 2; i < vertices.Length; ++i)
+            {
+                var a = vertices[0];
+                var b = vertices[i - 1];
+                var c = vertices[i];
+
+                primitive.AddTriangle(a, b, c);
+            }
+        }
     }
 }

+ 41 - 6
tests/SharpGLTF.Tests/Utils.cs

@@ -66,6 +66,13 @@ namespace SharpGLTF
             TestContext.AddTestAttachment(fileName);
         }
 
+        public static void AttachToCurrentTest(this Scenes.SceneBuilder scene, string fileName)
+        {
+            var model = scene.ToSchema2();
+
+            model.AttachToCurrentTest(fileName);
+        }
+
         public static void AttachToCurrentTest(this Schema2.ModelRoot model, string fileName, Schema2.Animation animation, float time)
         {
             fileName = fileName.Replace(" ", "_");
@@ -229,7 +236,7 @@ namespace SharpGLTF
         }        
     }
 
-    static class VectorUtils
+    static class VectorsUtils
     {
         public static Single NextSingle(this Random rnd)
         {
@@ -251,12 +258,40 @@ namespace SharpGLTF
             return new Vector4(rnd.NextSingle(), rnd.NextSingle(), rnd.NextSingle(), rnd.NextSingle());
         }
 
-        public static void AreEqual(Vector4 a, Vector4 b, double delta = 0)
+        public static float GetAngle(Quaternion a, Quaternion b)
         {
-            Assert.AreEqual(a.X, b.X, delta);
-            Assert.AreEqual(a.Y, b.Y, delta);
-            Assert.AreEqual(a.Z, b.Z, delta);
-            Assert.AreEqual(a.W, b.W, delta);
+            var w = Quaternion.Concatenate(b, Quaternion.Inverse(a)).W;
+
+            if (w < -1) w = -1;
+            if (w > 1) w = 1;
+
+            return (float)Math.Acos(w) * 2;
+        }
+
+        public static float GetAngle(Vector3 a, Vector3 b)
+        {
+            a = Vector3.Normalize(a);
+            b = Vector3.Normalize(b);
+
+            var c = Vector3.Dot(a, b);
+            if (c > 1) c = 1;
+            if (c < -1) c = -1;
+
+            return (float)Math.Acos(c);
+        }
+
+        public static float GetAngle(Vector2 a, Vector2 b)
+        {
+            a = Vector2.Normalize(a);
+            b = Vector2.Normalize(b);
+
+            var c = Vector2.Dot(a, b);
+            if (c > 1) c = 1;
+            if (c < -1) c = -1;
+
+            return (float)Math.Acos(c);
         }
     }
+
+    
 }