diff --git a/Source/ROUtils/ProceduralTools/DragCubeTool.cs b/Source/ROUtils/ProceduralTools/DragCubeTool.cs
new file mode 100644
index 0000000..c455b33
--- /dev/null
+++ b/Source/ROUtils/ProceduralTools/DragCubeTool.cs
@@ -0,0 +1,427 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using UnityEngine.Profiling;
+
+namespace ROUtils
+{
+ ///
+ /// Common tool for various procedural part mods for generating drag cubes.
+ ///
+ public class DragCubeTool : MonoBehaviour
+ {
+ private static readonly Dictionary _cacheDict = new Dictionary();
+ private static readonly HashSet _inProgressMultiCubeRenderings = new HashSet();
+ private static bool _statsRoutineStarted = false;
+ private static uint _cubesRenderedThisFrame = 0;
+ private static uint _cacheHitsThisFrame = 0;
+ private static long _elapsedTicks = 0;
+
+ private string _shapeKey;
+ private Coroutine _multiCubeRoutine;
+
+ ///
+ /// Globally enable of disable drag cube caching.
+ ///
+ public static bool UseCache { get; set; } = true;
+ ///
+ /// Whether to validate all cubes that got fetched from cache against freshly-rendered ones.
+ ///
+ public static bool ValidateCubes { get; set; } = false;
+ ///
+ /// Max number of items in cache. Once that number is reached then the cache is cleared entirely.
+ ///
+ public static uint MaxCacheSize { get; set; } = 5000;
+
+ public Part Part { get; private set; }
+
+ ///
+ /// Creates and assigns a drag cube for the given procedural part.
+ /// This process can have one to many frames of delay.
+ ///
+ /// Part to create drag cube for
+ /// Key that uniquely identifies the geometry of the part.Used in caching logic. Use null if no caching is desired.
+ ///
+ public static DragCubeTool UpdateDragCubes(Part p, string shapeKey = null)
+ {
+ var tool = p.GetComponent();
+ if (tool == null)
+ {
+ tool = p.gameObject.AddComponent();
+ tool.Part = p;
+ }
+ tool._shapeKey = shapeKey;
+ return tool;
+ }
+
+ ///
+ /// Creates and assigns a drag cube for the given procedural part.
+ /// Use only when you know that the part is ready for drag cube rendering. Otherwise use UpdateDragCubes.
+ ///
+ /// Part to create drag cube for
+ /// Key that uniquely identifies the geometry of the part.Used in caching logic. Use null if no caching is desired.
+ /// Thrown when the part is not yet ready for drag cube rendering
+ public static void UpdateDragCubesImmediate(Part p, string shapeKey = null)
+ {
+ if (!Ready(p))
+ throw new InvalidOperationException("Not ready for drag cube rendering yet");
+
+ UpdateCubes(p, shapeKey);
+ }
+
+ internal static void ClearStaticState()
+ {
+
+ _inProgressMultiCubeRenderings.Clear();
+ _statsRoutineStarted = false;
+ _cubesRenderedThisFrame = 0;
+ _cacheHitsThisFrame = 0;
+ _elapsedTicks = 0;
+ }
+
+ public void FixedUpdate()
+ {
+ if (Part == null)
+ {
+ // Somehow part can become null when doing cloning in symmetry
+ Destroy(this);
+ return;
+ }
+
+ if (_multiCubeRoutine == null && Ready())
+ UpdateCubes();
+ }
+
+ public bool Ready() => Ready(Part);
+
+ private static bool Ready(Part p)
+ {
+ if (HighLogic.LoadedSceneIsFlight)
+ return FlightGlobals.ready;
+ if (HighLogic.LoadedSceneIsEditor)
+ return p.localRoot == EditorLogic.RootPart && p.gameObject.layer != LayerMask.NameToLayer("TransparentFX");
+ return true;
+ }
+
+ private void UpdateCubes()
+ {
+ // Assume a single animation module
+ IMultipleDragCube multiCube = Part.FindModuleImplementing();
+ if (multiCube?.IsMultipleCubesActive ?? false)
+ {
+ Debug.Log($"[DragCubeTool] Need to render multiple cubes for {Part.partInfo.name}");
+ if (!UseCache || _shapeKey == null || !TryUpdateMultiCubesFromCache(multiCube))
+ {
+ // Render over multiple frames for animated parts
+ _multiCubeRoutine = StartCoroutine(UpdateMultiCubesRoutine());
+ }
+ }
+ else
+ {
+ UpdateCubes(Part, _shapeKey);
+ Destroy(this);
+ }
+ }
+
+ ///
+ /// If all cubes are cached then these can be assigned on the same frame.
+ ///
+ ///
+ ///
+ private bool TryUpdateMultiCubesFromCache(IMultipleDragCube multiCube)
+ {
+ long startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
+ List newCubeList = new List();
+ foreach (string cName in multiCube.GetDragCubeNames())
+ {
+ string shapeKey = $"{_shapeKey}${cName}";
+ if (!_cacheDict.TryGetValue(shapeKey, out DragCube dragCube))
+ {
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+ return false;
+ }
+ else
+ {
+ _cacheHitsThisFrame++;
+ dragCube = CloneCube(dragCube);
+ newCubeList.Add(dragCube);
+ }
+ }
+
+ if (ValidateCubes)
+ {
+ // Validation will run the multicube generation routine which will in turn assign these to the part as well
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+ _multiCubeRoutine = StartCoroutine(MultiCubeValidationRoutine(newCubeList));
+ }
+ else
+ {
+ AssignMultiCubes(newCubeList);
+
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+
+ NotifyFARIfNeeded(Part);
+ EnsureStatsRoutineStarted(Part);
+
+ Destroy(this);
+ }
+
+ return true;
+ }
+
+ private IEnumerator UpdateMultiCubesRoutine(bool isValidation = false)
+ {
+ long startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
+ Part p = Instantiate(Part, Vector3.zero, Quaternion.identity);
+ GameObject gameObject = p.gameObject;
+ bool hasVessel = p.GetComponent() != null;
+ if (hasVessel)
+ p.vessel.mapObject = null;
+
+ DragCubeSystem.Instance.SetupPartForRender(p, gameObject);
+ IMultipleDragCube multiCube = p.FindModuleImplementing();
+
+ List newCubeList = new List();
+ foreach (string cName in multiCube.GetDragCubeNames())
+ {
+ string shapeKey = _shapeKey == null ? null : $"{_shapeKey}${cName}";
+ bool alreadyInProgress = !_inProgressMultiCubeRenderings.Add(shapeKey);
+ if (!alreadyInProgress || isValidation)
+ {
+ try
+ {
+ Debug.Log($"[DragCubeTool] Rendering pos {cName}");
+ multiCube.AssumeDragCubePosition(cName);
+
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+ EnsureStatsRoutineStarted(Part);
+
+ // Animations do not propagate to the meshes immediately so need to wait for the next frame before rendering
+ yield return null;
+
+ startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
+
+ DragCube dragCube = DragCubeSystem.Instance.RenderProceduralDragCube(p);
+ dragCube.Name = cName;
+ newCubeList.Add(dragCube);
+ _cubesRenderedThisFrame++;
+ AddCubeToCache(dragCube, shapeKey);
+ }
+ finally
+ {
+ _inProgressMultiCubeRenderings.Remove(shapeKey);
+ }
+ }
+ else
+ {
+ // TODO: shouldn't instantiate a new part in this case. Kinda painful to implement though.
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+
+ DragCube dragCube;
+ do
+ {
+ Debug.Log($"[DragCubeTool] Rendering {cName} already in progress, waiting...");
+ yield return null;
+ }
+ while (!_cacheDict.TryGetValue(shapeKey, out dragCube));
+
+ dragCube = CloneCube(dragCube);
+ startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
+ Debug.Log($"[DragCubeTool] Finished waiting for {cName}");
+ newCubeList.Add(dragCube);
+ }
+ }
+
+ gameObject.SetActive(false);
+ Destroy(gameObject);
+ if (hasVessel)
+ FlightCamera.fetch.CycleCameraHighlighter();
+
+ AssignMultiCubes(newCubeList);
+
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+
+ NotifyFARIfNeeded(Part);
+ EnsureStatsRoutineStarted(Part);
+
+ Destroy(this);
+ }
+
+ private static void UpdateCubes(Part p, string shapeKey = null)
+ {
+ Profiler.BeginSample("UpdateCubes");
+ long startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
+ if (!UseCache || shapeKey == null || !_cacheDict.TryGetValue(shapeKey, out DragCube dragCube))
+ {
+ dragCube = DragCubeSystem.Instance.RenderProceduralDragCube(p);
+ _cubesRenderedThisFrame++;
+ AddCubeToCache(dragCube, shapeKey);
+ }
+ else
+ {
+ _cacheHitsThisFrame++;
+ dragCube = CloneCube(dragCube);
+ if (ValidateCubes)
+ RunCubeValidation(p, dragCube, shapeKey);
+ }
+
+ p.DragCubes.ClearCubes();
+ p.DragCubes.Cubes.Add(dragCube);
+ p.DragCubes.ResetCubeWeights();
+ p.DragCubes.ForceUpdate(true, true, false);
+ p.DragCubes.SetDragWeights();
+
+ _elapsedTicks += System.Diagnostics.Stopwatch.GetTimestamp() - startTicks;
+ Profiler.EndSample();
+
+ NotifyFARIfNeeded(p);
+ EnsureStatsRoutineStarted(p);
+ }
+
+ private static void AddCubeToCache(DragCube dragCube, string shapeKey)
+ {
+ if (UseCache && shapeKey != null && PartLoader.Instance.IsReady())
+ {
+ // Keep a pristine copy in cache. I.e the instance must not be be used by a part.
+ DragCube clonedCube = CloneCube(dragCube);
+ _cacheDict[shapeKey] = clonedCube;
+ }
+ }
+
+ private static void NotifyFARIfNeeded(Part p)
+ {
+ if (ModUtils.IsFARInstalled)
+ p.SendMessage("GeometryPartModuleRebuildMeshData");
+ }
+
+ private static void EnsureStatsRoutineStarted(Part p)
+ {
+ if (!_statsRoutineStarted && PartLoader.Instance.IsReady())
+ p.StartCoroutine(StatsCoroutine());
+ }
+
+ private static IEnumerator StatsCoroutine()
+ {
+ _statsRoutineStarted = true;
+ yield return new WaitForEndOfFrame();
+ _statsRoutineStarted = false;
+
+ double timeMs = _elapsedTicks / (System.Diagnostics.Stopwatch.Frequency / 1000d);
+ Debug.Log($"[DragCubeTool] Rendered {_cubesRenderedThisFrame} cubes; fetched {_cacheHitsThisFrame} from cache; exec time: {timeMs:F1}ms");
+ _cacheHitsThisFrame = 0;
+ _cubesRenderedThisFrame = 0;
+ _elapsedTicks = 0;
+
+ if (_cacheDict.Count > MaxCacheSize && _inProgressMultiCubeRenderings.Count == 0)
+ {
+ Debug.Log($"[DragCubeTool] Cache limit reached ({_cacheDict.Count} / {MaxCacheSize}), emptying...");
+ _cacheDict.Clear();
+ }
+ }
+
+ private void AssignMultiCubes(List cubes)
+ {
+ if (Part.DragCubes.Cubes.Count == cubes.Count)
+ {
+ // Copy over weights from part cubes. Most likely these were already updated to reflect animation state.
+ foreach (DragCube c in cubes)
+ {
+ DragCube c2 = Part.DragCubes.GetCube(c.name);
+ if (c2 != null)
+ c.Weight = c2.Weight;
+ }
+ }
+
+ Part.DragCubes.ClearCubes();
+ Part.DragCubes.Cubes.AddRange(cubes);
+ Part.DragCubes.ForceUpdate(true, true, false);
+ Part.DragCubes.SetDragWeights();
+ }
+
+ private static DragCube CloneCube(DragCube dragCube)
+ {
+ return new DragCube
+ {
+ area = dragCube.area,
+ drag = dragCube.drag,
+ depth = dragCube.depth,
+ dragModifiers = dragCube.dragModifiers,
+ center = dragCube.center,
+ size = dragCube.size,
+ name = dragCube.name
+ };
+ }
+
+ private static void RunCubeValidation(Part p, DragCube cacheCube, string shapeKey)
+ {
+ DragCube renderedCube = DragCubeSystem.Instance.RenderProceduralDragCube(p);
+ RunCubeValidation(cacheCube, renderedCube, p, shapeKey);
+ }
+
+ private IEnumerator MultiCubeValidationRoutine(List cacheCubeList)
+ {
+ yield return UpdateMultiCubesRoutine(isValidation: true);
+
+ IMultipleDragCube multiCube = Part.FindModuleImplementing();
+ string[] names = multiCube.GetDragCubeNames();
+ if (names.Length != cacheCubeList.Count)
+ {
+ Debug.LogError($"[DragCubeTool] Cube count mismatch in MultiCubeValidationRoutine");
+ yield break;
+ }
+
+ for (int i = 0; i < names.Length; i++)
+ {
+ string cName = names[i];
+ string shapeKey = $"{_shapeKey}${cName}";
+ if (!_cacheDict.TryGetValue(shapeKey, out DragCube dragCube))
+ {
+ // cache got cleared?
+ Debug.LogWarning($"[DragCubeTool] Failed to fetch {shapeKey} from cache in MultiCubeValidationRoutine");
+ yield break;
+ }
+
+ RunCubeValidation(cacheCubeList[i], dragCube, Part, shapeKey);
+ }
+ }
+
+ private static void RunCubeValidation(DragCube cacheCube, DragCube renderedCube, Part p, string shapeKey)
+ {
+ // drag components randomly switch places so sort the arrays before comparing
+ var cacheSortedDrag = cacheCube.drag.OrderBy(v => v).ToArray();
+ var renderSortedDrag = renderedCube.drag.OrderBy(v => v).ToArray();
+
+ if (cacheCube.name != renderedCube.name ||
+ !ArraysNearlyEqual(cacheCube.area, renderedCube.area, 0.005f) ||
+ !ArraysNearlyEqual(cacheSortedDrag, renderSortedDrag, 0.05f) ||
+ //!ArraysNearlyEqual(cacheCube.depth, renderedCube.depth, 0.01f) ||
+ !ArraysNearlyEqual(cacheCube.dragModifiers, renderedCube.dragModifiers, 0.005f) ||
+ !VectorsNearlyEqual(cacheCube.center, renderedCube.center, 0.005f) ||
+ !VectorsNearlyEqual(cacheCube.size, renderedCube.size, 0.005f))
+ {
+ Debug.LogError($"[DragCubeTool] Mismatch in cached cube for part {p.partInfo.name}, key {shapeKey}:");
+ Debug.LogError($"Cache: {cacheCube.SaveToString()}");
+ Debug.LogError($"Renderd: {renderedCube.SaveToString()}");
+ }
+ }
+
+ private static bool ArraysNearlyEqual(float[] arr1, float[] arr2, float tolerance)
+ {
+ for (int i = 0; i < arr1.Length; i++)
+ {
+ float a = arr1[i];
+ float b = arr2[i];
+ if (Math.Abs(a - b) > tolerance)
+ return false;
+ }
+ return true;
+ }
+
+ private static bool VectorsNearlyEqual(Vector3 v1, Vector3 v2, float tolerance)
+ {
+ return (v1 - v2).sqrMagnitude < tolerance * tolerance;
+ }
+ }
+}
diff --git a/Source/ROUtils/ProceduralTools/DragCubeToolEvents.cs b/Source/ROUtils/ProceduralTools/DragCubeToolEvents.cs
new file mode 100644
index 0000000..d6bc6ad
--- /dev/null
+++ b/Source/ROUtils/ProceduralTools/DragCubeToolEvents.cs
@@ -0,0 +1,21 @@
+using UnityEngine.SceneManagement;
+
+namespace ROUtils
+{
+ internal class DragCubeToolEvents : HostedSingleton
+ {
+ public DragCubeToolEvents(SingletonHost host) : base(host)
+ {
+ }
+
+ public override void Awake()
+ {
+ SceneManager.sceneLoaded += SceneLoaded;
+ }
+
+ private void SceneLoaded(Scene scene, LoadSceneMode mode)
+ {
+ DragCubeTool.ClearStaticState();
+ }
+ }
+}
diff --git a/Source/ROUtils/Properties/AssemblyInfo.cs b/Source/ROUtils/Properties/AssemblyInfo.cs
index a1e6684..1f2d9b3 100644
--- a/Source/ROUtils/Properties/AssemblyInfo.cs
+++ b/Source/ROUtils/Properties/AssemblyInfo.cs
@@ -33,13 +33,13 @@
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.1.0")] // Don't change for every release
+[assembly: AssemblyVersion("1.1.0.0")] // Don't change for every release
#if CIBUILD
[assembly: AssemblyFileVersion("@MAJOR@.@MINOR@.@PATCH@.@BUILD@")]
[assembly: KSPAssembly("ROUtils", @MAJOR@, @MINOR@, @PATCH@)]
#else
-[assembly: AssemblyFileVersion("1.0.1.0")]
-[assembly: KSPAssembly("ROUtils", 1, 0, 1)]
+[assembly: AssemblyFileVersion("1.1.0.0")]
+[assembly: KSPAssembly("ROUtils", 1, 1, 0)]
#endif
-[assembly: KSPAssemblyDependency("KSPCommunityFixes", 1, 22, 1)]
\ No newline at end of file
+[assembly: KSPAssemblyDependency("KSPCommunityFixes", 1, 22, 1)]
diff --git a/Source/ROUtils/ROUtils.csproj b/Source/ROUtils/ROUtils.csproj
index c916fd8..9c555fa 100644
--- a/Source/ROUtils/ROUtils.csproj
+++ b/Source/ROUtils/ROUtils.csproj
@@ -122,11 +122,13 @@
+
+
diff --git a/Source/ROUtils/Utils/ModUtils.cs b/Source/ROUtils/Utils/ModUtils.cs
index 20259c7..693ffdd 100644
--- a/Source/ROUtils/Utils/ModUtils.cs
+++ b/Source/ROUtils/Utils/ModUtils.cs
@@ -24,7 +24,19 @@ public static bool IsRP1Installed
}
}
-
+ private static bool? _isFARInstalled;
+ public static bool IsFARInstalled
+ {
+ get
+ {
+ if (!_isFARInstalled.HasValue)
+ {
+ _isFARInstalled = AssemblyLoader.loadedAssemblies.Any(a => a.assembly.GetName().Name == "FerramAerospaceResearch");
+ }
+ return _isFARInstalled.Value;
+ }
+ }
+
private static bool? _isTestFlightInstalled = null;
private static bool? _isTestLiteInstalled = null;