Răsfoiți Sursa

improving curve evaluation
meshbuilder interface cleanup (WIP)
beginning with scenebuilder tests

Vicente Penades 6 ani în urmă
părinte
comite
55f0e1cdec

+ 3 - 3
src/SharpGLTF.Core/Schema2/gltf.Animations.cs

@@ -517,7 +517,7 @@ namespace SharpGLTF.Schema2
             {
                 case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateStepSamplerFunc();
                 case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateLinearSamplerFunc();
-                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateCubicSamplerFunc();
+                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateSplineSamplerFunc();
             }
 
             throw new NotImplementedException();
@@ -531,7 +531,7 @@ namespace SharpGLTF.Schema2
             {
                 case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateStepSamplerFunc();
                 case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateLinearSamplerFunc();
-                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateCubicSamplerFunc();
+                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateSplineSamplerFunc();
             }
 
             throw new NotImplementedException();
@@ -545,7 +545,7 @@ namespace SharpGLTF.Schema2
             {
                 case AnimationInterpolationMode.STEP: return xsampler.GetLinearKeys().CreateStepSamplerFunc();
                 case AnimationInterpolationMode.LINEAR: return xsampler.GetLinearKeys().CreateLinearSamplerFunc();
-                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateCubicSamplerFunc();
+                case AnimationInterpolationMode.CUBICSPLINE: return xsampler.GetCubicKeys().CreateSplineSamplerFunc();
             }
 
             throw new NotImplementedException();

+ 44 - 34
src/SharpGLTF.Core/Transforms/AnimationSamplerFactory.cs

@@ -116,22 +116,22 @@ namespace SharpGLTF.Transforms
             return _sampler;
         }
 
-        internal static CurveSampler<Vector3> CreateCubicSamplerFunc(this IEnumerable<(float, (Vector3, Vector3, Vector3))> collection)
+        internal static CurveSampler<Vector3> CreateSplineSamplerFunc(this IEnumerable<(float, (Vector3, Vector3, Vector3))> collection)
         {
-            return CreateCubicSamplerFunc<Vector3>(collection, Hermite);
+            return CreateSplineSamplerFunc<Vector3>(collection, Hermite);
         }
 
-        internal static CurveSampler<Quaternion> CreateCubicSamplerFunc(this IEnumerable<(float, (Quaternion, Quaternion, Quaternion))> collection)
+        internal static CurveSampler<Quaternion> CreateSplineSamplerFunc(this IEnumerable<(float, (Quaternion, Quaternion, Quaternion))> collection)
         {
-            return CreateCubicSamplerFunc<Quaternion>(collection, Hermite);
+            return CreateSplineSamplerFunc<Quaternion>(collection, Hermite);
         }
 
-        internal static CurveSampler<float[]> CreateCubicSamplerFunc(this IEnumerable<(float, (float[], float[], float[]))> collection)
+        internal static CurveSampler<float[]> CreateSplineSamplerFunc(this IEnumerable<(float, (float[], float[], float[]))> collection)
         {
-            return CreateCubicSamplerFunc<float[]>(collection, Hermite);
+            return CreateSplineSamplerFunc<float[]>(collection, Hermite);
         }
 
-        internal static CurveSampler<T> CreateCubicSamplerFunc<T>(this IEnumerable<(float, (T, T, T))> collection, Func<T, T, T, T, float, T> hermiteFunc)
+        internal static CurveSampler<T> CreateSplineSamplerFunc<T>(this IEnumerable<(float, (T, T, T))> collection, Func<T, T, T, T, float, T> hermiteFunc)
         {
             if (collection == null) return null;
 
@@ -145,56 +145,66 @@ namespace SharpGLTF.Transforms
             return _sampler;
         }
 
-        internal static Vector3 Hermite(Vector3 value1, Vector3 tangent1, Vector3 value2, Vector3 tangent2, float amount)
+        internal static Vector3 Hermite(Vector3 start, Vector3 tangentOut, Vector3 end, Vector3 tangentIn, float amount)
         {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
+            var hermite = CalculateHermiteWeights(amount);
 
-            var squared = amount * amount;
-            var cubed = amount * squared;
+            return (start * hermite.Item1) + (end * hermite.Item2) + (tangentOut * hermite.Item3) + (tangentIn * hermite.Item4);
+        }
 
-            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;
+        internal static Quaternion Hermite(Quaternion value1, Quaternion tangent1, Quaternion value2, Quaternion tangent2, float amount)
+        {
+            var hermite = CalculateHermiteWeights(amount);
 
-            return (value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4);
+            return Quaternion.Normalize((value1 * hermite.Item1) + (value2 * hermite.Item2) + (tangent1 * hermite.Item3) + (tangent2 * hermite.Item4));
         }
 
-        internal static Quaternion Hermite(Quaternion value1, Quaternion tangent1, Quaternion value2, Quaternion tangent2, float amount)
+        internal static float[] Hermite(float[] value1, float[] tangent1, float[] value2, float[] tangent2, float amount)
         {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
+            var hermite = CalculateHermiteWeights(amount);
 
-            var squared = amount * amount;
-            var cubed = amount * squared;
+            var result = new float[value1.Length];
 
-            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;
+            for (int i = 0; i < result.Length; ++i)
+            {
+                result[i] = (value1[i] * hermite.Item1) + (value2[i] * hermite.Item2) + (tangent1[i] * hermite.Item3) + (tangent2[i] * hermite.Item4);
+            }
 
-            return Quaternion.Normalize((value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4));
+            return result;
         }
 
-        internal static float[] Hermite(float[] value1, float[] tangent1, float[] value2, float[] tangent2, float amount)
+        /// <summary>
+        /// for a given cubic interpolation <paramref name="amount"/>, it calculates
+        /// the weights to multiply each component:
+        /// 1: Weight for Start point
+        /// 2: Weight for End Tangent
+        /// 3: Weight for Out Tangent
+        /// 4: Weight for In Tangent
+        /// </summary>
+        /// <param name="amount">the input amount</param>
+        /// <returns>the output weights</returns>
+        public static (float, float, float, float) CalculateHermiteWeights(float 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 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);
-            }
+            var part2 = (3.0f * squared) - (2.0f * cubed);
+            var part1 = 1 - part2;
+            var part4 = cubed - squared;
+            var part3 = part4 - squared + amount;
 
-            return result;
+            return (part1, part2, part3, part4);
         }
     }
 }

+ 25 - 0
src/SharpGLTF.Toolkit/Animations/Animatable.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Numerics;
 using System.Text;
 
 namespace SharpGLTF.Animations
@@ -22,6 +23,30 @@ namespace SharpGLTF.Animations
             return _Tracks.TryGetValue(track, out ICurveSampler<T> sampler) ? sampler.GetSample(value) : this.Default;
         }
 
+        public ILinearCurve<T> UseLinearCurve(string track)
+        {
+            if (!_Tracks.TryGetValue(track, out ICurveSampler<T> curve))
+            {
+                _Tracks[track] = CurveFactory.CreateLinearCurve<T>();
+            }
+
+            if (curve is ILinearCurve<T> editableCurve) return editableCurve;
+
+            throw new ArgumentException(nameof(T), "Generic argument not supported");
+        }
+
+        public ISplineCurve<T> UseSplineCurve(string track)
+        {
+            if (!_Tracks.TryGetValue(track, out ICurveSampler<T> curve))
+            {
+                _Tracks[track] = CurveFactory.CreateSplineCurve<T>();
+            }
+
+            if (curve is ISplineCurve<T> editableCurve) return editableCurve;
+
+            throw new ArgumentException(nameof(T), "Generic argument not supported");
+        }
+
         #endregion
     }
 }

+ 242 - 52
src/SharpGLTF.Toolkit/Animations/Curves.cs

@@ -5,23 +5,25 @@ using System.Text;
 
 namespace SharpGLTF.Animations
 {
-    // TODO: we could support conversions between linear and cubic (with hermite regression)
-
     public interface ICurveSampler<T>
         where T : struct
     {
         T GetSample(float offset);
     }
 
-    public interface ICurveWriter<T>
+    public interface ILinearCurve<T> : ICurveSampler<T>
         where T : struct
     {
+        IReadOnlyCollection<float> Keys { get; }
+
         void RemoveKey(float key);
 
+        T GetControlPoint(float key);
+
         void SetControlPoint(float key, T value);
     }
 
-    public interface ICubicCurveWriter<T> : ICurveWriter<T>
+    public interface ISplineCurve<T> : ILinearCurve<T>
         where T : struct
     {
         void SetControlPointIn(float key, T value);
@@ -31,10 +33,81 @@ namespace SharpGLTF.Animations
         void SetTangentOut(float key, T value);
     }
 
-    public abstract class Curve<Tin, Tout> : ICurveSampler<Tout>
+    public static class CurveFactory
+    {
+        // TODO: we could support conversions between linear and cubic (with hermite regression)
+
+        public static ILinearCurve<T> CreateLinearCurve<T>()
+            where T : struct
+        {
+            if (typeof(T) == typeof(Single)) return new ScalarLinearCurve() as ILinearCurve<T>;
+            if (typeof(T) == typeof(Vector3)) return new Vector3LinearCurve() as ILinearCurve<T>;
+            if (typeof(T) == typeof(Quaternion)) return new QuaternionLinearCurve() as ILinearCurve<T>;
+
+            throw new ArgumentException(nameof(T), "Generic argument not supported");
+        }
+
+        public static ISplineCurve<T> CreateSplineCurve<T>()
+            where T : struct
+        {
+            if (typeof(T) == typeof(Single)) return new ScalarSplineCurve() as ISplineCurve<T>;
+            if (typeof(T) == typeof(Vector3)) return new Vector3SplineCurve() as ISplineCurve<T>;
+            if (typeof(T) == typeof(Quaternion)) return new QuaternionSplineCurve() as ISplineCurve<T>;
+
+            throw new ArgumentException(nameof(T), "Generic argument not supported");
+        }
+
+        /// <summary>
+        /// for a given cubic interpolation <paramref name="amount"/>, it calculates
+        /// the weights to multiply each component:
+        /// 1: Weight for Start point
+        /// 2: Weight for End Tangent
+        /// 3: Weight for Out Tangent
+        /// 4: Weight for In Tangent
+        /// </summary>
+        /// <param name="amount">the input amount</param>
+        /// <returns>the output weights</returns>
+        public static (float, float, float, float) CalculateHermiteWeights(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 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);
+        }
+    }
+
+    abstract class Curve<Tin, Tout> : ICurveSampler<Tout>
         where Tin : struct
         where Tout : struct
     {
+        #region lifecycle
+
+        public Curve() { }
+
+        protected Curve(Curve<Tin, Tout> other)
+        {
+            foreach (var kvp in other._Keys)
+            {
+                this._Keys.Add(kvp.Key, kvp.Value);
+            }
+        }
+
+        #endregion
+
         #region data
 
         private SortedDictionary<float, Tin> _Keys = new SortedDictionary<float, Tin>();
@@ -113,15 +186,37 @@ namespace SharpGLTF.Animations
         #endregion
     }
 
-    class SingleLinearCurve : Curve<Single, Single>, ICurveWriter<Single>
+    struct _SplinePoint<T>
+        where T : struct
     {
+        public T InTangent;
+        public T Point;
+        public T OutTangent;
+    }
+
+    class ScalarLinearCurve : Curve<Single, Single>, ILinearCurve<Single>
+    {
+        #region lifecycle
+
+        public ScalarLinearCurve() { }
+
+        protected ScalarLinearCurve(ScalarLinearCurve other) : base(other) { }
+
+        #endregion
+
         #region API
 
         public override Single GetSample(float offset)
         {
             var sample = FindSample(offset);
 
-            return sample.Item1 * (1-sample.Item3) + (sample.Item2 * sample.Item3);
+            return sample.Item1 * (1 - sample.Item3) + (sample.Item2 * sample.Item3);
+        }
+
+        public float GetControlPoint(float key)
+        {
+            var sample = FindSample(key);
+            return sample.Item3 <= 0.5f ? sample.Item1 : sample.Item2;
         }
 
         public void SetControlPoint(float offset, Single value)
@@ -132,72 +227,86 @@ namespace SharpGLTF.Animations
         #endregion
     }
 
-    class SingleCubicCurve : Curve<(Single, Single, Single), Single>, ICubicCurveWriter<Single>
+    class ScalarSplineCurve : Curve<_SplinePoint<Single>, Single>, ISplineCurve<Single>
     {
+        #region lifecycle
+
+        public ScalarSplineCurve() { }
+
+        protected ScalarSplineCurve(ScalarSplineCurve other) : base(other) { }
+
+        #endregion
+
         #region API
 
         public override Single GetSample(float offset)
         {
             var sample = FindSample(offset);
 
-            return Hermite(sample.Item1.Item2, sample.Item1.Item3, sample.Item2.Item2, sample.Item2.Item1, sample.Item3);
+            return Hermite(sample.Item1.Point, sample.Item1.OutTangent, sample.Item2.Point, sample.Item2.InTangent, sample.Item3);
         }
 
         private static Single Hermite(Single value1, Single tangent1, Single value2, Single tangent2, float amount)
         {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
+            var hermite = CurveFactory.CalculateHermiteWeights(amount);
 
-            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 * hermite.Item1) + (value2 * hermite.Item2) + (tangent1 * hermite.Item3) + (tangent2 * hermite.Item4);
+        }
 
-            return (value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4);
+        public float GetControlPoint(float key)
+        {
+            var sample = FindSample(key);
+            return sample.Item3 <= 0.5f ? sample.Item1.Point : sample.Item2.Point;
         }
 
         public void SetControlPoint(float key, Single value)
         {
             var val = GetKey(key) ?? default;
-            val.Item2 = value;
+            val.Point = value;
             SetKey(key, val);
         }
 
         public void SetControlPointIn(float key, Single value)
         {
             var val = GetKey(key) ?? default;
-            val.Item1 = value - val.Item2;
+            val.InTangent = val.Point - value;
             SetKey(key, val);
         }
 
         public void SetControlPointOut(float key, Single value)
         {
             var val = GetKey(key) ?? default;
-            val.Item3 = value + val.Item2;
+            val.OutTangent = value - val.Point;
             SetKey(key, val);
         }
 
         public void SetTangentIn(float key, Single value)
         {
             var val = GetKey(key) ?? default;
-            val.Item1 = value;
+            val.InTangent = value;
             SetKey(key, val);
         }
 
         public void SetTangentOut(float key, Single value)
         {
             var val = GetKey(key) ?? default;
-            val.Item3 = value;
+            val.OutTangent = value;
             SetKey(key, val);
         }
 
         #endregion
     }
 
-    class Vector3LinearCurve : Curve<Vector3, Vector3>, ICurveWriter<Vector3>
+    class Vector3LinearCurve : Curve<Vector3, Vector3>, ILinearCurve<Vector3>
     {
+        #region lifecycle
+
+        public Vector3LinearCurve() { }
+
+        protected Vector3LinearCurve(Vector3LinearCurve other) : base(other) { }
+
+        #endregion
+
         #region API
 
         public override Vector3 GetSample(float offset)
@@ -207,6 +316,12 @@ namespace SharpGLTF.Animations
             return Vector3.Lerp(sample.Item1, sample.Item2, sample.Item3);
         }
 
+        public Vector3 GetControlPoint(float key)
+        {
+            var sample = FindSample(key);
+            return sample.Item3 <= 0.5f ? sample.Item1 : sample.Item2;
+        }
+
         public void SetControlPoint(float offset, Vector3 value)
         {
             SetKey(offset, value);
@@ -215,72 +330,86 @@ namespace SharpGLTF.Animations
         #endregion
     }
 
-    class Vector3CubicCurve : Curve<(Vector3, Vector3, Vector3), Vector3>, ICubicCurveWriter<Vector3>
+    class Vector3SplineCurve : Curve<_SplinePoint<Vector3>, Vector3>, ISplineCurve<Vector3>
     {
+        #region lifecycle
+
+        public Vector3SplineCurve() { }
+
+        protected Vector3SplineCurve(Vector3SplineCurve other) : base(other) { }
+
+        #endregion
+
         #region API
 
         public override Vector3 GetSample(float offset)
         {
             var sample = FindSample(offset);
 
-            return Hermite(sample.Item1.Item2, sample.Item1.Item3, sample.Item2.Item2, sample.Item2.Item1, sample.Item3);
+            return Hermite(sample.Item1.Point, sample.Item1.OutTangent, sample.Item2.Point, sample.Item2.InTangent, sample.Item3);
         }
 
-        private static Vector3 Hermite(Vector3 value1, Vector3 tangent1, Vector3 value2, Vector3 tangent2, float amount)
+        private static Vector3 Hermite(Vector3 pointStart, Vector3 tangentOut, Vector3 pointEnd, Vector3 tangentIn, float amount)
         {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
+            var hermite = CurveFactory.CalculateHermiteWeights(amount);
 
-            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 (pointStart * hermite.Item1) + (pointEnd * hermite.Item2) + (tangentOut * hermite.Item3) + (tangentIn * hermite.Item4);
+        }
 
-            return (value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4);
+        public Vector3 GetControlPoint(float key)
+        {
+            var sample = FindSample(key);
+            return sample.Item3 <= 0.5f ? sample.Item1.Point : sample.Item2.Point;
         }
 
         public void SetControlPoint(float key, Vector3 value)
         {
             var val = GetKey(key) ?? default;
-            val.Item2 = value;
+            val.Point = value;
             SetKey(key, val);
         }
 
         public void SetControlPointIn(float key, Vector3 value)
         {
             var val = GetKey(key) ?? default;
-            val.Item1 = value - val.Item2;
+            val.InTangent = val.Point - value;
             SetKey(key, val);
         }
 
         public void SetControlPointOut(float key, Vector3 value)
         {
             var val = GetKey(key) ?? default;
-            val.Item3 = value + val.Item2;
+            val.OutTangent = value - val.Point;
             SetKey(key, val);
         }
 
         public void SetTangentIn(float key, Vector3 value)
         {
             var val = GetKey(key) ?? default;
-            val.Item1 = value;
+            val.InTangent = value;
             SetKey(key, val);
         }
 
         public void SetTangentOut(float key, Vector3 value)
         {
             var val = GetKey(key) ?? default;
-            val.Item3 = value;
+            val.OutTangent = value;
             SetKey(key, val);
         }
 
         #endregion
     }
 
-    class QuaternionLinearCurve : Curve<Quaternion, Quaternion>, ICurveWriter<Quaternion>
+    class QuaternionLinearCurve : Curve<Quaternion, Quaternion>, ILinearCurve<Quaternion>
     {
+        #region lifecycle
+
+        public QuaternionLinearCurve() { }
+
+        protected QuaternionLinearCurve(QuaternionLinearCurve other) : base(other) { }
+
+        #endregion
+
         #region API
 
         public override Quaternion GetSample(float offset)
@@ -290,6 +419,12 @@ namespace SharpGLTF.Animations
             return Quaternion.Slerp(sample.Item1, sample.Item2, sample.Item3);
         }
 
+        public Quaternion GetControlPoint(float key)
+        {
+            var sample = FindSample(key);
+            return sample.Item3 <= 0.5f ? sample.Item1 : sample.Item2;
+        }
+
         public void SetControlPoint(float offset, Quaternion value)
         {
             SetKey(offset, value);
@@ -298,28 +433,83 @@ namespace SharpGLTF.Animations
         #endregion
     }
 
-    class QuaternionCubicCurve : Curve<(Quaternion, Quaternion, Quaternion), Quaternion>
+    class QuaternionSplineCurve : Curve<_SplinePoint<Quaternion>, Quaternion> , ISplineCurve<Quaternion>
     {
+        #region lifecycle
+
+        public QuaternionSplineCurve() { }
+
+        protected QuaternionSplineCurve(QuaternionSplineCurve other) : base(other) { }
+
+        #endregion
+
+        #region API
+
         public override Quaternion GetSample(float offset)
         {
             var sample = FindSample(offset);
 
-            return Hermite(sample.Item1.Item2, sample.Item1.Item3, sample.Item2.Item2, sample.Item2.Item1, sample.Item3);
+            return Hermite(sample.Item1.Point, sample.Item1.OutTangent, sample.Item2.Point, sample.Item2.InTangent, sample.Item3);
         }
 
         private static Quaternion Hermite(Quaternion value1, Quaternion tangent1, Quaternion value2, Quaternion tangent2, float amount)
         {
-            // http://mathworld.wolfram.com/HermitePolynomial.html
+            var hermite = CurveFactory.CalculateHermiteWeights(amount);
 
-            var squared = amount * amount;
-            var cubed = amount * squared;
+            return Quaternion.Normalize((value1 * hermite.Item1) + (value2 * hermite.Item2) + (tangent1 * hermite.Item3) + (tangent2 * hermite.Item4));
+        }
 
-            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;
+        public Quaternion GetControlPoint(float key)
+        {
+            var sample = FindSample(key);
+            return sample.Item3 <= 0.5f ? sample.Item1.Point : sample.Item2.Point;
+        }
+
+        public void SetControlPoint(float key, Quaternion value)
+        {
+            var val = GetKey(key) ?? default;
+            val.Point = Quaternion.Normalize(value);
+            SetKey(key, val);
+        }
+
+        public void SetControlPointIn(float key, Quaternion value)
+        {
+            var val = GetKey(key) ?? default;
 
-            return Quaternion.Normalize((value1 * part1) + (value2 * part2) + (tangent1 * part3) + (tangent2 * part4));
+            var inv = Quaternion.Inverse(value);
+            value = Quaternion.Concatenate(val.Point, inv);
+            value = Quaternion.Normalize(value);
+
+            val.InTangent = value;
+            SetKey(key, val);
         }
+
+        public void SetControlPointOut(float key, Quaternion value)
+        {
+            var val = GetKey(key) ?? default;
+
+            var inv = Quaternion.Inverse(val.Point);
+            value = Quaternion.Concatenate(value, inv);
+            value = Quaternion.Normalize(value);
+
+            val.OutTangent = value;
+            SetKey(key, val);
+        }
+
+        public void SetTangentIn(float key, Quaternion value)
+        {
+            var val = GetKey(key) ?? default;
+            val.InTangent = Quaternion.Normalize(value);
+            SetKey(key, val);
+        }
+
+        public void SetTangentOut(float key, Quaternion value)
+        {
+            var val = GetKey(key) ?? default;
+            val.OutTangent = Quaternion.Normalize(value);
+            SetKey(key, val);
+        }
+
+        #endregion
     }
 }

+ 27 - 35
src/SharpGLTF.Toolkit/Geometry/PrimitiveBuilder.cs

@@ -17,10 +17,7 @@ namespace SharpGLTF.Geometry
 
         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;
+        IVertexBuilder GetVertex(int index);
 
         IReadOnlyList<int> Indices { get; }
 
@@ -35,15 +32,7 @@ namespace SharpGLTF.Geometry
 
     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;
+        void AddTriangle(IVertexBuilder a, IVertexBuilder b, IVertexBuilder c);
     }
 
     /// <summary>
@@ -103,11 +92,13 @@ namespace SharpGLTF.Geometry
 
         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()
             {
-                throw new NotImplementedException();
+                foreach (var item in this) yield return item;
             }
         }
 
@@ -206,6 +197,27 @@ namespace SharpGLTF.Geometry
             _Indices.Add(bb);
         }
 
+        /// <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>
+        public void 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;
+
+            AddTriangle(aa, bb, cc);
+        }
+
         /// <summary>
         /// Adds a triangle.
         /// </summary>
@@ -291,27 +303,7 @@ 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>());
-        }
+        IVertexBuilder IPrimitive<TMaterial>.GetVertex(int index) { return _Vertices[index]; }
 
         private IEnumerable<int> _GetPointIndices()
         {

+ 5 - 0
src/SharpGLTF.Toolkit/Geometry/VertexBuilder.cs

@@ -13,6 +13,11 @@ namespace SharpGLTF.Geometry
         IVertexMaterial GetMaterial();
         IVertexSkinning GetSkinning();
 
+        VertexBuilder<TvPP, TvMM, TvSS> ConvertTo<TvPP, TvMM, TvSS>()
+            where TvPP : struct, IVertexGeometry
+            where TvMM : struct, IVertexMaterial
+            where TvSS : struct, IVertexSkinning;
+
         // void SetGeometry(IVertexGeometry);
         // void SetMaterial(IVertexMaterial);
         // void SetSkinning(IVertexSkinning);

+ 3 - 1
src/SharpGLTF.Toolkit/Geometry/VertexTypes/VertexUtils.cs

@@ -54,7 +54,9 @@ namespace SharpGLTF.Geometry.VertexTypes
             // total number of vertices
             var totalCount = vertexBlocks.Sum(item => item.Count);
 
-            var firstVertex = vertexBlocks.First().First();
+            var firstVertex = vertexBlocks
+                .First(item => item.Count > 0)
+                .First();
 
             var tvg = firstVertex.GetGeometry().GetType();
             var tvm = firstVertex.GetMaterial().GetType();

+ 5 - 0
src/SharpGLTF.Toolkit/Materials/MaterialBuilder.cs

@@ -18,6 +18,11 @@ namespace SharpGLTF.Materials
             Name = name;
         }
 
+        public static MaterialBuilder CreateDefault()
+        {
+            return new MaterialBuilder("Default");
+        }
+
         #endregion
 
         #region data

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

@@ -136,6 +136,39 @@ namespace SharpGLTF.Scenes
             return c;
         }
 
+        public Animations.Animatable<Vector3> UseScale()
+        {
+            if (_Scale == null)
+            {
+                _Scale = new Animations.Animatable<Vector3>();
+                _Scale.Default = Vector3.One;
+            }
+
+            return _Scale;
+        }
+
+        public Animations.Animatable<Quaternion> UseRotation()
+        {
+            if (_Rotation == null)
+            {
+                _Rotation = new Animations.Animatable<Quaternion>();
+                _Rotation.Default = Quaternion.Identity;
+            }
+
+            return _Rotation;
+        }
+
+        public Animations.Animatable<Vector3> UseTranslation()
+        {
+            if (_Translation == null)
+            {
+                _Translation = new Animations.Animatable<Vector3>();
+                _Translation.Default = Vector3.One;
+            }
+
+            return _Translation;
+        }
+
         public Transforms.AffineTransform GetLocalTransform(string animationTrack, float time)
         {
             if (animationTrack == null) return this.LocalTransform;

+ 78 - 10
tests/SharpGLTF.Tests/AnimationSamplingTests.cs

@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Numerics;
 using System.Text;
 
@@ -11,11 +12,72 @@ namespace SharpGLTF
     [Category("Core")]
     public class AnimationSamplingTests
     {
+        [Test]
+        public void TestHermiteInterpolation1()
+        {
+            var p1 = new Vector2(0, 0);
+            var p2 = new Vector2(0, 1);
+            var p3 = new Vector2(1, 1);
+            var p4 = new Vector2(1, 0);
+
+            var ppp = new List<Vector2>();
+
+            for (float amount = 0; amount <= 1; amount += 0.01f)
+            {
+                var hermite = Transforms.AnimationSamplerFactory.CalculateHermiteWeights(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);
+            }
+
+            var series1 = ppp.ToPointSeries();
+            var series2 = new[] { p1, p2, p3, p4 }.ToLineSeries();
+
+            new[] { series1, series2 }.AttachToCurrentTest("plot.png");
+        }
+
+        [Test]
+        public void TestHermiteInterpolation2()
+        {
+            var p1 = new Vector2(0, 0);
+            var p2 = new Vector2(0.1f, 5);
+            var p3 = new Vector2(0.7f, 3);
+            var p4 = new Vector2(1, 0);            
+
+            var ppp = new List<Vector2>();
+
+            for (float amount = 0; amount <= 1; amount += 0.01f)
+            {
+                var hermite = Transforms.AnimationSamplerFactory.CalculateHermiteWeights(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);
+            }
+
+            var series1 = ppp.ToPointSeries();
+            var series2 = new[] { p1, p2, p3, p4 }.ToLineSeries();
+
+            new[] { series1, series2 }.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,20 +92,26 @@ namespace SharpGLTF
         [Test]
         public void TestVector3CubicSplineSampling()
         {
-            var hermite = Transforms.AnimationSamplerFactory.Hermite(new Vector3(1, 0, 0), new Vector3(1, 2, 0), new Vector3(3, 0, 0), new Vector3(3, -2, 0), 0.5f);
+            var sampler = Transforms.AnimationSamplerFactory.CreateSplineSamplerFunc(_TransAnim);
 
-            var sampler = Transforms.AnimationSamplerFactory.CreateCubicSamplerFunc(_TransAnim);
+            var points = new List<Vector3>();
 
-            var a = sampler(0);
-            var b = sampler(1);
-            var bc = sampler(1.5f);
-            var c = sampler(2);
+            for(int i=0; i < 300; ++i)
+            {
+                var sample = sampler(((float)i) / 100.0f);
+                points.Add( sample );
+            }
+
+            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 = Transforms.AnimationSamplerFactory.CreateSplineSamplerFunc(_RotAnim);
 
             var a = sampler(0);
             var b = sampler(1);

+ 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);
         }
     }
 }

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

@@ -0,0 +1,296 @@
+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,
+
+            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 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);
+        }
+    }
+}

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

@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Linq;
+using System.Numerics;
+
+using NUnit.Framework;
+
+using SharpGLTF.Schema2.Authoring;
+
+namespace SharpGLTF.Scenes
+{
+    using Geometry;
+    
+    using VPOSNRM = Geometry.VertexBuilder<Geometry.VertexTypes.VertexPositionNormal, Geometry.VertexTypes.VertexEmpty, Geometry.VertexTypes.VertexEmpty>;
+
+
+    [Category("Toolkit.Scenes")]
+    public class SceneBuilderTests
+    {
+        [Test]
+        public void CreateSceneWithRandomCubes()
+        {
+            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 m = materials[rnd.Next(0, 10)];
+                var cube = VPOSNRM.CreateCompatibleMesh("cube");
+                cube.VertexPreprocessor.SetDebugPreprocessors();
+                cube.AddCube(m, Matrix4x4.Identity);
+                cube.Validate();
+
+                // create transform
+                var r = rnd.NextVector3() * 5;
+                var xform = Matrix4x4.CreateFromYawPitchRoll(r.X, r.Y, r.Z) * Matrix4x4.CreateTranslation(rnd.NextVector3() * 25);
+
+                scene.AddMesh(cube, xform);                
+            }
+
+            // save the model as GLB
+
+            scene.AttachToCurrentTest("cubes.glb");
+        }
+
+    }
+}

+ 10 - 9
tests/SharpGLTF.Tests/Schema2/Authoring/SolidMeshUtils.cs

@@ -16,17 +16,19 @@ namespace SharpGLTF.Schema2.Authoring
     {
         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);
+            var p = meshBuilder.UsePrimitive(material);
 
-            meshBuilder._AddCubeFace(material, Vector3.UnitY, Vector3.UnitZ, Vector3.UnitX, xform);
-            meshBuilder._AddCubeFace(material, -Vector3.UnitY, Vector3.UnitX, Vector3.UnitZ, xform);
+            p._AddCubeFace(Vector3.UnitX, Vector3.UnitY, Vector3.UnitZ, xform);
+            p._AddCubeFace(-Vector3.UnitX, Vector3.UnitZ, Vector3.UnitY, xform);
 
-            meshBuilder._AddCubeFace(material, Vector3.UnitZ, Vector3.UnitX, Vector3.UnitY, xform);
-            meshBuilder._AddCubeFace(material, -Vector3.UnitZ, Vector3.UnitY, Vector3.UnitX, xform);
+            p._AddCubeFace(Vector3.UnitY, Vector3.UnitZ, Vector3.UnitX, xform);
+            p._AddCubeFace(-Vector3.UnitY, Vector3.UnitX, Vector3.UnitZ, xform);
+
+            p._AddCubeFace(Vector3.UnitZ, Vector3.UnitX, Vector3.UnitY, xform);
+            p._AddCubeFace(-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)
+        private static void _AddCubeFace<TMaterial>(this PrimitiveBuilder<TMaterial, VPOSNRM, VEMPTY, VEMPTY> 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);
@@ -34,8 +36,7 @@ namespace SharpGLTF.Schema2.Authoring
             var p4 = Vector3.Transform(origin - axisX + axisY, xform);
             var n = Vector3.Normalize(Vector3.TransformNormal(origin, xform));
 
-            meshBuilder.UsePrimitive(material)
-                .AddConvexPolygon
+            primitiveBuilder.AddConvexPolygon
                 (
                 new VPOSNRM(p1, n),
                 new VPOSNRM(p2, n),

+ 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.1.1" />
+    <PackageReference Include="PLplot" Version="5.13.7" />    
   </ItemGroup>
 
   <ItemGroup>

+ 7 - 0
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(" ", "_");