diff --git a/original/AlternativeFuta.cs b/modified/AlternativeFuta.cs index 9a5a1b9..7873b70 100644 --- a/original/AlternativeFuta.cs +++ b/modified/AlternativeFuta.cs @@ -86,7 +86,6 @@ namespace Stopper JSONStorableBool hideFemaleGens; JSONStorableBool hideMalePelvis; JSONStorableBool useTransparentShader; - JSONStorableBool mergeLoad; JSONStorableBool disableTestesCollision; @@ -129,7 +128,45 @@ namespace Stopper JSONStorableFloat pelvisMorph1Value; JSONStorableFloat pelvisMorph2Value; JSONStorableFloat pelvisMorph3Value; - + + // Seam color blending + JSONStorableBool seamColorBlendEnabled; + const float SeamColorBlendRadius = 0.1f; + // Hide-pelvis mode: how far up the shaft (above the body surface) the + // body-color fade reaches before the penis texture takes over. + const float HiddenSeamFadeRadius = 0.04f; + // Hide-pelvis mode: graft verts farther than this from any visible body + // vertex are left unpainted. + const float HiddenSeamSearchRange = 0.06f; + // Hide-pelvis mode calibration sliders (temporary: once good values are + // found they get hard-coded and the sliders removed) + JSONStorableFloat seamFadeRange; + JSONStorableFloat seamAnchorOffset; + JSONStorableFloat seamColorBrightness; + JSONStorableFloat seamSurfaceRange; + JSONStorableFloat seamPatchRadius; + // Hide-pelvis surface map fade (spec/gloss/normal detail removal at the + // base): original textures by property name, and our baked replacements + Dictionary _seamSurfPrev = new Dictionary(); + List _seamSurfTextures = new List(); + bool _seamSurfNulled; + bool _skinColorMatched; + HSVColor _skinColorPrev; + Texture2D _seamColorDecalTex; + Texture _seamDecalPrevPelvis; + Texture _seamDecalPrevPenis; + bool _seamDecalApplied; + Dictionary _bodySeamDecalPrev = new Dictionary(); + List _bodySeamDecalTextures = new List(); + // Baking is heavy, so UI callbacks only schedule a rebake here; Update() + // runs it once the value has settled (and only once the skin is loaded). + float _seamRebakeAt = -1f; + // Right after a skin reload textures are still loading asynchronously, so + // a bake can fail (retry) or get silently overwritten by a texture that + // finishes loading later (integrity watch). + int _seamRebakeRetries; + float _seamWatchAt; + // Saving and loading JSONClass savedPresetJson; @@ -191,6 +228,10 @@ namespace Stopper JSONStorableFloat pelvisMorph3_restore; JSONStorableBool disableTestesCollision_restore; + // True while the hidden-penis morph override is applied. Guards the + // capture/restore logic: without it, loading or destroying the plugin + // clobbers the character's own morph values with stale restore values. + JSONStorableBool hiddenMorphsApplied; UIDynamicSlider hiddenPenisLengthSlider; UIDynamicSlider genYOffsetValueSlider; @@ -527,6 +568,10 @@ namespace Stopper disableTestesCollision_restore.storeType = JSONStorableParam.StoreType.Full; RegisterBool(disableTestesCollision_restore); + hiddenMorphsApplied = new JSONStorableBool("hiddenMorphsApplied", false); + hiddenMorphsApplied.storeType = JSONStorableParam.StoreType.Full; + RegisterBool(hiddenMorphsApplied); + // Initialise triggerable actions reloadSkinAction = new JSONStorableAction("Reload Skin", ReloadSkin); RegisterAction(reloadSkinAction); @@ -818,9 +863,17 @@ private string GetHierarchyPath(Transform t) } } + int genVertCount = genMesh.baseVertices.Length; + int targetVertCount = mergedMesh.targetMesh.baseVertices.Length; + var perineumPins = hideFemaleGens.val ? PerineumPins : PerineumPinsShowVagina; foreach(var entry in perineumPins) { + if(entry.Key < 0 || entry.Key >= genVertCount || entry.Value < 0 || entry.Value >= targetVertCount) + { + SuperController.LogError($"AlternativeFuta: Skipping perineum pin {entry.Key}->{entry.Value}, out of range (gen mesh: {genVertCount} verts, target mesh: {targetVertCount} verts)."); + continue; + } var pair = new DAZMeshGraftVertexPair(); pair.vertexNum = entry.Key; pair.graftToVertexNum = entry.Value; @@ -838,8 +891,14 @@ private string GetHierarchyPath(Transform t) graftPairs2.Add(pair); } + int vagVertCount = vagMesh.baseVertices.Length; foreach(var entry in vaginaPins) { + if(entry.Key < 0 || entry.Key >= vagVertCount || entry.Value < 0 || entry.Value >= targetVertCount) + { + SuperController.LogError($"AlternativeFuta: Skipping vagina pin {entry.Key}->{entry.Value}, out of range (vag mesh: {vagVertCount} verts, target mesh: {targetVertCount} verts)."); + continue; + } var pair = new DAZMeshGraftVertexPair(); pair.vertexNum = entry.Key; pair.graftToVertexNum = entry.Value; @@ -975,15 +1034,16 @@ private string GetHierarchyPath(Transform t) skin.Init(); // Adjust the male auto colliders + int mergedVertCount = mergedMesh.baseVertices.Length; foreach(var entry in maleAutoColliderConfigs) { AutoCollider ac = entry.Key; AutoColliderConfig config = entry.Value; - - if(ac.targetVertex >=0) ac.targetVertex = config.targetVertex + mergedMesh.numGraftBaseVertices; - if(ac.anchorVertex1 >=0) ac.anchorVertex1 = config.anchorVertex1 + mergedMesh.numGraftBaseVertices; - if(ac.anchorVertex2 >=0) ac.anchorVertex2 = config.anchorVertex2 + mergedMesh.numGraftBaseVertices; - if(ac.oppositeVertex >=0) ac.oppositeVertex = config.oppositeVertex + mergedMesh.numGraftBaseVertices; + + if(ac.targetVertex >=0) ac.targetVertex = ValidateGraftVertex(config.targetVertex + mergedMesh.numGraftBaseVertices, mergedVertCount, ac.name, "targetVertex"); + if(ac.anchorVertex1 >=0) ac.anchorVertex1 = ValidateGraftVertex(config.anchorVertex1 + mergedMesh.numGraftBaseVertices, mergedVertCount, ac.name, "anchorVertex1"); + if(ac.anchorVertex2 >=0) ac.anchorVertex2 = ValidateGraftVertex(config.anchorVertex2 + mergedMesh.numGraftBaseVertices, mergedVertCount, ac.name, "anchorVertex2"); + if(ac.oppositeVertex >=0) ac.oppositeVertex = ValidateGraftVertex(config.oppositeVertex + mergedMesh.numGraftBaseVertices, mergedVertCount, ac.name, "oppositeVertex"); } // Set the materials @@ -1018,8 +1078,15 @@ private string GetHierarchyPath(Transform t) skin.FlushBuffers(); } - - private IEnumerator WaitForLoad() + + private int ValidateGraftVertex(int index, int vertexCount, string colliderName, string fieldName) + { + if(index >= 0 && index < vertexCount) return index; + SuperController.LogError($"AlternativeFuta: AutoCollider '{colliderName}' {fieldName} index {index} is out of range (merged mesh has {vertexCount} verts); disabling this anchor."); + return -1; + } + + private IEnumerator WaitForLoad() { // Wait for it do its thing... while (newCharacterContainer == null || newCharacter == null || !newCharacter.ready) @@ -1153,6 +1220,17 @@ private string GetHierarchyPath(Transform t) { showHideGenital(showMaterial30.val, false); // Second bool is 'norestore' to prevent application during loading } + + // Clear hidden-penis morph residue from older versions (they relied on + // an unconditional restore-on-load that also acted as a cleanup) + CleanupStaleHiddenMorphs(); + + // Migrate the old "Female Pelvis Out/In" default: saved scenes carry + // 1.0 from the old default, which never actually applied back then. + if (pelvisMorph2Value != null && Mathf.Abs(pelvisMorph2Value.val - 1f) < 0.001f) + { + pelvisMorph2Value.val = 0f; + } // Apply testes collision setting if (disableTestesCollision != null) @@ -1184,6 +1262,53 @@ private string GetHierarchyPath(Transform t) }); Skynet.ToolTipManager.AddTooltip(genMaterialOptionsButton, "Add/remove penis textures, edit coloring, gloss/specular/illumination properties, and hide penis materials."); + seamColorBlendEnabled = new JSONStorableBool("Seam Color Blend", false, (bool val) => { + RequestSeamRebake(); + }); + RegisterBool(seamColorBlendEnabled); + var seamColorBlendToggle = CreateToggle(seamColorBlendEnabled, false); + Skynet.ToolTipManager.AddTooltip(seamColorBlendToggle, "Matches the penis skin color to the body, then bakes a color-blend decal that gradually fades the graft into the body on both sides of the connection seam, hiding color/texture differences. Works with both the opaque and transparent shaders. Reloading the skin (also triggered by the hide/shader toggles) clears the blend and unchecks this; re-enable it afterwards."); + + // Temporary calibration sliders for the seam blend + seamPatchRadius = new JSONStorableFloat("Seam Patch Radius", SeamColorBlendRadius, (float val) => { + RequestSeamRebake(); + }, 0.02f, 0.2f, true); + RegisterFloat(seamPatchRadius); + var seamPatchSlider = CreateSlider(seamPatchRadius, false); + Skynet.ToolTipManager.AddTooltip(seamPatchSlider, "(Pelvis visible mode) Size of the soft color patch that straddles the pelvis seam on both sides. 0.1 is the original value."); + + seamFadeRange = new JSONStorableFloat("Seam Fade Range", HiddenSeamFadeRadius, (float val) => { + RequestSeamRebake(); + }, 0.01f, 0.1f, true); + RegisterFloat(seamFadeRange); + var seamFadeSlider = CreateSlider(seamFadeRange, false); + Skynet.ToolTipManager.AddTooltip(seamFadeSlider, "(Hide Male Pelvis only) How far up the shaft the body-color fade reaches before the penis texture takes over."); + + seamAnchorOffset = new JSONStorableFloat("Seam Anchor Offset", 0f, (float val) => { + RequestSeamRebake(); + }, -0.05f, 0.05f, true); + RegisterFloat(seamAnchorOffset); + var seamAnchorSlider = CreateSlider(seamAnchorOffset, false); + Skynet.ToolTipManager.AddTooltip(seamAnchorSlider, "(Hide Male Pelvis only) Shifts the fade's starting line: negative moves it into the body, positive moves it up the shaft."); + + // Default 0.95: even with coverage-weighted, linear-space averaging the + // painted flat color renders slightly brighter than the surrounding + // textured skin (lighting-driven residual that texture-space math + // cannot capture). 0.95 was measured against real skin in the scene. + seamColorBrightness = new JSONStorableFloat("Seam Color Brightness", 0.95f, (float val) => { + RequestSeamRebake(); + }, 0.3f, 1.5f, true); + RegisterFloat(seamColorBrightness); + var seamBrightnessSlider = CreateSlider(seamColorBrightness, false); + Skynet.ToolTipManager.AddTooltip(seamBrightnessSlider, "Darkens or brightens the painted blend color (both modes). The painted flat color tends to render slightly brighter than real textured skin; 0.95 compensates for that."); + + seamSurfaceRange = new JSONStorableFloat("Seam Surface Range", 0.015f, (float val) => { + RequestSeamRebake(); + }, 0.005f, 0.2f, true); + RegisterFloat(seamSurfaceRange); + var seamSurfaceSlider = CreateSlider(seamSurfaceRange, false); + Skynet.ToolTipManager.AddTooltip(seamSurfaceSlider, "(Hide Male Pelvis only) How far the specular/gloss/normal map detail is faded out around the contact area. Keep this narrow: a wide fade looks worse than the seam itself."); + UIDynamicButton reload = CreateButton("Reload Skin", false); reload.button.onClick.AddListener(() => { ReloadSkin(); @@ -1347,7 +1472,10 @@ private string GetHierarchyPath(Transform t) ); Skynet.ToolTipManager.AddTooltip(genZMeshOffsetValueSlider, "Morph to move the male pelvis to make it fit the rest of the hip/pelvis area."); - pelvisMorph2Value = AddSlider("Female Pelvis Out/In", 1f, 0f, 3f, out pelvisMorph2ValueSlider, (float value) => { + // Default was 1.0 for years but never actually applied (an unconditional + // restore zeroed the morph on every load); now that slider values stick, + // 1.0 would bulge the hidden female pelvis out at the penis base. + pelvisMorph2Value = AddSlider("Female Pelvis Out/In", 0f, 0f, 3f, out pelvisMorph2ValueSlider, (float value) => { if (pelvisMorph2 != null) { pelvisMorph2.morphValue = value; @@ -1380,6 +1508,35 @@ private string GetHierarchyPath(Transform t) HideMaterialOptionsIUI(); } + // One-time cleanup for morph values left behind by the old capture bug: + // if 'Hide Penis' is off but the character still holds the exact + // hidden-penis override signature (MonsPubis 4.0 + Genital Bulge 2.0 + + // Genital Area 0.5), those values are stale leftovers — clear them. + // They create a body-colored mound at the crotch that is normally hidden + // behind the male pelvis mesh and only shows up with 'Hide Male Pelvis'. + private void CleanupStaleHiddenMorphs() + { + if (showMaterial30 != null && showMaterial30.val) return; + if (hiddenMorphsApplied != null && hiddenMorphsApplied.val) return; + + bool monsStale = monsPubicInOutMorph != null && Mathf.Abs(monsPubicInOutMorph.morphValue - 4.0f) < 0.01f; + bool bulgeStale = genitalBulge1Morph != null && Mathf.Abs(genitalBulge1Morph.morphValue - 2.0f) < 0.01f; + bool areaStale = genitalAreaInOutMorph != null && Mathf.Abs(genitalAreaInOutMorph.morphValue - 0.5f) < 0.01f; + int staleCount = (monsStale ? 1 : 0) + (bulgeStale ? 1 : 0) + (areaStale ? 1 : 0); + // Two or more of the override constants together are effectively + // impossible as deliberate user values; treat as stale and clear all. + if (staleCount < 2) return; + + if (monsPubicInOutMorph != null) monsPubicInOutMorph.morphValue = 0f; + if (genitalBulge1Morph != null) genitalBulge1Morph.morphValue = 0f; + if (genitalAreaInOutMorph != null) genitalAreaInOutMorph.morphValue = 0f; + if (penisLengthMorph != null && penisLengthMorph.morphValue < 0f) penisLengthMorph.morphValue = 0f; + if (scCastrateMorph != null && scCastrateMorph.morphValue > 0f) scCastrateMorph.morphValue = 0f; + + if (characterRun != null) characterRun.SetNeedsAutoColliderUpdate(); + SuperController.LogMessage("AlternativeFuta: cleared stale hidden-penis morph values left behind by an older version."); + } + // Morph manipulation to hide the penis (instead of hiding the material, which leads to a black hole) private void showHideGenital(bool val, bool norestore) { @@ -1387,10 +1544,13 @@ private string GetHierarchyPath(Transform t) if (isLoaded == false) return; - if (val) + if (val) { - // Save existing morph values & testicle collision setting - if (norestore == false) + // Save existing morph values & testicle collision setting. + // Skipped while the override is already applied: capturing then would + // store the hidden values themselves and lose the user's originals + // (e.g. on skin reloads while 'Hide Penis' is on). + if (norestore == false && hiddenMorphsApplied.val == false) { //SuperController.LogMessage($"showHideGenital: SAVING restore values...}"); //ZZZ @@ -1424,16 +1584,18 @@ private string GetHierarchyPath(Transform t) if (pelvisMorph2 != null) pelvisMorph2.morphValue = 0f; if (pelvisMorph3 != null) pelvisMorph3.morphValue = 0f; + hiddenMorphsApplied.val = true; + // Disable testes collision UpdateTestesCollision(false); } - else + else if (hiddenMorphsApplied.val) { //SuperController.LogMessage($"showHideGenital: RE-SETTING to restore values...}"); //ZZZ // Reset user values of morphs and testicle collision - if (genZMeshOffsetMorph != null) genZMeshOffsetValue.val = genZMeshOffsetValue_restore.val; - //if (genYMeshOffsetValue != null) genYMeshOffsetValue.val = genYMeshOffsetValue_restore.val; + if (genZMeshOffsetMorph != null) genZMeshOffsetValue.val = genZMeshOffsetValue_restore.val; + //if (genYMeshOffsetValue != null) genYMeshOffsetValue.val = genYMeshOffsetValue_restore.val; if (penisLengthMorph != null) penisLengthMorph.morphValue = penisLengthMorph_restore.val; if (genitalBulge1Morph != null) genitalBulge1Morph.morphValue = genitalBulge1Morph_restore.val; if (scCastrateMorph != null) scCastrateMorph.morphValue = scCastrateMorph_restore.val; @@ -1444,6 +1606,8 @@ private string GetHierarchyPath(Transform t) if (pelvisMorph2 != null) pelvisMorph2.morphValue = pelvisMorph2_restore.val; if (pelvisMorph3 != null) pelvisMorph3.morphValue = pelvisMorph3_restore.val; + hiddenMorphsApplied.val = false; + // Reset testes collision UpdateTestesCollision(disableTestesCollision_restore.val); } @@ -1679,70 +1843,73 @@ private string GetHierarchyPath(Transform t) var bank = characterSelector.maleMorphBank2; ZeroDuplicateMorphs(bank); - genYOffsetMorph = bank.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/male_genitalia/AltFuta Bone+Mesh Y-Offset.vmi"); + genYOffsetMorph = bank.GetMorphByDisplayName("AltFuta Bone+Mesh Y-Offset"); if(genYOffsetMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Bone+Mesh Y-Offset'. Genitals may be positioned incorrectly."); } else { morphs.Add(genYOffsetMorph); } - genZOffsetMorph = bank.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/male_genitalia/AltFuta Bone+Mesh Z-Offset.vmi"); + genZOffsetMorph = bank.GetMorphByDisplayName("AltFuta Bone+Mesh Z-Offset"); if(genZOffsetMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Bone+Mesh Z-Offset'. Genitals may be positioned incorrectly."); } else { morphs.Add(genZOffsetMorph); } - genYMeshOffsetMorph = bank.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/male_genitalia/AltFuta Mesh Y-Offset.vmi"); + genYMeshOffsetMorph = bank.GetMorphByDisplayName("AltFuta Mesh Y-Offset"); if(genYMeshOffsetMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Mesh Y-Offset'. Genitals may be positioned incorrectly."); } else { morphs.Add(genYMeshOffsetMorph); } - genZMeshOffsetMorph = bank.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/male_genitalia/AltFuta Mesh Z-Offset.vmi"); + genZMeshOffsetMorph = bank.GetMorphByDisplayName("AltFuta Mesh Z-Offset"); if(genZMeshOffsetMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Mesh Z-Offset'. Genitals may be positioned incorrectly."); } else { morphs.Add(genZMeshOffsetMorph); } - hidePerineumMorph = bank.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/male_genitalia/AltFuta Hide Perineum.vmi"); + hidePerineumMorph = bank.GetMorphByDisplayName("AltFuta Hide Perineum"); if(hidePerineumMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Hide Perineum'. Genitals may be positioned incorrectly."); } else { morphs.Add(hidePerineumMorph); } - gapFixMorph = characterSelector.maleMorphBank1.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/male/AltFuta Gap Fix.vmi"); + gapFixMorph = characterSelector.maleMorphBank1.GetMorphByDisplayName("AltFuta Gap Fix"); if(gapFixMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Gap Fix'. Genitals may be positioned incorrectly."); } else { morphs.Add(gapFixMorph); } - vaginaHideMorph = characterSelector.femaleMorphBank2.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/female_genitalia/AltFuta Vagina Hide.vmi"); + vaginaHideMorph = characterSelector.femaleMorphBank2.GetMorphByDisplayName("AltFuta Vagina Hide"); if(vaginaHideMorph == null){ SuperController.LogError($"Unable to find morph 'AltFuta Vagina Hide'. Genitals may be positioned incorrectly."); } else { morphs.Add(vaginaHideMorph); } - pelvisMorph1 = characterSelector.femaleMorphBank2.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/female_genitalia/VL_13/Pubis_Size_Z.vmi"); + pelvisMorph1 = characterSelector.femaleMorphBank2.GetMorphByDisplayName("Pubis_Size_Z"); + if (pelvisMorph1 == null) { + pelvisMorph1 = characterSelector.femaleMorphBank2.GetMorphByDisplayName("Pubis Size Z"); + } if(pelvisMorph1 == null){ SuperController.LogError($"Unable to find morph 'Pubis Size Z' by VL_13."); } else { morphs.Add(pelvisMorph1); } - pelvisMorph2 = characterSelector.femaleMorphBank2.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/female_genitalia/AshAuryn/AAX Genitals Fat.vmi"); + pelvisMorph2 = characterSelector.femaleMorphBank2.GetMorphByDisplayName("AAX Genitals Fat"); if(pelvisMorph2 == null){ SuperController.LogError($"Unable to find morph 'AAX Genitals Fat' by AshAuryn."); } else { morphs.Add(pelvisMorph2); } - pelvisMorph3 = characterSelector.femaleMorphBank2.GetMorphByUid($"{packagePath}Custom/Atom/Person/Morphs/female_genitalia/AshAuryn/AAX Pubic Area Press.vmi"); + pelvisMorph3 = characterSelector.femaleMorphBank2.GetMorphByDisplayName("AAX Pubic Area Press"); if(pelvisMorph3 == null){ SuperController.LogError($"Unable to find morph 'AAX Pubic Area Press' by AshAuryn."); } else { @@ -2117,6 +2284,35 @@ private string GetHierarchyPath(Transform t) skin.materialsEnabled[29] = !hideMalePelvis.val && (showMaterial29 == null || showMaterial29.val); // Male Pelvis skin.materialsEnabled[31] = false; // Female Genital/Pelvis } + + // Deferred seam rebake: waiting for skinLoaded keeps pending requests + // alive across scene load / skin reload until there is a skin to bake on. + if (skinLoaded) + { + if (_seamRebakeAt >= 0f) + { + if (Time.unscaledTime >= _seamRebakeAt) + { + _seamRebakeAt = -1f; + bool blendOn = seamColorBlendEnabled != null && seamColorBlendEnabled.val; + if (blendOn && !MatchSkinColor() && _seamRebakeRetries < 5) + { + // Materials/textures may still be initializing; retry + _seamRebakeRetries++; + _seamRebakeAt = Time.unscaledTime + 1f; + } + else + { + GenerateSeamColorBlend(blendOn ? SeamColorBlendRadius : 0f); + } + } + } + else if (Time.unscaledTime >= _seamWatchAt) + { + _seamWatchAt = Time.unscaledTime + 1f; + CheckSeamDecalIntegrity(); + } + } if (characterRun.morphBank3 != null) { @@ -2156,6 +2352,7 @@ private string GetHierarchyPath(Transform t) private void UnloadSkin() { status.val = "unloaded"; + ResetSeamBlendState(); skin = null; if(genMaterialOptions != null) @@ -2235,17 +2432,22 @@ private string GetHierarchyPath(Transform t) Destroy(maleAsset); } - // Reset user values of morphs and testicle collision - if (genZMeshOffsetMorph != null) genZMeshOffsetValue.val = genZMeshOffsetValue_restore.val; - //if (genYMeshOffsetValue != null) genYMeshOffsetValue.val = genYMeshOffsetValue_restore.val; - if (penisLengthMorph != null) penisLengthMorph.morphValue = penisLengthMorph_restore.val; - if (genitalBulge1Morph != null) genitalBulge1Morph.morphValue = genitalBulge1Morph_restore.val; - if (scCastrateMorph != null) scCastrateMorph.morphValue = scCastrateMorph_restore.val; - if (genitalAreaInOutMorph != null) genitalAreaInOutMorph.morphValue = genitalAreaInOutMorph_restore.val; - if (monsPubicInOutMorph != null) monsPubicInOutMorph.morphValue = monsPubicInOutMorph_restore.val; + // Undo the hidden-penis morph override only if it is actually applied; + // restoring unconditionally would clobber the character's own morph + // values (Penis Length etc.) with stale or default restore values. + if (hiddenMorphsApplied != null && hiddenMorphsApplied.val) + { + if (genZMeshOffsetMorph != null) genZMeshOffsetValue.val = genZMeshOffsetValue_restore.val; + //if (genYMeshOffsetValue != null) genYMeshOffsetValue.val = genYMeshOffsetValue_restore.val; + if (penisLengthMorph != null) penisLengthMorph.morphValue = penisLengthMorph_restore.val; + if (genitalBulge1Morph != null) genitalBulge1Morph.morphValue = genitalBulge1Morph_restore.val; + if (scCastrateMorph != null) scCastrateMorph.morphValue = scCastrateMorph_restore.val; + if (genitalAreaInOutMorph != null) genitalAreaInOutMorph.morphValue = genitalAreaInOutMorph_restore.val; + if (monsPubicInOutMorph != null) monsPubicInOutMorph.morphValue = monsPubicInOutMorph_restore.val; - // Reset testes collision - UpdateTestesCollision(disableTestesCollision_restore.val); + // Reset testes collision + UpdateTestesCollision(disableTestesCollision_restore.val); + } Skynet.ToolTipManager.Destroy(); //RunRudolf } @@ -3398,7 +3600,1487 @@ private string GetHierarchyPath(Transform t) 1428, 1429, }; - } + private Color GetAverageColor(Texture mainTex) + { + if (mainTex == null) return Color.white; + + RenderTexture tmp = RenderTexture.GetTemporary(16, 16, 0, RenderTextureFormat.Default, RenderTextureReadWrite.sRGB); + Graphics.Blit(mainTex, tmp); + + RenderTexture previous = RenderTexture.active; + RenderTexture.active = tmp; + + Texture2D myTex = new Texture2D(16, 16, TextureFormat.RGB24, false); + myTex.ReadPixels(new Rect(0, 0, 16, 16), 0, 0); + myTex.Apply(); + + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(tmp); + + Color[] pixels = myTex.GetPixels(); + float r = 0, g = 0, b = 0; + foreach (var p in pixels) + { + r += p.r; + g += p.g; + b += p.b; + } + UnityEngine.Object.Destroy(myTex); + + float count = pixels.Length; + return new Color(r / count, g / count, b / count); + } + + // VaM skins render through GPU compute skinning, not a SkinnedMeshRenderer, + // so get the UV-mapped mesh from the DAZ mesh data instead. + private Mesh GetSkinMesh() + { + if (skin == null) return null; + SkinnedMeshRenderer smr = skin.GetComponent(); + if (smr != null && smr.sharedMesh != null) return smr.sharedMesh; + if (skin.dazMesh != null) + { + if (skin.dazMesh.morphedUVMappedMesh != null) return skin.dazMesh.morphedUVMappedMesh; + if (skin.dazMesh.uvMappedMesh != null) return skin.dazMesh.uvMappedMesh; + } + return null; + } + + // Finds vertices on open (unshared) edges of the given triangle list. + private HashSet FindOpenBoundaryVerts(List tris) + { + Dictionary edgeCounts = new Dictionary(); + for (int i = 0; i < tris.Count; i += 3) + { + for (int e = 0; e < 3; e++) + { + int v1 = tris[i + e]; + int v2 = tris[i + (e + 1) % 3]; + long minV = Mathf.Min(v1, v2); + long maxV = Mathf.Max(v1, v2); + long edgeKey = (minV << 32) | maxV; + if (edgeCounts.ContainsKey(edgeKey)) edgeCounts[edgeKey]++; + else edgeCounts[edgeKey] = 1; + } + } + + HashSet boundaryVerts = new HashSet(); + foreach (var kvp in edgeCounts) + { + if (kvp.Value == 1) + { + boundaryVerts.Add((int)(kvp.Key >> 32)); + boundaryVerts.Add((int)(kvp.Key & 0xFFFFFFFF)); + } + } + return boundaryVerts; + } + + // Returns graft vertices lying on the connection seam. Prefers open boundary + // edges; if the graft is welded to the body (no open edges), falls back to + // matching graft vertex positions against body vertex positions. + private HashSet FindSeamVerts(Mesh m, List graftTris, Vector3[] verts) + { + HashSet boundary = FindOpenBoundaryVerts(graftTris); + if (boundary.Count > 0) return boundary; + + HashSet graftVerts = new HashSet(graftTris); + Dictionary bodyByPos = new Dictionary(); + for (int s = 0; s < m.subMeshCount; s++) + { + if (s == 29 || s == 30) continue; + int[] tris = m.GetTriangles(s); + foreach (int v in tris) + { + if (graftVerts.Contains(v)) continue; + bodyByPos[QuantizePos(verts[v])] = v; + } + } + + HashSet seam = new HashSet(); + foreach (int v in graftVerts) + { + if (bodyByPos.ContainsKey(QuantizePos(verts[v]))) seam.Add(v); + } + return seam; + } + + private Vector3 QuantizePos(Vector3 p) + { + // 0.5mm grid, coarse enough to catch welded/glued duplicate vertices + return new Vector3(Mathf.Round(p.x * 2000f), Mathf.Round(p.y * 2000f), Mathf.Round(p.z * 2000f)); + } + + // Copies a (possibly non-readable) texture into a CPU-readable Texture2D. + // Color textures (diffuse/decal) use sRGB; data textures (spec/gloss/normal + // maps) must use linear=true so values round-trip unchanged. + private Texture2D ReadTextureToCPU(Texture tex, int size, bool linear = false) + { + if (tex == null) return null; + + RenderTexture tmp = RenderTexture.GetTemporary(size, size, 0, RenderTextureFormat.Default, + linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB); + Graphics.Blit(tex, tmp); + + RenderTexture previous = RenderTexture.active; + RenderTexture.active = tmp; + + Texture2D result = new Texture2D(size, size, TextureFormat.ARGB32, false, linear); + result.ReadPixels(new Rect(0, 0, size, size), 0, 0); + result.Apply(); + + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(tmp); + return result; + } + + // Samples texture colors right at the graft seam: the open boundary of the + // penis/pelvis submeshes on the graft side, and body vertices within a small + // radius of that boundary on the body side. When seamVertColors is given it + // also receives a local body color per seam vertex, so the blend can follow + // the body's spatial color variation (bright mons vs dark perineum) instead + // of one flat average that mismatches everywhere. + private bool TryGetSeamColors(out Color graftTexAvg, out Color bodyFinalAvg, out Color patchAvg, float patchRadius) + { + graftTexAvg = Color.white; + bodyFinalAvg = Color.white; + patchAvg = Color.white; + + if (skin == null || skin.GPUmaterials == null) return false; + Mesh m = GetSkinMesh(); + if (m == null) return false; + + Vector3[] verts = m.vertices; + Vector2[] uvs = m.uv; + if (verts == null || verts.Length == 0 || uvs == null || uvs.Length < verts.Length) return false; + + // Same graft submesh selection as the seam gradient bake (29 = Pelvis, 30 = Penis) + int[] graftSubmeshes; + if (hideMalePelvis != null && hideMalePelvis.val) + graftSubmeshes = new int[] { 30 }; + else + graftSubmeshes = new int[] { 29, 30 }; + + List graftTris = new List(); + Dictionary vertSubmesh = new Dictionary(); + foreach (int subIdx in graftSubmeshes) + { + if (subIdx >= m.subMeshCount) continue; + int[] tris = m.GetTriangles(subIdx); + graftTris.AddRange(tris); + foreach (int v in tris) vertSubmesh[v] = subIdx; + } + if (graftTris.Count == 0) return false; + + HashSet boundaryVerts = FindSeamVerts(m, graftTris, verts); + if (boundaryVerts.Count == 0) return false; + + Dictionary readableCache = new Dictionary(); + try + { + // Graft side: sample the graft textures at the boundary vertices. + // Texture color only (no diffuse) because we solve for a new diffuse. + float gr = 0f, gg = 0f, gb = 0f; + int gCount = 0; + foreach (int v in boundaryVerts) + { + int subIdx; + if (!vertSubmesh.TryGetValue(v, out subIdx)) continue; + if (subIdx >= skin.GPUmaterials.Length || skin.GPUmaterials[subIdx] == null) continue; + + Texture tex = skin.GPUmaterials[subIdx].GetTexture("_MainTex"); + if (tex == null) continue; + Texture2D readable; + if (!readableCache.TryGetValue(tex, out readable)) + { + readable = ReadTextureToCPU(tex, 256); + readableCache[tex] = readable; + } + if (readable == null) continue; + + Vector2 uv = uvs[v]; + Color c = readable.GetPixelBilinear(uv.x - Mathf.Floor(uv.x), uv.y - Mathf.Floor(uv.y)); + gr += c.r; gg += c.g; gb += c.b; + gCount++; + } + if (gCount == 0) return false; + graftTexAvg = new Color(gr / gCount, gg / gCount, gb / gCount); + + // Body side: vertices of every non-graft submesh close to the seam ring. + // Sampled color includes that material's diffuse tint (final rendered color). + HashSet graftVertSet = new HashSet(graftTris); + float br = 0f, bg = 0f, bb = 0f; + int bCount = 0; + float[] searchRadii = new float[] { 0.02f, 0.05f }; + foreach (float maxDist in searchRadii) + { + br = 0f; bg = 0f; bb = 0f; bCount = 0; + + // Expanded bounding box of the seam ring for cheap rejection. + Vector3 bbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 bbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); + foreach (int bv in boundaryVerts) + { + Vector3 p = verts[bv]; + bbMin = Vector3.Min(bbMin, p); + bbMax = Vector3.Max(bbMax, p); + } + bbMin -= new Vector3(maxDist, maxDist, maxDist); + bbMax += new Vector3(maxDist, maxDist, maxDist); + + for (int subIdx = 0; subIdx < m.subMeshCount; subIdx++) + { + if (subIdx == 29 || subIdx == 30) continue; + if (subIdx >= skin.GPUmaterials.Length) continue; + // Invisible submeshes (e.g. 31, the hidden female pelvis top) + // must not contribute: their textures can differ wildly from + // the skin actually visible around the seam. + if (skin.materialsEnabled != null && subIdx < skin.materialsEnabled.Length && !skin.materialsEnabled[subIdx]) continue; + Material mat = skin.GPUmaterials[subIdx]; + if (mat == null) continue; + + Texture tex = mat.GetTexture("_MainTex"); + if (tex == null) continue; + + int[] tris = m.GetTriangles(subIdx); + if (tris.Length == 0) continue; + + Texture2D readable = null; + Color diffuse = mat.HasProperty("_Color") ? mat.GetColor("_Color") : Color.white; + + HashSet bodyVerts = new HashSet(tris); + foreach (int v in bodyVerts) + { + if (graftVertSet.Contains(v)) continue; + + Vector3 pos = verts[v]; + if (pos.x < bbMin.x || pos.y < bbMin.y || pos.z < bbMin.z || + pos.x > bbMax.x || pos.y > bbMax.y || pos.z > bbMax.z) continue; + + bool near = false; + foreach (int bv in boundaryVerts) + { + if (Vector3.Distance(pos, verts[bv]) <= maxDist) { near = true; break; } + } + if (!near) continue; + + if (readable == null) + { + if (!readableCache.TryGetValue(tex, out readable)) + { + // Body textures need decent resolution here: the seam + // colors must match the actual texels right at the seam, + // and a heavily downscaled read blurs them into a color + // that no longer exists on the rendered skin. + readable = ReadTextureToCPU(tex, 1024); + readableCache[tex] = readable; + } + if (readable == null) break; + } + + Vector2 uv = uvs[v]; + Color c = readable.GetPixelBilinear(uv.x - Mathf.Floor(uv.x), uv.y - Mathf.Floor(uv.y)); + float fr = c.r * diffuse.r, fg = c.g * diffuse.g, fb = c.b * diffuse.b; + br += fr; bg += fg; bb += fb; + bCount++; + } + } + if (bCount > 0) break; + } + if (bCount == 0) return false; + bodyFinalAvg = new Color(br / bCount, bg / bCount, bb / bCount); + + // Coverage-weighted average: the wide patch replaces the look of + // the whole area it covers, so its color must match that area's + // average (weighted by patch opacity), not just the ring's own + // neighborhood. The ring sits in slightly brighter skin, so a + // ring-only average paints the patch a few percent too bright — + // this is the measured-reality auto correction. + patchAvg = bodyFinalAvg; + if (patchRadius > 0f) + { + Vector3 pbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 pbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); + foreach (int bv in boundaryVerts) + { + Vector3 p = verts[bv]; + pbMin = Vector3.Min(pbMin, p); + pbMax = Vector3.Max(pbMax, p); + } + pbMin -= new Vector3(patchRadius, patchRadius, patchRadius); + pbMax += new Vector3(patchRadius, patchRadius, patchRadius); + + float pr = 0f, pg = 0f, pb = 0f, pw = 0f; + for (int subIdx = 0; subIdx < m.subMeshCount; subIdx++) + { + if (subIdx == 29 || subIdx == 30) continue; + if (subIdx >= skin.GPUmaterials.Length) continue; + if (skin.materialsEnabled != null && subIdx < skin.materialsEnabled.Length && !skin.materialsEnabled[subIdx]) continue; + Material mat = skin.GPUmaterials[subIdx]; + if (mat == null) continue; + Texture tex = mat.GetTexture("_MainTex"); + if (tex == null) continue; + int[] tris = m.GetTriangles(subIdx); + if (tris.Length == 0) continue; + + Texture2D readable = null; + Color diffuse = mat.HasProperty("_Color") ? mat.GetColor("_Color") : Color.white; + HashSet bodyVerts = new HashSet(tris); + foreach (int v in bodyVerts) + { + if (graftVertSet.Contains(v)) continue; + Vector3 pos = verts[v]; + if (pos.x < pbMin.x || pos.y < pbMin.y || pos.z < pbMin.z || + pos.x > pbMax.x || pos.y > pbMax.y || pos.z > pbMax.z) continue; + + float minDist = float.MaxValue; + foreach (int bv in boundaryVerts) + { + float dd = Vector3.Distance(pos, verts[bv]); + if (dd < minDist) minDist = dd; + } + if (minDist > patchRadius) continue; + float wgt = 1f - Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(minDist / patchRadius)); + if (wgt <= 0f) continue; + + if (readable == null) + { + if (!readableCache.TryGetValue(tex, out readable)) + { + readable = ReadTextureToCPU(tex, 1024); + readableCache[tex] = readable; + } + if (readable == null) break; + } + Vector2 uv = uvs[v]; + Color c = readable.GetPixelBilinear(uv.x - Mathf.Floor(uv.x), uv.y - Mathf.Floor(uv.y)); + // Average in LINEAR space: rendered brightness is linear + // in the albedo, and averaging the gamma-encoded values + // makes a flat fill render brighter than the textured + // skin it replaces (convexity bias, a few percent). + pr += Mathf.GammaToLinearSpace(c.r) * Mathf.GammaToLinearSpace(diffuse.r) * wgt; + pg += Mathf.GammaToLinearSpace(c.g) * Mathf.GammaToLinearSpace(diffuse.g) * wgt; + pb += Mathf.GammaToLinearSpace(c.b) * Mathf.GammaToLinearSpace(diffuse.b) * wgt; + pw += wgt; + } + } + if (pw > 0f) + patchAvg = new Color( + Mathf.LinearToGammaSpace(pr / pw), + Mathf.LinearToGammaSpace(pg / pw), + Mathf.LinearToGammaSpace(pb / pw)); + } + return true; + } + finally + { + foreach (var kvp in readableCache) + { + if (kvp.Value != null) UnityEngine.Object.Destroy(kvp.Value); + } + } + } + + // Bakes a decal that gradually tints the graft toward the body's color at the + // connection seam. Textures can never match perfectly, so instead of trying to + // match them exactly this hides the transition under a smooth gradient. + // Works with both the opaque and transparent shaders. + private void GenerateSeamColorBlend(float fadeRadius) + { + if (skin == null || skin.GPUmaterials == null) return; + Material penisMat = skin.GPUmaterials.Length > 30 ? skin.GPUmaterials[30] : null; + Material pelvisMat = skin.GPUmaterials.Length > 29 ? skin.GPUmaterials[29] : null; + if (penisMat == null) return; + + if (fadeRadius <= 0f) + { + if (_seamDecalApplied) + { + if (pelvisMat != null && pelvisMat.HasProperty("_DecalTex")) + pelvisMat.SetTexture("_DecalTex", _seamDecalPrevPelvis); + if (penisMat.HasProperty("_DecalTex")) + penisMat.SetTexture("_DecalTex", _seamDecalPrevPenis); + _seamDecalApplied = false; + } + RestoreBodySeamDecals(); + RestoreGraftSurfaceMaps(); + RestoreSkinColor(); + return; + } + + if (!penisMat.HasProperty("_DecalTex")) + { + SuperController.LogMessage("[AF debug] blend aborted: penis material has no _DecalTex."); + return; + } + + // Two very different seams need two very different treatments. With the + // pelvis visible the seam is the long pelvis outline running through + // evenly colored skin: a wide soft patch of the flat average color over + // BOTH sides hides it completely (the original, known-good recipe). + // With 'Hide Male Pelvis' the graft's cut ring is buried inside the + // body, so the visible transition is where the shaft exits the body + // surface instead: the fade is anchored to that surface (signed + // distance) and only the graft is painted, never the body. + bool hidePelvisMode = hideMalePelvis != null && hideMalePelvis.val; + + // OFF paints a patch of this size on both sides; resolve it up front + // so the sampled color can be weighted by the actual coverage. + if (!hidePelvisMode && seamPatchRadius != null) fadeRadius = seamPatchRadius.val; + + Color graftTexAvg; + Color bodySeamColor; + Color patchColorAvg; + if (!TryGetSeamColors(out graftTexAvg, out bodySeamColor, out patchColorAvg, hidePelvisMode ? 0f : fadeRadius)) + { + // Right after a skin reload the textures may still be loading + // asynchronously; retry a few times before giving up. + if (_seamRebakeRetries < 5) + { + _seamRebakeRetries++; + _seamRebakeAt = Time.unscaledTime + 1f; + } + else + { + SuperController.LogMessage("[AF debug] blend aborted: seam color sampling failed after retries."); + } + return; + } + _seamRebakeRetries = 0; + + // OFF paints with the coverage-weighted average so the patch matches + // the area it replaces (the ring-only average runs a few percent + // bright); keep the ring average around for the debug log. + Color ringColor = bodySeamColor; + if (!hidePelvisMode) bodySeamColor = patchColorAvg; + + Mesh m = GetSkinMesh(); + Vector3[] mVerts = m != null ? m.vertices : null; + Vector2[] mUvs = m != null ? m.uv : null; + if (m == null || mVerts == null || mVerts.Length == 0 || mUvs == null || mUvs.Length < mVerts.Length) + { + SuperController.LogMessage("[AF debug] blend aborted: no usable mesh/UV data."); + return; + } + + int[] graftSubmeshes; + if (hidePelvisMode) + graftSubmeshes = new int[] { 30 }; + else + graftSubmeshes = new int[] { 29, 30 }; + List allTris = new List(); + foreach (int subIdx in graftSubmeshes) + { + if (subIdx < m.subMeshCount) allTris.AddRange(m.GetTriangles(subIdx)); + } + if (allTris.Count == 0) + { + SuperController.LogMessage("[AF debug] blend aborted: no graft triangles."); + return; + } + + Dictionary vertW = new Dictionary(); + Dictionary vertWSurf = null; + Dictionary vertCol = null; + HashSet activeVerts = new HashSet(allTris); + int dbgBuried = 0, dbgFaded = 0, dbgUnpainted = 0, dbgSamples = 0; + bool dbgNormals = false; + if (hidePelvisMode) + { + // Surface-anchored fade: weight 0 (opaque) while the vertex is + // buried in the body, ramping to 1 as it rises up the shaft. + vertWSurf = new Dictionary(); + vertCol = new Dictionary(); + if (!ComputeHiddenModeFade(activeVerts, mVerts, m, bodySeamColor, vertW, vertWSurf, vertCol, + out dbgBuried, out dbgFaded, out dbgUnpainted, out dbgSamples, out dbgNormals)) + { + SuperController.LogMessage("[AF debug] blend aborted: no visible body geometry near the graft."); + return; + } + } + else + { + // Ring-anchored fade: distance from the pelvis outline, which here + // is an actual visible seam on the surface. + HashSet boundaryVerts = FindSeamVerts(m, allTris, mVerts); + if (boundaryVerts.Count == 0) + { + SuperController.LogMessage("[AF debug] blend aborted: no seam boundary found."); + return; + } + foreach (int v in activeVerts) + { + if (boundaryVerts.Contains(v)) + { + vertW[v] = 0f; + continue; + } + float minDist = float.MaxValue; + Vector3 pos = mVerts[v]; + foreach (int bv in boundaryVerts) + { + float dist = Vector3.Distance(pos, mVerts[bv]); + if (dist < minDist) minDist = dist; + } + vertW[v] = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(minDist / fadeRadius)); + } + } + + Texture2D gradient = BakeGradientForTris(allTris, vertW, mUvs, 1024); + Texture2D colorsTex = hidePelvisMode ? BakeColorsForTris(allTris, vertCol, mUvs, 1024, bodySeamColor) : null; + if (gradient == null || (hidePelvisMode && colorsTex == null)) + { + if (gradient != null) UnityEngine.Object.Destroy(gradient); + if (colorsTex != null) UnityEngine.Object.Destroy(colorsTex); + SuperController.LogMessage("[AF debug] blend aborted: gradient bake failed."); + return; + } + + // Second, much narrower gradient drives the surface-map detail fade + // around the contact area (hide-pelvis mode only) + float[] surfW = null; + if (hidePelvisMode) + { + Texture2D surfGrad = BakeGradientForTris(allTris, vertWSurf, mUvs, 1024); + if (surfGrad != null) + { + Color[] spx = surfGrad.GetPixels(); + DilateGradient(spx, 1024, 3); + surfW = new float[spx.Length]; + for (int i = 0; i < spx.Length; i++) surfW[i] = spx[i].r; + UnityEngine.Object.Destroy(surfGrad); + } + } + + // The shader multiplies the decal by the material's diffuse _Color, so + // store colors pre-diffuse; dividing also keeps sampled colors above 1 + // representable in the texture. + Color graftDiffuse = penisMat.HasProperty("_Color") ? penisMat.GetColor("_Color") : Color.white; + + // Turn the gradient into a decal, fully opaque at the seam and fading + // out with distance. Pelvis visible: flat average color held near-opaque + // across the band (pow 5, the original recipe). Pelvis hidden: local + // per-vertex colors with a true gradient (pow 2). + float fadePower = hidePelvisMode ? 2f : 5f; + + // In hide-pelvis mode the paint keeps the penis texture's own shading + // detail: tone comes from the body, detail from the graft, so the + // painted base doesn't turn into a flat featureless patch. + Color[] penisPx = null; + float brightness = seamColorBrightness != null ? seamColorBrightness.val : 1f; + if (hidePelvisMode) + { + Texture pt = penisMat.GetTexture("_MainTex"); + Texture2D ptRead = pt != null ? ReadTextureToCPU(pt, 1024) : null; + if (ptRead != null) + { + penisPx = ptRead.GetPixels(); + UnityEngine.Object.Destroy(ptRead); + } + } + + Color[] px = gradient.GetPixels(); + DilateGradient(px, 1024, 3); + Color[] colPx = colorsTex != null ? colorsTex.GetPixels() : null; + for (int i = 0; i < px.Length; i++) + { + float w = AdjustSeamWeight(px[i].r, fadePower); + Color c = colPx != null ? colPx[i] : bodySeamColor; + if (penisPx != null) + { + Color pt = penisPx[i]; + c.r *= graftTexAvg.r > 0.01f ? pt.r / graftTexAvg.r : 1f; + c.g *= graftTexAvg.g > 0.01f ? pt.g / graftTexAvg.g : 1f; + c.b *= graftTexAvg.b > 0.01f ? pt.b / graftTexAvg.b : 1f; + } + c.r *= brightness; c.g *= brightness; c.b *= brightness; + px[i] = new Color( + graftDiffuse.r > 0.01f ? c.r / graftDiffuse.r : c.r, + graftDiffuse.g > 0.01f ? c.g / graftDiffuse.g : c.g, + graftDiffuse.b > 0.01f ? c.b / graftDiffuse.b : c.b, + 1f - w); + } + UnityEngine.Object.Destroy(gradient); + if (colorsTex != null) UnityEngine.Object.Destroy(colorsTex); + + if (_seamColorDecalTex == null) + { + _seamColorDecalTex = new Texture2D(1024, 1024, TextureFormat.ARGB32, false); + _seamColorDecalTex.wrapMode = TextureWrapMode.Repeat; + } + _seamColorDecalTex.SetPixels(px); + _seamColorDecalTex.Apply(); + + if (!_seamDecalApplied) + { + _seamDecalPrevPelvis = (pelvisMat != null && pelvisMat.HasProperty("_DecalTex")) ? pelvisMat.GetTexture("_DecalTex") : null; + _seamDecalPrevPenis = penisMat.GetTexture("_DecalTex"); + _seamDecalApplied = true; + } + + if (pelvisMat != null && pelvisMat.HasProperty("_DecalTex")) + { + pelvisMat.SetTexture("_DecalTex", _seamColorDecalTex); + if (pelvisMat.HasProperty("_DecalAlpha")) pelvisMat.SetFloat("_DecalAlpha", 1f); + } + penisMat.SetTexture("_DecalTex", _seamColorDecalTex); + if (penisMat.HasProperty("_DecalAlpha")) penisMat.SetFloat("_DecalAlpha", 1f); + + if (hidePelvisMode) + { + // The crotch must keep its own shading: only the graft adapts, the + // body is never painted in this mode. + RestoreBodySeamDecals(); + // Fade the spec/gloss/normal detail in a narrow band around the + // contact area so the material switch stops reading as a seam + if (surfW != null) ApplyGraftSurfaceMapFade(penisMat, surfW); + SuperController.LogMessage($"[AF debug] blend applied: mode=ON, fade={(seamFadeRange != null ? seamFadeRange.val : HiddenSeamFadeRadius):F3}, offset={(seamAnchorOffset != null ? seamAnchorOffset.val : 0f):F3}, brightness={brightness:F2}, surf range={(seamSurfaceRange != null ? seamSurfaceRange.val : 0.015f):F3}, buried={dbgBuried}, faded={dbgFaded}, unpainted={dbgUnpainted}, body samples={dbgSamples}, normals={dbgNormals}, detail={(penisPx != null)}, fallback color=({bodySeamColor.r:F2},{bodySeamColor.g:F2},{bodySeamColor.b:F2})"); + } + else + { + // Fade the body side toward the same flat color so both sides of + // the seam meet inside one wide soft patch (the original recipe). + // The body gets the same brightness-adjusted color as the graft, + // otherwise the two halves of the patch no longer match. + Color bodyPatchColor = new Color(bodySeamColor.r * brightness, bodySeamColor.g * brightness, bodySeamColor.b * brightness); + ApplyBodySeamDecals(fadeRadius, bodyPatchColor); + RestoreGraftSurfaceMaps(); + SuperController.LogMessage($"[AF debug] blend applied: mode=OFF, radius={fadeRadius:F3}, brightness={brightness:F2}, patch color=({bodySeamColor.r:F3},{bodySeamColor.g:F3},{bodySeamColor.b:F3}), ring color=({ringColor.r:F3},{ringColor.g:F3},{ringColor.b:F3}), graft diffuse=({graftDiffuse.r:F2},{graftDiffuse.g:F2},{graftDiffuse.b:F2}), bodyDecalMats={_bodySeamDecalPrev.Count}"); + } + } + + // Hide-pelvis fade weights and colors. The graft's cut ring is buried + // inside the body, so distances from it never line up with the visible + // seam (log evidence: 83% of ring verts had no visible body vert within + // 5cm). Instead the fade is anchored to the body surface itself: the + // signed distance of a graft vertex from the nearest visible body vertex + // (projected on that vertex's normal) is zero right where the shaft exits + // the body — exactly where the transition is actually seen. Buried verts + // are painted fully; each vertex takes the local body color around its + // exit point. + private bool ComputeHiddenModeFade(HashSet graftVerts, Vector3[] verts, Mesh m, Color fallbackColor, + Dictionary vertW, Dictionary vertWSurf, Dictionary vertCol, + out int buried, out int faded, out int unpainted, out int sampleCount, out bool usedNormals) + { + buried = 0; faded = 0; unpainted = 0; sampleCount = 0; + + Vector2[] uvs = m.uv; + Vector3[] normals = m.normals; + bool hasNormals = normals != null && normals.Length >= verts.Length; + usedNormals = hasNormals; + if (uvs == null || uvs.Length < verts.Length) return false; + + float fadeRange = seamFadeRange != null ? seamFadeRange.val : HiddenSeamFadeRadius; + float anchorOffset = seamAnchorOffset != null ? seamAnchorOffset.val : 0f; + // The surface-map detail fade is deliberately much narrower than the + // color fade: a wide surface blend reads as worse than the seam itself. + float surfRange = seamSurfaceRange != null ? seamSurfaceRange.val : 0.015f; + + // Bounds of the graft, expanded by the search range, limit the body scan + Vector3 bbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 bbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); + foreach (int v in graftVerts) + { + Vector3 p = verts[v]; + bbMin = Vector3.Min(bbMin, p); + bbMax = Vector3.Max(bbMax, p); + } + Vector3 margin = new Vector3(HiddenSeamSearchRange, HiddenSeamSearchRange, HiddenSeamSearchRange); + bbMin -= margin; + bbMax += margin; + + // Collect visible body vertices inside the bounds: position, normal + // and final color (texel * material diffuse) + List smpPos = new List(); + List smpNrm = new List(); + List smpCol = new List(); + Dictionary readableCache = new Dictionary(); + try + { + for (int subIdx = 0; subIdx < m.subMeshCount; subIdx++) + { + if (subIdx == 29 || subIdx == 30) continue; + if (subIdx >= skin.GPUmaterials.Length) continue; + if (skin.materialsEnabled != null && subIdx < skin.materialsEnabled.Length && !skin.materialsEnabled[subIdx]) continue; + Material mat = skin.GPUmaterials[subIdx]; + if (mat == null) continue; + Texture tex = mat.GetTexture("_MainTex"); + if (tex == null) continue; + int[] tris = m.GetTriangles(subIdx); + if (tris.Length == 0) continue; + + Texture2D readable = null; + Color diffuse = mat.HasProperty("_Color") ? mat.GetColor("_Color") : Color.white; + HashSet bodyVerts = new HashSet(tris); + foreach (int v in bodyVerts) + { + if (graftVerts.Contains(v)) continue; + Vector3 pos = verts[v]; + if (pos.x < bbMin.x || pos.y < bbMin.y || pos.z < bbMin.z || + pos.x > bbMax.x || pos.y > bbMax.y || pos.z > bbMax.z) continue; + + if (readable == null) + { + if (!readableCache.TryGetValue(tex, out readable)) + { + readable = ReadTextureToCPU(tex, 1024); + readableCache[tex] = readable; + } + if (readable == null) break; + } + Vector2 uv = uvs[v]; + Color c = readable.GetPixelBilinear(uv.x - Mathf.Floor(uv.x), uv.y - Mathf.Floor(uv.y)); + smpPos.Add(pos); + smpNrm.Add(hasNormals ? normals[v] : Vector3.zero); + smpCol.Add(new Color(c.r * diffuse.r, c.g * diffuse.g, c.b * diffuse.b)); + } + } + } + finally + { + foreach (var kvp in readableCache) + { + if (kvp.Value != null) UnityEngine.Object.Destroy(kvp.Value); + } + } + sampleCount = smpPos.Count; + if (sampleCount == 0) return false; + + // 2cm spatial hash grid over the samples so the per-vertex nearest + // search doesn't go all-against-all. Hash collisions only ever add + // far-away candidates, which the distance checks filter out. + const float cell = 0.02f; + Dictionary> grid = new Dictionary>(); + for (int i = 0; i < smpPos.Count; i++) + { + long key = GridKey(smpPos[i], cell); + List lst; + if (!grid.TryGetValue(key, out lst)) + { + lst = new List(); + grid[key] = lst; + } + lst.Add(i); + } + + List found = new List(); + List foundDist = new List(); + foreach (int v in graftVerts) + { + Vector3 pos = verts[v]; + + // +-2 cells guarantees everything within 4cm; widen to +-3 (6cm) + // only when that neighborhood is empty + CollectGridCandidates(grid, pos, cell, 2, found); + if (found.Count == 0) CollectGridCandidates(grid, pos, cell, 3, found); + + int nearest = -1; + float nearestDist = float.MaxValue; + foundDist.Clear(); + for (int fi = 0; fi < found.Count; fi++) + { + float d = Vector3.Distance(pos, smpPos[found[fi]]); + foundDist.Add(d); + if (d < nearestDist) { nearestDist = d; nearest = fi; } + } + if (nearest < 0 || nearestDist > HiddenSeamSearchRange) + { + vertW[v] = 1f; + if (vertWSurf != null) vertWSurf[v] = 1f; + unpainted++; + continue; + } + + int nearestSmp = found[nearest]; + Vector3 nrm = smpNrm[nearestSmp]; + float signedDist; + if (nrm.sqrMagnitude > 0.25f) + signedDist = Vector3.Dot(pos - smpPos[nearestSmp], nrm.normalized); + else + signedDist = nearestDist; // no normals: no burial detection + + // The anchor offset shifts where the fade starts: negative pulls + // it into the body, positive pushes it up the shaft. + signedDist -= anchorOffset; + + if (signedDist <= 0f) + { + vertW[v] = 0f; + if (vertWSurf != null) vertWSurf[v] = 0f; + buried++; + } + else + { + vertW[v] = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(signedDist / fadeRange)); + if (vertWSurf != null) vertWSurf[v] = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(signedDist / surfRange)); + faded++; + } + + // Local body color around the exit point: inverse-square distance + // weighted average of the nearby samples + float colorRadius = Mathf.Max(0.02f, nearestDist + 0.01f); + float sr = 0f, sg = 0f, sb = 0f, sw = 0f; + for (int fi = 0; fi < found.Count; fi++) + { + float d = foundDist[fi]; + if (d > colorRadius) continue; + float wgt = 1f / (d * d + 0.00001f); + Color cc = smpCol[found[fi]]; + sr += cc.r * wgt; sg += cc.g * wgt; sb += cc.b * wgt; sw += wgt; + } + vertCol[v] = sw > 0f ? new Color(sr / sw, sg / sw, sb / sw) : fallbackColor; + } + return true; + } + + // Fades the penis material's spec/gloss/normal maps toward their own + // average color in a narrow band around the contact area, so the abrupt + // change in shine and micro-detail stops reading as a seam. Fading toward + // the map's own average removes detail without disturbing its encoding + // (safe for normal maps too) and keeps the overall intensity. + private void ApplyGraftSurfaceMapFade(Material penisMat, float[] surfW) + { + // Always bake from the pristine maps: put originals back first + RestoreGraftSurfaceMaps(); + if (penisMat == null) return; + + // Diagnostic: at the slider maximum the maps are removed outright. + // If even that changes nothing on screen, this shader/material does + // not actually sample these properties and the map fade is a dead end. + bool nullTest = seamSurfaceRange != null && seamSurfaceRange.val >= 0.19f; + _seamSurfNulled = nullTest; + + string[] props = new string[] { "_SpecTex", "_GlossTex", "_BumpMap" }; + List done = new List(); + foreach (string prop in props) + { + if (!penisMat.HasProperty(prop)) continue; + Texture orig = penisMat.GetTexture(prop); + if (orig == null) continue; + + if (nullTest) + { + _seamSurfPrev[prop] = orig; + penisMat.SetTexture(prop, null); + done.Add(prop); + continue; + } + + Texture2D readable = ReadTextureToCPU(orig, 1024); + if (readable == null) continue; + Color[] opx = readable.GetPixels(); + UnityEngine.Object.Destroy(readable); + if (opx == null || opx.Length != surfW.Length) continue; + + float ar = 0f, ag = 0f, ab = 0f, aa = 0f; + for (int i = 0; i < opx.Length; i++) + { + ar += opx[i].r; ag += opx[i].g; ab += opx[i].b; aa += opx[i].a; + } + Color avg = new Color(ar / opx.Length, ag / opx.Length, ab / opx.Length, aa / opx.Length); + + for (int i = 0; i < opx.Length; i++) + { + float a = 1f - AdjustSeamWeight(surfW[i], 2f); + if (a <= 0f) continue; + opx[i] = Color.Lerp(opx[i], avg, a); + } + + Texture2D baked = new Texture2D(1024, 1024, TextureFormat.ARGB32, false); + baked.wrapMode = TextureWrapMode.Repeat; + baked.SetPixels(opx); + baked.Apply(); + + _seamSurfPrev[prop] = orig; + _seamSurfTextures.Add(baked); + penisMat.SetTexture(prop, baked); + done.Add(prop); + } + SuperController.LogMessage($"[AF debug] surface maps {(nullTest ? "NULLED (diagnostic)" : "faded")}: {(done.Count > 0 ? string.Join(",", done.ToArray()) : "none")}, shader='{penisMat.shader.name}'"); + } + + private void RestoreGraftSurfaceMaps() + { + if (_seamSurfPrev.Count > 0 && skin != null && skin.GPUmaterials != null && skin.GPUmaterials.Length > 30) + { + Material penisMat = skin.GPUmaterials[30]; + if (penisMat != null) + { + foreach (var kvp in _seamSurfPrev) + { + if (penisMat.HasProperty(kvp.Key)) + penisMat.SetTexture(kvp.Key, kvp.Value); + } + } + } + _seamSurfPrev.Clear(); + foreach (var t in _seamSurfTextures) + { + if (t != null) UnityEngine.Object.Destroy(t); + } + _seamSurfTextures.Clear(); + _seamSurfNulled = false; + } + + private static long GridKey(Vector3 p, float cell) + { + long ix = (long)Mathf.Floor(p.x / cell); + long iy = (long)Mathf.Floor(p.y / cell); + long iz = (long)Mathf.Floor(p.z / cell); + return (ix * 73856093L) ^ (iy * 19349663L) ^ (iz * 83492791L); + } + + // Fills 'result' with sample indices from the (2n+1)^3 cell neighborhood + // around p. A HashSet keeps colliding cells from being added twice. + private static void CollectGridCandidates(Dictionary> grid, Vector3 p, float cell, int n, List result) + { + result.Clear(); + long ix = (long)Mathf.Floor(p.x / cell); + long iy = (long)Mathf.Floor(p.y / cell); + long iz = (long)Mathf.Floor(p.z / cell); + HashSet seen = new HashSet(); + for (long dx = -n; dx <= n; dx++) + { + for (long dy = -n; dy <= n; dy++) + { + for (long dz = -n; dz <= n; dz++) + { + long key = ((ix + dx) * 73856093L) ^ ((iy + dy) * 19349663L) ^ ((iz + dz) * 83492791L); + if (!seen.Add(key)) continue; + List lst; + if (grid.TryGetValue(key, out lst)) result.AddRange(lst); + } + } + } + } + + // Bakes a grayscale gradient for the given triangles into their material UV + // space. Vertices found in vertWeights use that value; all others are white. + private Texture2D BakeGradientForTris(List tris, Dictionary vertWeights, Vector2[] uvs, int texSize) + { + RenderTexture rt = RenderTexture.GetTemporary(texSize, texSize, 0, RenderTextureFormat.ARGB32); + RenderTexture oldRT = RenderTexture.active; + RenderTexture.active = rt; + GL.Clear(true, true, Color.white); + + Material glMat = new Material(Shader.Find("Hidden/Internal-Colored")); + glMat.SetPass(0); + + GL.PushMatrix(); + GL.LoadOrtho(); + GL.Begin(GL.TRIANGLES); + for (int i = 0; i < tris.Count; i += 3) + { + int v1 = tris[i]; + int v2 = tris[i + 1]; + int v3 = tris[i + 2]; + + float w1, w2, w3; + if (!vertWeights.TryGetValue(v1, out w1)) w1 = 1f; + if (!vertWeights.TryGetValue(v2, out w2)) w2 = 1f; + if (!vertWeights.TryGetValue(v3, out w3)) w3 = 1f; + if (w1 >= 1f && w2 >= 1f && w3 >= 1f) continue; + + float uOffset = Mathf.Floor(uvs[v1].x); + float vOffset = Mathf.Floor(uvs[v1].y); + + GL.Color(new Color(w1, w1, w1, 1f)); + GL.Vertex3(uvs[v1].x - uOffset, uvs[v1].y - vOffset, 0f); + GL.Color(new Color(w2, w2, w2, 1f)); + GL.Vertex3(uvs[v2].x - uOffset, uvs[v2].y - vOffset, 0f); + GL.Color(new Color(w3, w3, w3, 1f)); + GL.Vertex3(uvs[v3].x - uOffset, uvs[v3].y - vOffset, 0f); + } + GL.End(); + GL.PopMatrix(); + + Texture2D result = new Texture2D(texSize, texSize, TextureFormat.ARGB32, false); + result.ReadPixels(new Rect(0, 0, texSize, texSize), 0, 0); + result.Apply(); + + RenderTexture.active = oldRT; + RenderTexture.ReleaseTemporary(rt); + return result; + } + + // Bakes interpolated vertex colors for the given triangles into their + // material UV space. Vertices missing from vertColors use fallback, and the + // target is cleared to fallback too so bilinear sampling at island edges + // picks up a sane color instead of white. + private Texture2D BakeColorsForTris(List tris, Dictionary vertColors, Vector2[] uvs, int texSize, Color fallback) + { + RenderTexture rt = RenderTexture.GetTemporary(texSize, texSize, 0, RenderTextureFormat.ARGB32); + RenderTexture oldRT = RenderTexture.active; + RenderTexture.active = rt; + GL.Clear(true, true, new Color(fallback.r, fallback.g, fallback.b, 1f)); + + Material glMat = new Material(Shader.Find("Hidden/Internal-Colored")); + glMat.SetPass(0); + + GL.PushMatrix(); + GL.LoadOrtho(); + GL.Begin(GL.TRIANGLES); + for (int i = 0; i < tris.Count; i += 3) + { + int v1 = tris[i]; + int v2 = tris[i + 1]; + int v3 = tris[i + 2]; + + Color c1, c2, c3; + if (!vertColors.TryGetValue(v1, out c1)) c1 = fallback; + if (!vertColors.TryGetValue(v2, out c2)) c2 = fallback; + if (!vertColors.TryGetValue(v3, out c3)) c3 = fallback; + + float uOffset = Mathf.Floor(uvs[v1].x); + float vOffset = Mathf.Floor(uvs[v1].y); + + GL.Color(new Color(c1.r, c1.g, c1.b, 1f)); + GL.Vertex3(uvs[v1].x - uOffset, uvs[v1].y - vOffset, 0f); + GL.Color(new Color(c2.r, c2.g, c2.b, 1f)); + GL.Vertex3(uvs[v2].x - uOffset, uvs[v2].y - vOffset, 0f); + GL.Color(new Color(c3.r, c3.g, c3.b, 1f)); + GL.Vertex3(uvs[v3].x - uOffset, uvs[v3].y - vOffset, 0f); + } + GL.End(); + GL.PopMatrix(); + + Texture2D result = new Texture2D(texSize, texSize, TextureFormat.ARGB32, false); + result.ReadPixels(new Rect(0, 0, texSize, texSize), 0, 0); + result.Apply(); + + RenderTexture.active = oldRT; + RenderTexture.ReleaseTemporary(rt); + return result; + } + + private void RequestSeamRebake() + { + _seamRebakeAt = Time.unscaledTime + 0.4f; + _seamRebakeRetries = 0; + } + + + // Textures load asynchronously in VaM, so one that finishes loading after + // our bake (e.g. the user's own decal, right after a skin reload) silently + // replaces our seam decal and the blend disappears while the toggle still + // says on. Detect that, adopt the new texture as the original to restore, + // and re-apply. + private void CheckSeamDecalIntegrity() + { + if (!_seamDecalApplied || _seamColorDecalTex == null) return; + if (skin == null || skin.GPUmaterials == null) return; + + bool dirty = false; + + Material pelvisMat = skin.GPUmaterials.Length > 29 ? skin.GPUmaterials[29] : null; + Material penisMat = skin.GPUmaterials.Length > 30 ? skin.GPUmaterials[30] : null; + if (penisMat != null && penisMat.HasProperty("_DecalTex") && + !ReferenceEquals(penisMat.GetTexture("_DecalTex"), _seamColorDecalTex)) + { + _seamDecalPrevPenis = penisMat.GetTexture("_DecalTex"); + dirty = true; + } + if (pelvisMat != null && pelvisMat.HasProperty("_DecalTex") && + !ReferenceEquals(pelvisMat.GetTexture("_DecalTex"), _seamColorDecalTex)) + { + _seamDecalPrevPelvis = pelvisMat.GetTexture("_DecalTex"); + dirty = true; + } + + // Also watch the surface maps: if something (material options, async + // texture loads) re-applies its own spec/gloss/normal textures, our + // fade silently disappears. Adopt the new texture and re-bake. + if (penisMat != null && _seamSurfPrev.Count > 0) + { + List replaced = null; + foreach (var kvp in _seamSurfPrev) + { + if (!penisMat.HasProperty(kvp.Key)) continue; + Texture cur = penisMat.GetTexture(kvp.Key); + bool stillOurs = _seamSurfNulled ? cur == null : _seamSurfTextures.Contains(cur as Texture2D); + if (!stillOurs) + { + if (replaced == null) replaced = new List(); + replaced.Add(kvp.Key); + } + } + if (replaced != null) + { + foreach (string prop in replaced) + { + _seamSurfPrev[prop] = penisMat.GetTexture(prop); + } + SuperController.LogMessage($"[AF debug] surface maps externally replaced: {string.Join(",", replaced.ToArray())} - rebaking"); + dirty = true; + } + } + + List changedBodyMats = null; + foreach (var kvp in _bodySeamDecalPrev) + { + Material mat = kvp.Key; + if (mat == null) continue; + Texture cur = mat.GetTexture("_DecalTex"); + if (!_bodySeamDecalTextures.Contains(cur as Texture2D)) + { + if (changedBodyMats == null) changedBodyMats = new List(); + changedBodyMats.Add(mat); + } + } + if (changedBodyMats != null) + { + foreach (var mat in changedBodyMats) + { + _bodySeamDecalPrev[mat] = mat.GetTexture("_DecalTex"); + } + dirty = true; + } + + if (dirty) RequestSeamRebake(); + } + + // Undoes the seam blend and drops all bookkeeping. Called while the old + // skin still exists, right before it is unloaded/rebuilt: the graft + // materials get rebuilt anyway, but the BODY materials survive the reload, + // so their original decals must be put back here — otherwise they keep + // pointing at our destroyed decal textures and render a gray band. + private void ResetSeamBlendState() + { + if (_seamDecalApplied) + { + if (skin != null && skin.GPUmaterials != null) + { + Material pelvisMat = skin.GPUmaterials.Length > 29 ? skin.GPUmaterials[29] : null; + Material penisMat = skin.GPUmaterials.Length > 30 ? skin.GPUmaterials[30] : null; + if (pelvisMat != null && pelvisMat.HasProperty("_DecalTex")) + pelvisMat.SetTexture("_DecalTex", _seamDecalPrevPelvis); + if (penisMat != null && penisMat.HasProperty("_DecalTex")) + penisMat.SetTexture("_DecalTex", _seamDecalPrevPenis); + } + _seamDecalApplied = false; + } + _seamDecalPrevPelvis = null; + _seamDecalPrevPenis = null; + RestoreBodySeamDecals(); + RestoreGraftSurfaceMaps(); + if (_seamColorDecalTex != null) + { + UnityEngine.Object.Destroy(_seamColorDecalTex); + _seamColorDecalTex = null; + } + // The rebuilt skin comes back without the blend, so uncheck the toggle + // (and drop any pending rebake) so the UI matches reality; re-enable + // the toggle to bake again on the new skin. + _seamRebakeAt = -1f; + _skinColorMatched = false; + if (seamColorBlendEnabled != null) seamColorBlendEnabled.valNoCallback = false; + } + + // Pushes the blend outward: higher powers keep the decal near-opaque across + // most of the radius (a wide soft repaint patch), lower powers give a real + // gradient from the seam. The two blend modes use different powers. + private float AdjustSeamWeight(float w, float power) + { + return Mathf.Pow(Mathf.Clamp01(w), power); + } + + // Expands (dilates) the dark decal regions of a baked gradient by a few texels + // so bilinear filtering at UV island edges doesn't fade the decal right at the + // seam, which would let dark unpainted texture regions show through as a line. + private void DilateGradient(Color[] px, int size, int passes) + { + float[] cur = new float[px.Length]; + for (int i = 0; i < px.Length; i++) cur[i] = px[i].r; + float[] tmp = new float[px.Length]; + + for (int p = 0; p < passes; p++) + { + for (int y = 0; y < size; y++) + { + int row = y * size; + for (int x = 0; x < size; x++) + { + float v = cur[row + x]; + if (x > 0 && cur[row + x - 1] < v) v = cur[row + x - 1]; + if (x < size - 1 && cur[row + x + 1] < v) v = cur[row + x + 1]; + tmp[row + x] = v; + } + } + for (int y = 0; y < size; y++) + { + int row = y * size; + for (int x = 0; x < size; x++) + { + float v = tmp[row + x]; + if (y > 0 && tmp[row + x - size] < v) v = tmp[row + x - size]; + if (y < size - 1 && tmp[row + x + size] < v) v = tmp[row + x + size]; + cur[row + x] = v; + } + } + } + + for (int i = 0; i < px.Length; i++) + { + Color c = px[i]; + c.r = cur[i]; + px[i] = c; + } + } + + private void RestoreBodySeamDecals() + { + foreach (var kvp in _bodySeamDecalPrev) + { + if (kvp.Key != null && kvp.Key.HasProperty("_DecalTex")) + kvp.Key.SetTexture("_DecalTex", kvp.Value); + } + _bodySeamDecalPrev.Clear(); + + foreach (var t in _bodySeamDecalTextures) + { + if (t != null) UnityEngine.Object.Destroy(t); + } + _bodySeamDecalTextures.Clear(); + } + + // Fades the body's own texture detail toward the seam color on the body side + // of the connection, mirroring the graft-side decal. Composites over any + // existing decal so user decals are preserved and restored at radius 0. + // Only used with the pelvis visible: there the seam runs through evenly + // colored skin and this wide soft patch is what makes it disappear. + private void ApplyBodySeamDecals(float fadeRadius, Color seamColor) + { + Mesh m = GetSkinMesh(); + if (m == null) return; + + Vector3[] verts = m.vertices; + Vector2[] uvs = m.uv; + if (verts == null || verts.Length == 0 || uvs == null || uvs.Length < verts.Length) return; + + int[] graftSubmeshes; + if (hideMalePelvis != null && hideMalePelvis.val) + graftSubmeshes = new int[] { 30 }; + else + graftSubmeshes = new int[] { 29, 30 }; + + List graftTris = new List(); + foreach (int subIdx in graftSubmeshes) + { + if (subIdx < m.subMeshCount) graftTris.AddRange(m.GetTriangles(subIdx)); + } + if (graftTris.Count == 0) return; + + HashSet seamVerts = FindSeamVerts(m, graftTris, verts); + if (seamVerts.Count == 0) return; + + Vector3 bbMin = new Vector3(float.MaxValue, float.MaxValue, float.MaxValue); + Vector3 bbMax = new Vector3(float.MinValue, float.MinValue, float.MinValue); + foreach (int sv in seamVerts) + { + Vector3 p = verts[sv]; + bbMin = Vector3.Min(bbMin, p); + bbMax = Vector3.Max(bbMax, p); + } + bbMin -= new Vector3(fadeRadius, fadeRadius, fadeRadius); + bbMax += new Vector3(fadeRadius, fadeRadius, fadeRadius); + + // Group body submeshes by material instance so shared materials get one decal + HashSet graftVertSet = new HashSet(graftTris); + Dictionary> matTris = new Dictionary>(); + for (int subIdx = 0; subIdx < m.subMeshCount; subIdx++) + { + if (subIdx == 29 || subIdx == 30) continue; + if (subIdx >= skin.GPUmaterials.Length) continue; + if (skin.materialsEnabled != null && subIdx < skin.materialsEnabled.Length && !skin.materialsEnabled[subIdx]) continue; + Material mat = skin.GPUmaterials[subIdx]; + if (mat == null || !mat.HasProperty("_DecalTex")) continue; + int[] tris = m.GetTriangles(subIdx); + if (tris.Length == 0) continue; + + List lst; + if (!matTris.TryGetValue(mat, out lst)) + { + lst = new List(); + matTris[mat] = lst; + } + lst.AddRange(tris); + } + + // Radius may have changed: put original decals back before re-applying + RestoreBodySeamDecals(); + + foreach (var entry in matTris) + { + Material mat = entry.Key; + List tris = entry.Value; + + // Distance-based weight for body verts near the seam ring + Dictionary weights = new Dictionary(); + HashSet vertSet = new HashSet(tris); + bool anyNear = false; + foreach (int v in vertSet) + { + if (graftVertSet.Contains(v)) continue; + Vector3 pos = verts[v]; + if (pos.x < bbMin.x || pos.y < bbMin.y || pos.z < bbMin.z || + pos.x > bbMax.x || pos.y > bbMax.y || pos.z > bbMax.z) continue; + + float minDist = float.MaxValue; + foreach (int sv in seamVerts) + { + float dist = Vector3.Distance(pos, verts[sv]); + if (dist < minDist) minDist = dist; + } + if (minDist > fadeRadius) continue; + weights[v] = Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(minDist / fadeRadius)); + anyNear = true; + } + if (!anyNear) continue; + + Texture2D gradient = BakeGradientForTris(tris, weights, uvs, 1024); + if (gradient == null) continue; + + // Composite our fade over any existing decal + Texture prevDecal = mat.GetTexture("_DecalTex"); + Texture2D prevReadable = prevDecal != null ? ReadTextureToCPU(prevDecal, 1024) : null; + + Color[] px = gradient.GetPixels(); + Color[] prevPx = prevReadable != null ? prevReadable.GetPixels() : null; + for (int i = 0; i < px.Length; i++) + { + float a = 1f - px[i].r; + if (prevPx != null) + { + Color o = prevPx[i]; + float outA = a + o.a * (1f - a); + if (outA > 0.0001f) + { + px[i] = new Color( + (seamColor.r * a + o.r * o.a * (1f - a)) / outA, + (seamColor.g * a + o.g * o.a * (1f - a)) / outA, + (seamColor.b * a + o.b * o.a * (1f - a)) / outA, + outA); + } + else + { + px[i] = new Color(0f, 0f, 0f, 0f); + } + } + else + { + px[i] = new Color(seamColor.r, seamColor.g, seamColor.b, a); + } + } + UnityEngine.Object.Destroy(gradient); + if (prevReadable != null) UnityEngine.Object.Destroy(prevReadable); + + Texture2D decal = new Texture2D(1024, 1024, TextureFormat.ARGB32, false); + decal.wrapMode = TextureWrapMode.Repeat; + decal.SetPixels(px); + decal.Apply(); + + _bodySeamDecalPrev[mat] = prevDecal; + _bodySeamDecalTextures.Add(decal); + mat.SetTexture("_DecalTex", decal); + if (mat.HasProperty("_DecalAlpha")) mat.SetFloat("_DecalAlpha", 1f); + } + } + + // Matches the penis diffuse color to the body. Returns false when the + // skin/materials are not ready yet so the caller can retry. + private bool MatchSkinColor() + { + if (skin == null || skin.GPUmaterials == null) return false; + + Material torsoMat = null; + foreach (var m in skin.GPUmaterials) + { + if (m != null && m.name.IndexOf("Torso", StringComparison.OrdinalIgnoreCase) >= 0) + { + torsoMat = m; + break; + } + } + + if (torsoMat == null && skin.GPUmaterials.Length > 2) + { + torsoMat = skin.GPUmaterials[2]; + } + + if (torsoMat == null) return false; + + Material penisMat = skin.GPUmaterials.Length > 30 ? skin.GPUmaterials[30] : null; + if (penisMat == null) return false; + + Texture torsoTex = torsoMat.GetTexture("_MainTex"); + Texture penisTex = penisMat.GetTexture("_MainTex"); + + if (torsoTex == null || penisTex == null) return false; + + Color torsoTexAvg = GetAverageColor(torsoTex); + Color penisTexAvg = GetAverageColor(penisTex); + + Color torsoDiffuse = Color.white; + if (torsoMat.HasProperty("_Color")) + { + torsoDiffuse = torsoMat.GetColor("_Color"); + } + + // Target final color is Torso Texture Avg * Torso Diffuse + Color targetFinalColor = new Color( + torsoTexAvg.r * torsoDiffuse.r, + torsoTexAvg.g * torsoDiffuse.g, + torsoTexAvg.b * torsoDiffuse.b, + 1f + ); + + // We want: Penis Texture Avg * Penis Diffuse = Target Final Color + // So: Penis Diffuse = Target Final Color / Penis Texture Avg + Color penisDiffuse = new Color( + penisTexAvg.r > 0.01f ? Mathf.Clamp01(targetFinalColor.r / penisTexAvg.r) : 1f, + penisTexAvg.g > 0.01f ? Mathf.Clamp01(targetFinalColor.g / penisTexAvg.g) : 1f, + penisTexAvg.b > 0.01f ? Mathf.Clamp01(targetFinalColor.b / penisTexAvg.b) : 1f, + 1f + ); + + if (genMaterialOptions == null) return false; + + // Sync all other parameters (Specular, Gloss, etc.) first + var skinMaterialOpts = containingAtom.GetStorableByID("skin") as DAZCharacterMaterialOptions; + if (skinMaterialOpts != null) + { + genMaterialOptions.CopyMaterialParams(skinMaterialOpts); + } + + var diffuseStorable = genMaterialOptions.GetColorJSONParam("Diffuse Color"); + if (diffuseStorable == null) return false; + + // Remember the color the user had, so the toggle can restore it + if (!_skinColorMatched) + { + _skinColorPrev = diffuseStorable.val; + _skinColorMatched = true; + } + + float H, S, V; + Color.RGBToHSV(penisDiffuse, out H, out S, out V); + var currentHSV = diffuseStorable.val; + currentHSV.H = H; + currentHSV.S = S; + currentHSV.V = V; + diffuseStorable.val = currentHSV; + return true; + } + + private void RestoreSkinColor() + { + if (!_skinColorMatched) return; + _skinColorMatched = false; + if (genMaterialOptions == null) return; + var diffuseStorable = genMaterialOptions.GetColorJSONParam("Diffuse Color"); + if (diffuseStorable != null) diffuseStorable.val = _skinColorPrev; + } + } } namespace Skynet @@ -3922,4 +5604,4 @@ namespace Skynet ToolTipManager.UpdateTooltipPosition(); } } -} \ No newline at end of file +}