diff --git a/GameData/KSPCommunityPartModules/Localization/en-us.cfg b/GameData/KSPCommunityPartModules/Localization/en-us.cfg
index d0f4d05..3fe2888 100644
--- a/GameData/KSPCommunityPartModules/Localization/en-us.cfg
+++ b/GameData/KSPCommunityPartModules/Localization/en-us.cfg
@@ -4,5 +4,14 @@ Localization
{
// ModuleAutoCutDrogue
#KSPCPM_CutDrogues = Auto-Cut Drogue Chute(s)
+
+ // ModuleToggleTracking
+ #KSPCPM_Tracking = Tracking
+ #KSPCPM_ToggleTracking = Toggle Tracking
+ #KSPCPM_DisableTracking = Disable Tracking
+ #KSPCPM_EnableTracking = Enable Tracking
+
+ // ModuleAttachmentVisuals
+ #KSPCPM_AttachmentVisual = Capping
}
}
diff --git a/README.md b/README.md
index d9135bd..94cb152 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,12 @@ Compatible with **KSP 1.12.3** and up - Available on [CKAN]
- **ModuleDepthMask**
This module allows for parts to have hollow insets that dont clip into other parts, ideal for engine nozzles, landing gear, air intakes, solar panel bays, and more.
- **ModuleNameTag**
This module adds a user-editable name tag to a part, set through an in-game window. Shared by kOS (part:TAG) and kRPC (Part.Tag) so a tag assigned by one is visible to the other. Consuming mods add the module to parts with their own ModuleManager patch; legacy KOSNameTag tags from older kOS/kRPC saves are migrated automatically.
+
+- **ModuleAttachmentVisuals**
This module adds configurable visibility for part objects similar to `ModuleJettison` but with improved configurability and the ability to support multiple instances on the same part.
+
+- **ModuleExclusiveResourceConverter**
This module is a variant of the stock resource converter which will only allow one process to run at a time (for example cannot convert LFO and Monoprop at the same time).
+
+- **ModuleToggleTracking**
This module allows the sun-tracking behaviour of solar/radiator panels or any `ModuelDeployablePart` instance to be enabled or disabled on the fly.
[CKAN]: https://forum.kerbalspaceprogram.com/topic/197082-ckan-the-comprehensive-kerbal-archive-network-v1332-laplace-ksp-2-support/
diff --git a/Source/Modules/ModuleAttachmentVisuals.cs b/Source/Modules/ModuleAttachmentVisuals.cs
new file mode 100644
index 0000000..8732640
--- /dev/null
+++ b/Source/Modules/ModuleAttachmentVisuals.cs
@@ -0,0 +1,284 @@
+/*
+ Usecase: Part Gameobject Visibility based on Stack Attachment Node Occupancy with support for multiple nodes per part.
+ Originally By: Spartwo
+ Originally For: Kerbal Powers
+ License: GNU General Public License v3.0, see https://www.gnu.org/licenses/gpl-3.0.html
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using UnityEngine;
+using KSP.Localization;
+
+namespace KSPCommunityPartModules.Modules
+{
+ public class ModuleAttachmentVisuals : PartModule
+ {
+ [KSPField]
+ public string requiredNodes;
+
+ //visible transforms when node occupied
+ [KSPField]
+ public string showAttached;
+
+ //visible transforms when node unoccupied
+ [KSPField]
+ public string showFree;
+
+ //"Enable/Disable " in the editor
+ [KSPField]
+ public string objectDisplayName;
+
+ [KSPEvent(
+ guiActive = false,
+ guiActiveEditor = true,
+ guiName = "#KSPCPM_Capping"
+ )]
+ public void EventToggleVisual() => ToggleVisual();
+
+ [KSPField(isPersistant = true)]
+ public bool transformEnabled = true;
+
+ // Nodes used as conditions
+ private List nodes = new List();
+
+ // Transforms shown when nodes are occupied
+ private List attachedTransforms = new List();
+
+ // Transforms shown when nodes are free
+ private List freeTransforms = new List();
+
+ private HashSet directChildren = new HashSet();
+
+ public override void OnStart(StartState state)
+ {
+ base.OnStart(state);
+
+ if (HighLogic.LoadedSceneIsEditor)
+ {
+ GameEvents.onEditorPartEvent.Add(OnEditorEvent);
+ }
+
+ CacheInitialChildren();
+ ParseConfig();
+ UpdateVisuals();
+
+ // make this module cheaper in update loops
+ enabled = false;
+ }
+
+ public void OnDestroy()
+ {
+ if (HighLogic.LoadedSceneIsEditor)
+ {
+ GameEvents.onEditorPartEvent.Remove(OnEditorEvent);
+ }
+ }
+
+ private void ParseConfig()
+ {
+ nodes.Clear();
+ attachedTransforms.Clear();
+ freeTransforms.Clear();
+
+ // Parse attachment nodes
+ if (!string.IsNullOrWhiteSpace(requiredNodes))
+ {
+ foreach (string nodeName in requiredNodes.Split(';'))
+ {
+ string nodeId = nodeName.Trim();
+
+ if (string.IsNullOrEmpty(nodeId))
+ continue;
+
+ AttachNode node = part.FindAttachNode(nodeId);
+
+ if (node != null)
+ {
+ nodes.Add(node);
+
+ Debug.Log(
+ $"[ModuleAttachmentVisuals] Found node '{nodeId}' " +
+ $"for part '{part.name}'"
+ );
+ }
+ else
+ {
+ Debug.LogWarning(
+ $"[ModuleAttachmentVisuals] Node '{nodeId}' " +
+ $"not found on part '{part.name}'"
+ );
+ }
+ }
+ }
+
+ // Parse transforms shown when condition is true
+ if (!string.IsNullOrWhiteSpace(showAttached))
+ {
+ foreach (string transformName in showAttached.Split(';'))
+ {
+ string name = transformName.Trim();
+
+ if (string.IsNullOrEmpty(name))
+ continue;
+
+ Transform transform = part.FindModelTransform(name);
+
+ if (transform != null)
+ {
+ attachedTransforms.Add(transform);
+ }
+ else
+ {
+ Debug.LogWarning(
+ $"[ModuleAttachmentVisuals] Could not find attached transform " +
+ $"'{name}' on '{part.name}'"
+ );
+ }
+ }
+ }
+
+ // Parse transforms shown when condition is false
+ if (!string.IsNullOrWhiteSpace(showFree))
+ {
+ foreach (string transformName in showFree.Split(';'))
+ {
+ string name = transformName.Trim();
+
+ if (string.IsNullOrEmpty(name))
+ continue;
+
+ Transform transform = part.FindModelTransform(name);
+
+ if (transform != null)
+ {
+ freeTransforms.Add(transform);
+ }
+ else
+ {
+ Debug.LogWarning(
+ $"[ModuleAttachmentVisuals] Could not find free transform " +
+ $"'{name}' on '{part.name}'"
+ );
+ }
+ }
+ }
+ }
+
+ private void UpdateVisuals()
+ {
+ // The objects only show when:
+ // - transform is enabled
+ // - all config nodes are occupied
+
+ bool allNodesAttached =
+ nodes.Count > 0 &&
+ nodes.All(node => node != null && node.attachedPart != null);
+
+ bool visualActive = transformEnabled && allNodesAttached;
+
+ SetTransforms(attachedTransforms, visualActive);
+ SetTransforms(freeTransforms, !visualActive);
+
+ UpdateToggleEventUI(allNodesAttached);
+ }
+
+ private void UpdateToggleEventUI(bool allNodesAttached)
+ {
+ BaseEvent toggleEvent = Events["EventToggleVisual"];
+
+ // Only offer the toggle when there's actually something capped to toggle
+ toggleEvent.active = allNodesAttached;
+
+ string adjective = transformEnabled
+ ? Localizer.Format("#autoLOC_900889")
+ : Localizer.Format("#autoLOC_247995");
+
+ string displayName = string.IsNullOrWhiteSpace(objectDisplayName)
+ ? Localizer.Format("#KSPCPM_AttachmentVisual")
+ : Localizer.Format(objectDisplayName);
+
+ toggleEvent.guiName = $"{displayName} {adjective}";
+ }
+
+ private void SetTransforms(List transforms, bool active)
+ {
+ foreach (Transform transform in transforms)
+ {
+ if (transform == null)
+ {
+ Debug.LogWarning(
+ $"[ModuleAttachmentVisuals] Transform is null on part '{part.name}'"
+ );
+
+ continue;
+ }
+
+ transform.gameObject.SetActive(active);
+ }
+ }
+
+ private void ToggleVisual()
+ {
+ transformEnabled = !transformEnabled;
+
+ UpdateVisuals();
+ }
+
+ private void OnEditorEvent(ConstructionEventType evt, Part p)
+ {
+ if (
+ evt != ConstructionEventType.PartAttached &&
+ evt != ConstructionEventType.PartDetached
+ )
+ {
+ return;
+ }
+
+ // Event directly involving this part
+ if (part == p)
+ {
+ CacheInitialChildren();
+ UpdateVisuals();
+ return;
+ }
+
+ bool wasDirectChild = directChildren.Contains(p);
+ bool isDirectChildNow = p.parent == part;
+
+ switch (evt)
+ {
+ case ConstructionEventType.PartAttached:
+
+ if (isDirectChildNow)
+ {
+ directChildren.Add(p);
+ UpdateVisuals();
+ }
+
+ break;
+
+ case ConstructionEventType.PartDetached:
+
+ if (wasDirectChild)
+ {
+ directChildren.Remove(p);
+ UpdateVisuals();
+ }
+
+ break;
+ }
+ }
+
+ private void CacheInitialChildren()
+ {
+ directChildren.Clear();
+
+ foreach (Part child in part.children)
+ {
+ directChildren.Add(child);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Source/Modules/ModuleExclusiveResourceConverter.cs b/Source/Modules/ModuleExclusiveResourceConverter.cs
new file mode 100644
index 0000000..ab9ac45
--- /dev/null
+++ b/Source/Modules/ModuleExclusiveResourceConverter.cs
@@ -0,0 +1,38 @@
+/*
+ Usecase: Extends the stock resource converter so that only one type can run at a time.
+ Originally By: Spartwo
+ Originally For: Kerbal Powers
+ License: GNU General Public License v3.0, see https://www.gnu.org/licenses/gpl-3.0.html
+*/
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using UnityEngine;
+using KSP.IO;
+using KSP.UI.Screens;
+
+namespace KSPCommunityPartModules.Modules
+{
+ public class ModuleExclusiveResourceConverter : ModuleResourceConverter
+ {
+ public override void StartResourceConverter()
+ {
+ StopOtherConverters();
+ base.StartResourceConverter();
+ }
+
+ private void StopOtherConverters ()
+ {
+ ModuleExclusiveResourceConverter[] otherConverters = part.GetComponents();
+ foreach (ModuleExclusiveResourceConverter e in otherConverters)
+ {
+ e.StopResourceConverter();
+ }
+ }
+
+ }
+}
diff --git a/Source/Modules/ModuleToggleTracking.cs b/Source/Modules/ModuleToggleTracking.cs
new file mode 100644
index 0000000..b3925e2
--- /dev/null
+++ b/Source/Modules/ModuleToggleTracking.cs
@@ -0,0 +1,98 @@
+/*
+ Usecase: This module is applied to parts such as extendable solar panels and radiators to toggle their ability to track the sun.
+ Originally By: Spartwo
+ Originally For: Kerbal Powers
+ License: GNU General Public License v3.0, see https://www.gnu.org/licenses/gpl-3.0.html
+*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading.Tasks;
+using UnityEngine;
+using KSP.IO;
+using KSP.UI.Screens;
+using UnityEngine.SceneManagement;
+
+namespace KSPCommunityPartModules.Modules
+{
+ public class ModuleToggleTracking : PartModule
+ {
+
+ [KSPEvent(guiActive = true,
+ guiActiveEditor = true,
+ guiName = "#KSPCPM_Tracking")]
+ public void EventToggleTracking() => ToggleTracking();
+
+
+ [KSPAction("#KSPCPM_ToggleTracking")]
+ public void AGToggleTracking(KSPActionParam param) => ToggleTracking();
+
+ [KSPAction("#KSPCPM_DisableTracking")]
+ public void AGDisableTracking(KSPActionParam param) => SetTracking(false);
+
+ [KSPAction("#KSPCPM_EnableTracking")]
+ public void AGEnableTracking(KSPActionParam param) => SetTracking(true);
+
+ [KSPField(isPersistant = true)]
+ public bool trackingEnabled;
+
+ ModuleDeployablePart tracker;
+
+ public override void OnStart(StartState state)
+ {
+ base.OnStart(state);
+
+ try
+ {
+ tracker = part.GetComponent();
+ SetToggleName();
+ if (state != StartState.Editor)
+ {
+ SetTracking(trackingEnabled);
+ }
+ }
+ catch(Exception e)
+ {
+ Debug.Log($"Setup Error: {e}");
+ }
+
+ }
+
+ public override void OnLoad(ConfigNode node)
+ {
+ try
+ {
+ tracker = part.GetComponent();
+ SetToggleName();
+ SetTracking(trackingEnabled);
+ }
+ catch (Exception e)
+ {
+ Debug.Log($"Load Error: {e}");
+ }
+ }
+
+ private void SetTracking(bool newState)
+ {
+ trackingEnabled = newState;
+ tracker.isTracking = newState;
+ SetToggleName();
+ }
+
+ private void ToggleTracking()
+ {
+ bool newState = !trackingEnabled;
+ trackingEnabled = newState;
+ tracker.isTracking = newState;
+ SetToggleName();
+ }
+
+ private void SetToggleName()
+ {
+ Events["EventToggleTracking"].guiName = trackingEnabled ? "#KSPCPM_DisableTracking" : "#KSPCPM_EnableTracking";
+ }
+ }
+}