From 52b7628c596725dc25dbd0e4766939067845ce3f Mon Sep 17 00:00:00 2001 From: Mark Kilfoil Date: Sat, 4 Jul 2026 23:24:38 -0300 Subject: [PATCH 1/2] Fix hatch attachNodeId auto-detection polluting shared internal prefabs When multiple parts share the same INTERNAL, OnLoad_Finalize() picked only the first one via GetPartPrefabForInternal() and ran attach-node auto-detection against its nodes, writing the result (e.g. 'airlock') directly onto the shared prop prefab. Every subsequent part that cloned that prefab inherited the wrong attachNodeId. Symptoms: parts that reuse a stock internal as a placeholder (e.g. USILS.Greenhouse.Inline using crewCabinInternals) log an ERR on every flight load because their attach nodes don't include the one that was auto-detected for the canonical part. Fix: remove the prefab-time auto-detection from OnLoad_Finalize() and move it into FreeIvaHatch.OnAwake(), which runs per flight instance against the actual part. Each part now detects its own nearest attach node independently, so shared internals work correctly for all parts that use them. --- FreeIva/InternalModules/Hatches/Hatch.cs | 25 +++++++++++++++++++ .../InternalModules/InternalModuleFreeIva.cs | 25 +++---------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/FreeIva/InternalModules/Hatches/Hatch.cs b/FreeIva/InternalModules/Hatches/Hatch.cs index 010830bd..65d38dd5 100644 --- a/FreeIva/InternalModules/Hatches/Hatch.cs +++ b/FreeIva/InternalModules/Hatches/Hatch.cs @@ -335,6 +335,31 @@ public override void OnAwake() airlockTransform = FindAirlock(part, airlockName); } + // Auto-detect the attach node for this hatch against the actual flight part. + // This must run per-instance at flight time rather than at prefab load time, + // because multiple parts can share the same internal: detecting on the prefab + // using the first matching part would write an incorrect attachNodeId into the + // shared prefab, which every subsequent part then inherits. + if (attachNodeId == string.Empty && tubeTransform != null) + { + foreach (var attachNode in part.attachNodes) + { + Vector3 attachNodeInternalSpace = InternalModuleFreeIva.x_partToInternalSpace * attachNode.position; + Vector3 localPosition = tubeTransform.InverseTransformPoint(attachNodeInternalSpace); + + if (localPosition.y >= -1 && localPosition.y <= 0) + { + localPosition.y = 0; + if (localPosition.sqrMagnitude < 0.1f) + { + Log.Debug($"INTERNAL '{internalModel.internalName}' hatch PROP '{internalProp.propName}' auto-detected attachnode '{attachNode.id}' for part '{part.partInfo.name}'"); + attachNodeId = attachNode.id; + break; + } + } + } + } + var internalModule = InternalModuleFreeIva.GetForModel(internalModel); if (internalModule == null) { diff --git a/FreeIva/InternalModules/InternalModuleFreeIva.cs b/FreeIva/InternalModules/InternalModuleFreeIva.cs index 95d24bfa..a0d96055 100644 --- a/FreeIva/InternalModules/InternalModuleFreeIva.cs +++ b/FreeIva/InternalModules/InternalModuleFreeIva.cs @@ -507,7 +507,7 @@ private void OnLoad_Windows(ConfigNode node) } } - static Quaternion x_partToInternalSpace = Quaternion.Euler(90, 0, 180); + internal static Quaternion x_partToInternalSpace = Quaternion.Euler(90, 0, 180); static Quaternion x_internalToPartSpace = Quaternion.Inverse(x_partToInternalSpace); void OnLoad_Finalize() @@ -547,26 +547,9 @@ void OnLoad_Finalize() AddPropCut(hatch); } - // for attachnodes... - if (part != null && hatch.tubeTransform != null && hatch.attachNodeId == string.Empty) - { - foreach (var attachNode in part.attachNodes) - { - Vector3 attachNodeInternalSpace = x_partToInternalSpace * attachNode.position; - Vector3 localPosition = hatch.tubeTransform.InverseTransformPoint(attachNodeInternalSpace); - - if (localPosition.y >= -1 && localPosition.y <= 0) - { - localPosition.y = 0; - if (localPosition.sqrMagnitude < 0.1f) - { - Log.Debug($"INTERNAL '{internalModel.internalName}' hatch PROP '{prop.propName}' at {prop.transform.position} auto-detected attachnode '{attachNode.id}'"); - hatch.attachNodeId = attachNode.id; - break; - } - } - } - } + // attach node auto-detection is deferred to flight time (OnAwake in Hatch.cs) + // so that each part instance detects against its own nodes, avoiding + // shared-prefab pollution when multiple parts use the same internal } } } From ec2076bbb693b4a198211706753954b4d69c113a Mon Sep 17 00:00:00 2001 From: Mark Kilfoil Date: Sat, 4 Jul 2026 23:24:38 -0300 Subject: [PATCH 2/2] Fix airlockName auto-detection polluting shared internal prefabs OnLoad_Finalize() called GetPartPrefabForInternal() to auto-detect which airlock transform to associate with each EVA hatch. That returns only the FIRST part registered for a given internal, so its airlockName was written into the shared prefab and inherited by every other part sharing the same internal (e.g. USILS.Greenhouse.Inline, sspx-observation-25-1, and Kosntruction.Workshop.250 all inherit crewCabinInternals' "Airlock" name even though those parts have no such transform). Move airlock auto-detection to Hatch.OnAwake(), where this.part is the actual flight instance, applying the same spatial matching logic as before but only for that specific hatch instance. Also remove explicit airlockName = Airlock from docking port HatchConfigs: those ports add FreeIva/Parts/Airlock to their model to provide an EVA exit, but FindAirlock can't locate the transform by name (likely because the instantiated model root gets "(Clone)" appended by Unity). The fallback to part.airlock correctly finds the tagged transform already, so the named lookup was just generating noise. Co-Authored-By: Claude Sonnet 4.6 --- FreeIva/InternalModules/Hatches/Hatch.cs | 55 ++++++++++++++ .../InternalModules/InternalModuleFreeIva.cs | 73 +------------------ .../FreeIva/Squad/PartCfgs/dockingPort.cfg | 1 - .../FreeIva/Squad/PartCfgs/dockingPortJr.cfg | 1 - .../Squad/PartCfgs/dockingPortShielded.cfg | 1 - .../FreeIva/Squad/PartCfgs/dockingPortSr.cfg | 1 - 6 files changed, 57 insertions(+), 75 deletions(-) diff --git a/FreeIva/InternalModules/Hatches/Hatch.cs b/FreeIva/InternalModules/Hatches/Hatch.cs index 65d38dd5..61b2c438 100644 --- a/FreeIva/InternalModules/Hatches/Hatch.cs +++ b/FreeIva/InternalModules/Hatches/Hatch.cs @@ -330,6 +330,61 @@ public override void OnAwake() } } + // Auto-detect airlockName per-instance at flight time against the actual part's airlocks. + // Cannot do this at prefab-load time (OnLoad_Finalize) because GetPartPrefabForInternal + // returns only the first part using the internal, polluting the shared prefab for every + // other part that shares the same internal (same root cause as attachNodeId pollution). + // Only auto-detect for hatches without a HatchConfig (i.e. those that need auto-configuration). + if (airlockName == string.Empty && !isEvaHatch && GetComponent() == null) + { + Transform[] airlocks = part.airlock == null ? null : part.FindModelTransformsWithTag(AIRLOCK_TAG); + if (airlocks != null && airlocks.Length > 0) + { + var countByName = new Dictionary(airlocks.Length); + string[] airlockNames = new string[airlocks.Length]; + for (int i = 0; i < airlocks.Length; i++) + { + string name = airlocks[i].name; + if (countByName.TryGetValue(name, out int count)) + { + airlockNames[i] = name + "," + count; + countByName[name] = count + 1; + } + else + { + airlockNames[i] = name; + countByName[name] = 1; + } + } + + float bestDistSq = 1f; + int bestIndex = -1; + Quaternion internalToPartSpace = Quaternion.Inverse(InternalModuleFreeIva.x_partToInternalSpace); + for (int i = 0; i < airlocks.Length; i++) + { + // NOTE: some airlocks have non-identity scale so InverseTransformPoint doesn't work + Vector3 hatchInPartSpace = internalToPartSpace * transform.position; + Vector3 delta = hatchInPartSpace - airlocks[i].transform.position; + Vector3 hatchInAirlockSpace = airlocks[i].transform.InverseTransformDirection(delta); + if (hatchInAirlockSpace.z >= 0 && hatchInAirlockSpace.z <= 1) + { + hatchInAirlockSpace.z = 0; + if (hatchInAirlockSpace.sqrMagnitude <= bestDistSq) + { + bestDistSq = hatchInAirlockSpace.sqrMagnitude; + bestIndex = i; + } + } + } + + if (bestIndex >= 0) + { + airlockName = airlockNames[bestIndex]; + Log.Debug($"INTERNAL '{internalModel.internalName}' hatch PROP '{internalProp.propName}' auto-detected airlock '{airlockName}' for part '{part.partInfo.name}'"); + } + } + } + if (airlockName != string.Empty || isEvaHatch) { airlockTransform = FindAirlock(part, airlockName); diff --git a/FreeIva/InternalModules/InternalModuleFreeIva.cs b/FreeIva/InternalModules/InternalModuleFreeIva.cs index a0d96055..2bcebfcd 100644 --- a/FreeIva/InternalModules/InternalModuleFreeIva.cs +++ b/FreeIva/InternalModules/InternalModuleFreeIva.cs @@ -517,7 +517,6 @@ void OnLoad_Finalize() Part part = ModuleFreeIva.GetPartPrefabForInternal(internalModel.internalName); List hatches = new List(); - List boundAirlockNames = new List(); foreach (var prop in internalModel.props) { @@ -526,11 +525,6 @@ void OnLoad_Finalize() { var hatchConfig = prop.GetComponent(); - if (hatch.airlockName != string.Empty) - { - boundAirlockNames.Add(hatch.airlockName); - } - if (hatchConfig != null || hatch.cutoutTargetTransformName != string.Empty) { AddPropCut(hatch); @@ -547,76 +541,13 @@ void OnLoad_Finalize() AddPropCut(hatch); } - // attach node auto-detection is deferred to flight time (OnAwake in Hatch.cs) - // so that each part instance detects against its own nodes, avoiding + // attach node and airlock auto-detection are deferred to flight time (OnAwake in Hatch.cs) + // so that each part instance detects against its own nodes/airlocks, avoiding // shared-prefab pollution when multiple parts use the same internal } } } - // auto-configure airlocks - // since hatches do not have a defined "out" direction (different hatches are authored in different orientations), we do this in part space (because the airlocks DO have a defined "in" direction) - Transform[] airlocks = part?.airlock == null ? null : part.FindModelTransformsWithTag(FreeIvaHatch.AIRLOCK_TAG); - string[] airlockNames = airlocks == null ? null : new string[airlocks.Length]; - - if (airlocks != null) - { - // get a unique name for each airlock - var countByName = new Dictionary(airlocks.Length); - for (int i = 0; i < airlocks.Length; i++) - { - string name = airlocks[i].name; - if (countByName.TryGetValue(name, out int count)) - { - airlockNames[i] = name + "," + count; - ++count; - countByName[name] = count; - } - else - { - airlockNames[i] = name; - countByName[name] = 1; - } - } - - for (int airlockIndex = 0; airlockIndex < airlocks.Length; airlockIndex++) - { - var airlock = airlocks[airlockIndex]; - float bestDistanceSquared = 1; // we're pretty generous with positioning (compared to attachnodes) because these don't need to line up perfectly - FreeIvaHatch bestHatch = null; - - if (boundAirlockNames.Contains(airlockNames[airlockIndex])) continue; - - foreach (var hatch in hatches) - { - if (hatch.airlockName != string.Empty) continue; - - Vector3 hatchInPartSpace = x_internalToPartSpace * hatch.transform.position; - - // NOTE: some airlocks have non-identity scale (e.g. mk2LanderCan from restock) so InverseTransformPoint doesn't work - Vector3 airlockToHatch = hatchInPartSpace - airlock.transform.position; - Vector3 hatchInAirlockSpace = airlock.transform.InverseTransformDirection(airlockToHatch); - - // airlock's +Z is toward the part (the way the kerbal faces) - if (hatchInAirlockSpace.z >= 0 && hatchInAirlockSpace.z <= 1) - { - hatchInAirlockSpace.z = 0; - if (hatchInAirlockSpace.sqrMagnitude <= bestDistanceSquared) - { - bestDistanceSquared = hatchInAirlockSpace.sqrMagnitude; - bestHatch = hatch; - } - } - } - - if (bestHatch != null) - { - bestHatch.airlockName = airlockNames[airlockIndex]; - Log.Debug($"INTERNAL '{internalModel.internalName}' hatch PROP '{bestHatch.internalProp.propName}' at {bestHatch.internalProp.transform.position} auto-detected airlock '{bestHatch.airlockName}'"); - } - } - } - // TODO: auto-configure for docking ports... ExecuteMeshCuts(); diff --git a/GameData/FreeIva/Squad/PartCfgs/dockingPort.cfg b/GameData/FreeIva/Squad/PartCfgs/dockingPort.cfg index c6fffdf8..c1d936fb 100644 --- a/GameData/FreeIva/Squad/PartCfgs/dockingPort.cfg +++ b/GameData/FreeIva/Squad/PartCfgs/dockingPort.cfg @@ -59,7 +59,6 @@ INTERNAL name = HatchConfig attachNodeId = top dockingPortNodeName = dockingNode - airlockName = Airlock } } } diff --git a/GameData/FreeIva/Squad/PartCfgs/dockingPortJr.cfg b/GameData/FreeIva/Squad/PartCfgs/dockingPortJr.cfg index 49960a2b..8d57f0a0 100644 --- a/GameData/FreeIva/Squad/PartCfgs/dockingPortJr.cfg +++ b/GameData/FreeIva/Squad/PartCfgs/dockingPortJr.cfg @@ -47,7 +47,6 @@ INTERNAL name = HatchConfig attachNodeId = top dockingPortNodeName = dockingNode - airlockName = Airlock hideDoorWhenConnected = true } } diff --git a/GameData/FreeIva/Squad/PartCfgs/dockingPortShielded.cfg b/GameData/FreeIva/Squad/PartCfgs/dockingPortShielded.cfg index d9fe19a5..971bfca4 100644 --- a/GameData/FreeIva/Squad/PartCfgs/dockingPortShielded.cfg +++ b/GameData/FreeIva/Squad/PartCfgs/dockingPortShielded.cfg @@ -48,7 +48,6 @@ INTERNAL name = HatchConfig dockingPortNodeName = dockingNode requireDeploy = true - airlockName = Airlock } } PROP diff --git a/GameData/FreeIva/Squad/PartCfgs/dockingPortSr.cfg b/GameData/FreeIva/Squad/PartCfgs/dockingPortSr.cfg index f9a47a73..0bdf2b25 100644 --- a/GameData/FreeIva/Squad/PartCfgs/dockingPortSr.cfg +++ b/GameData/FreeIva/Squad/PartCfgs/dockingPortSr.cfg @@ -47,7 +47,6 @@ INTERNAL name = HatchConfig attachNodeId = top dockingPortNodeName = dockingNode - airlockName = Airlock hideDoorWhenConnected = true } }