From 95fa5cc22d57a0ff993e64317cad4adf0f68ecad Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Thu, 25 Jun 2026 05:11:36 +0200 Subject: [PATCH] Persist painted maps and HUD layout across sessions Add MeshPainter.Persistence.cs: auto-save the three canonical paint maps (albedo, MSO, emission) as PNGs under persistentDataPath on quit/play-stop and auto-load them as the backend seed on start, so painting survives sessions. Reuses the existing ReadCanonical/SetupBackend seed round-trip; no new color-space code. Add PaintHud.Persistence.cs: auto-save draggable panel positions and the widget-bound painter values to a small JSON layout file, restored on start via the painter -> SyncFromPainter -> widgets path. Complete SyncFromPainter to also cover the spray sliders. --- Assets/Scripts/MeshPainter.Persistence.cs | 164 ++++++++++++++ .../Scripts/MeshPainter.Persistence.cs.meta | 2 + Assets/Scripts/MeshPainter.cs | 12 +- Assets/Scripts/PaintHud.Persistence.cs | 214 ++++++++++++++++++ Assets/Scripts/PaintHud.Persistence.cs.meta | 2 + Assets/Scripts/PaintHud.cs | 12 +- 6 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 Assets/Scripts/MeshPainter.Persistence.cs create mode 100644 Assets/Scripts/MeshPainter.Persistence.cs.meta create mode 100644 Assets/Scripts/PaintHud.Persistence.cs create mode 100644 Assets/Scripts/PaintHud.Persistence.cs.meta diff --git a/Assets/Scripts/MeshPainter.Persistence.cs b/Assets/Scripts/MeshPainter.Persistence.cs new file mode 100644 index 0000000..b86dbcd --- /dev/null +++ b/Assets/Scripts/MeshPainter.Persistence.cs @@ -0,0 +1,164 @@ +using System.IO; +using UnityEngine; +using UnityEngine.SceneManagement; + +public partial class MeshPainter +{ + // --- Persistence: save the painted maps to disk and reload them next session --- + + [Header("Persistence")] + [Tooltip("Save the painted maps to disk on quit / play-stop and reload them on the next " + + "start, so the painting persists across sessions. Off = paint is volatile (as before).")] + public bool persistPaint = true; + + [Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " + + "explicitly to keep a save stable across object renames or to share one save.")] + public string saveId = ""; + + // Raw, bit-exact map dump: 'CHMP' magic + version + width + height + map count, then each + // map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity + // and round-trips the exact canonical bytes ReadCanonical produces. + private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian + private const int PaintFileVersion = 1; + + private bool _savedOnTeardown; // guards against a double save across OnApplicationQuit + OnDestroy + + private static string PaintSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint"); + + private string PaintSavePath() + { + string key = string.IsNullOrEmpty(saveId) + ? SanitizeKey(SceneManager.GetActiveScene().name + "_" + gameObject.name) + : SanitizeKey(saveId); + return Path.Combine(PaintSaveDir, key + ".paint"); + } + + private static string SanitizeKey(string s) + { + foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); + return s.Replace(' ', '_'); + } + + /// Reads all maps from the active backend (canonical layout) and writes them to the save file. + public void SavePaint() + { + if (!persistPaint || !_setupValid) return; + + try + { + Directory.CreateDirectory(PaintSaveDir); + int n = textureSize * textureSize; + + using (var fs = new FileStream(PaintSavePath(), FileMode.Create, FileAccess.Write)) + using (var w = new BinaryWriter(fs)) + { + w.Write(PaintFileMagic); + w.Write(PaintFileVersion); + w.Write(textureSize); + w.Write(textureSize); + w.Write(MAP_COUNT); + + var bytes = new byte[n * 4]; + for (int mi = 0; mi < MAP_COUNT; mi++) + { + Color32[] px = ReadCanonical(mi); + int count = Mathf.Min(n, px.Length); + for (int i = 0; i < count; i++) + { + int o = i * 4; + bytes[o] = px[i].r; + bytes[o + 1] = px[i].g; + bytes[o + 2] = px[i].b; + bytes[o + 3] = px[i].a; + } + w.Write(bytes, 0, count * 4); + } + } + } + catch (System.Exception e) + { + Debug.LogWarning($"[MeshPainter] Saving paint failed: {e.Message}", this); + } + } + + /// + /// Loads the saved maps in canonical layout for seeding, or null if none / incompatible. + /// Per-map null entries are tolerated by both backends (they fall back to the material seed). + /// + private Color32[][] LoadPaintMaps() + { + if (!persistPaint) return null; + + string path = PaintSavePath(); + if (!File.Exists(path)) return null; + + try + { + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read)) + using (var r = new BinaryReader(fs)) + { + if (r.ReadUInt32() != PaintFileMagic) return null; + int ver = r.ReadInt32(); + int w = r.ReadInt32(); + int h = r.ReadInt32(); + int maps = r.ReadInt32(); + + // Resolution / version change -> ignore the save rather than smear mismatched + // data; the brush re-seeds from the material instead. + if (ver != PaintFileVersion || w != textureSize || h != textureSize) + { + Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h} v{ver}, expected " + + $"{textureSize}x{textureSize} v{PaintFileVersion}). Re-seeding from material.", this); + return null; + } + + int n = textureSize * textureSize; + var result = new Color32[MAP_COUNT][]; + int load = Mathf.Min(maps, MAP_COUNT); + for (int mi = 0; mi < load; mi++) + { + byte[] raw = r.ReadBytes(n * 4); + if (raw.Length != n * 4) break; // truncated file -> remaining maps stay seeded + + var px = new Color32[n]; + for (int i = 0; i < n; i++) + { + int o = i * 4; + px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]); + } + result[mi] = px; + } + return result; + } + } + catch (System.Exception e) + { + Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this); + return null; + } + } + + /// Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes). + private void SavePaintOnTeardown() + { + if (_savedOnTeardown) return; + _savedOnTeardown = true; + SavePaint(); + } + + /// Deletes this painter's save file (next start re-seeds from the material). Editor convenience. + [ContextMenu("Clear Saved Paint")] + public void ClearSavedPaint() + { + try + { + string path = PaintSavePath(); + if (File.Exists(path)) File.Delete(path); + Debug.Log($"[MeshPainter] Cleared saved paint: {path}", this); + } + catch (System.Exception e) + { + Debug.LogWarning($"[MeshPainter] Clearing saved paint failed: {e.Message}", this); + } + } +} diff --git a/Assets/Scripts/MeshPainter.Persistence.cs.meta b/Assets/Scripts/MeshPainter.Persistence.cs.meta new file mode 100644 index 0000000..543e274 --- /dev/null +++ b/Assets/Scripts/MeshPainter.Persistence.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cbe2b3b1d4eb9eb489c40b1ee7f31ac3 \ No newline at end of file diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index 2be4cb8..00c898a 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -295,7 +295,9 @@ public partial class MeshPainter : MonoBehaviour else if (_materialInstance.HasProperty(ColorId)) _materialInstance.SetColor(ColorId, Color.white); BuildSeedColors(); - SetupBackend(useGpu, _seedCanonical); + // Persistence: seed from the saved maps if present (per-map null falls back to the + // material seed), otherwise from the material as before. + SetupBackend(useGpu, LoadPaintMaps() ?? _seedCanonical); ConfigureMaterialPbr(); EnsureProceduralMask(); @@ -841,8 +843,16 @@ public partial class MeshPainter : MonoBehaviour return es && es.IsPointerOverGameObject(); } + // Quit / play-stop (and standalone exit) fire OnApplicationQuit before teardown; persist + // here so the painted maps survive into the next session. + private void OnApplicationQuit() => SavePaintOnTeardown(); + private void OnDestroy() { + // Save before tearing the backend down (ReadCanonical needs the live maps). Covers a + // scene unload that does not raise OnApplicationQuit; the once-guard prevents a re-save. + SavePaintOnTeardown(); + if (_bakedMesh) Destroy(_bakedMesh); if (_materialInstance) Destroy(_materialInstance); if (_proceduralMask) Destroy(_proceduralMask); diff --git a/Assets/Scripts/PaintHud.Persistence.cs b/Assets/Scripts/PaintHud.Persistence.cs new file mode 100644 index 0000000..129f5bc --- /dev/null +++ b/Assets/Scripts/PaintHud.Persistence.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; +using UnityEngine.SceneManagement; + +public partial class PaintHud +{ + // --- Persistence: panel positions + the widget-bound painter values --- + // + // Saved on quit / play-stop and restored on the next start, so the HUD opens where you left it + // with the brush settings you last used. This is small structured state (a handful of positions + // and scalars), so it goes to a human-readable JSON file - unlike the painter's bulk pixel maps + // (MeshPainter.Persistence) which use a raw byte dump. The two saves are independent: this one + // owns the HUD layout + brush values, the painter owns the canvas pixels. + + [Header("Persistence")] + [Tooltip("Save the panel positions and their values on quit / play-stop and restore them on the " + + "next start. Off = the HUD opens at its authored layout / painter defaults every session.")] + public bool persistHud = true; + + [Tooltip("Save file identifier. Empty = derived from the active scene name. Set explicitly to keep " + + "the layout stable across scene renames or to share one layout between scenes.")] + public string hudSaveId = ""; + + private const int HudFileVersion = 1; + private bool _hudSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy + + private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint"); + + private string HudSavePath() + { + string key = string.IsNullOrEmpty(hudSaveId) + ? SanitizeHudKey(SceneManager.GetActiveScene().name + "_hud") + : SanitizeHudKey(hudSaveId); + return Path.Combine(HudSaveDir, key + ".hudlayout"); + } + + private static string SanitizeHudKey(string s) + { + foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); + return s.Replace(' ', '_'); + } + + [Serializable] + private class HudState + { + public int version = HudFileVersion; + public bool hasValues; // false = no painter was bound when saved -> restore positions only + public List panels = new List(); + public HudValues values = new HudValues(); + } + + [Serializable] + private struct PanelPos + { + public string name; + public Vector2 pos; // anchoredPosition (point-anchored panels -> fully determines placement) + } + + // Mirrors exactly the painter fields the HUD widgets bind to (PaintHud.WirePanel). + [Serializable] + private class HudValues + { + public bool paintAlbedo, paintMetallicSmoothness, paintOcclusion, paintEmission, spray; + public Color brushColor; + public float metallic, smoothness, metallicSmoothnessOpacity, occlusion, occlusionOpacity; + public Color emissionColor; + public float emissionIntensity; + public int brushSize; + public int sprayDots; + public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax; + } + + private void OnApplicationQuit() => SaveHudOnTeardown(); + + /// Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes). + private void SaveHudOnTeardown() + { + if (_hudSavedOnTeardown) return; + _hudSavedOnTeardown = true; + SaveHudState(); + } + + /// Writes the current panel positions and bound painter values to disk. + public void SaveHudState() + { + if (!persistHud || !_panel) return; + + try + { + var state = new HudState(); + var panel = _panel.transform; + + foreach (var name in DraggablePanels) + { + if (FindDeep(panel, name) is RectTransform rt) + state.panels.Add(new PanelPos { name = name, pos = rt.anchoredPosition }); + } + + if (painter) + { + state.hasValues = true; + var v = state.values; + v.paintAlbedo = painter.paintAlbedo; + v.paintMetallicSmoothness = painter.paintMetallicSmoothness; + v.paintOcclusion = painter.paintOcclusion; + v.paintEmission = painter.paintEmission; + v.spray = painter.spray; + v.brushColor = painter.brushColor; + v.metallic = painter.metallic; + v.smoothness = painter.smoothness; + v.metallicSmoothnessOpacity = painter.metallicSmoothnessOpacity; + v.occlusion = painter.occlusion; + v.occlusionOpacity = painter.occlusionOpacity; + v.emissionColor = painter.emissionColor; + v.emissionIntensity = painter.emissionIntensity; + v.brushSize = painter.brushSize; + v.sprayDots = painter.sprayDots; + v.sprayDotSizeMin = painter.sprayDotSizeMin; + v.sprayDotSizeMax = painter.sprayDotSizeMax; + v.sprayDotAlphaMin = painter.sprayDotAlphaMin; + v.sprayDotAlphaMax = painter.sprayDotAlphaMax; + } + + Directory.CreateDirectory(HudSaveDir); + File.WriteAllText(HudSavePath(), JsonUtility.ToJson(state, true)); + } + catch (Exception e) + { + Debug.LogWarning($"[PaintHud] Saving HUD state failed: {e.Message}", this); + } + } + + /// + /// Restores the saved panel positions and brush values. Values are written into the painter (the + /// source of truth the widgets read) and then pushed to every widget via , + /// reusing the exact same painter -> HUD path the pipette pick uses. Positions are applied last. + /// + public void RestoreHudState() + { + if (!persistHud || !_panel) return; + + var state = LoadHudState(); + if (state == null) return; + + if (painter && state.hasValues && state.values != null) + { + var v = state.values; + painter.paintAlbedo = v.paintAlbedo; + painter.paintMetallicSmoothness = v.paintMetallicSmoothness; + painter.paintOcclusion = v.paintOcclusion; + painter.paintEmission = v.paintEmission; + painter.spray = v.spray; + painter.brushColor = v.brushColor; + painter.metallic = v.metallic; + painter.smoothness = v.smoothness; + painter.metallicSmoothnessOpacity = v.metallicSmoothnessOpacity; + painter.occlusion = v.occlusion; + painter.occlusionOpacity = v.occlusionOpacity; + painter.emissionColor = v.emissionColor; + painter.emissionIntensity = v.emissionIntensity; + painter.brushSize = v.brushSize; + painter.sprayDots = v.sprayDots; + painter.sprayDotSizeMin = v.sprayDotSizeMin; + painter.sprayDotSizeMax = v.sprayDotSizeMax; + painter.sprayDotAlphaMin = v.sprayDotAlphaMin; + painter.sprayDotAlphaMax = v.sprayDotAlphaMax; + SyncFromPainter(); + } + + // Point-anchored panels: anchoredPosition is resolution-independent under the canvas' + // ScaleWithScreenSize scaler, so the raw saved value places them identically next session. + var root = _panel.transform; + if (state.panels != null) + foreach (var p in state.panels) + if (FindDeep(root, p.name) is RectTransform rt) + rt.anchoredPosition = p.pos; + } + + private HudState LoadHudState() + { + string path = HudSavePath(); + if (!File.Exists(path)) return null; + + try + { + var state = JsonUtility.FromJson(File.ReadAllText(path)); + if (state == null || state.version != HudFileVersion) return null; + return state; + } + catch (Exception e) + { + Debug.LogWarning($"[PaintHud] Loading HUD state failed: {e.Message}", this); + return null; + } + } + + /// Deletes the saved HUD layout (next start opens at the authored layout / defaults). Editor convenience. + [ContextMenu("Clear Saved HUD Layout")] + public void ClearSavedHudState() + { + try + { + string path = HudSavePath(); + if (File.Exists(path)) File.Delete(path); + Debug.Log($"[PaintHud] Cleared saved HUD layout: {path}", this); + } + catch (Exception e) + { + Debug.LogWarning($"[PaintHud] Clearing saved HUD layout failed: {e.Message}", this); + } + } +} diff --git a/Assets/Scripts/PaintHud.Persistence.cs.meta b/Assets/Scripts/PaintHud.Persistence.cs.meta new file mode 100644 index 0000000..e0c360e --- /dev/null +++ b/Assets/Scripts/PaintHud.Persistence.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4fb3415c5348e5c49955e222fd43a55c \ No newline at end of file diff --git a/Assets/Scripts/PaintHud.cs b/Assets/Scripts/PaintHud.cs index a780240..2f4eeb3 100644 --- a/Assets/Scripts/PaintHud.cs +++ b/Assets/Scripts/PaintHud.cs @@ -25,7 +25,7 @@ using UnityEngine.InputSystem.UI; /// sliders, an HSV picker + intensity for emission, and brush size. Attach to an empty GameObject. /// [DisallowMultipleComponent] -public class PaintHud : MonoBehaviour +public partial class PaintHud : MonoBehaviour { [Tooltip("Mode manager driving visibility. Empty = found automatically.")] public GameModeManager modeManager; @@ -115,6 +115,9 @@ public class PaintHud : MonoBehaviour // Re-sync every widget when the pipette adopts a sampled material (painter -> HUD). if (painter) painter.Picked += SyncFromPainter; + // Restore the saved panel positions + widget values from the previous session. + RestoreHudState(); + // Set initial visibility (the manager's first SwitchTo may have fired before this Start). OnModeChanged(modeManager ? modeManager.ActiveMode : null); } @@ -122,6 +125,7 @@ public class PaintHud : MonoBehaviour private void OnDestroy() { if (painter) painter.Picked -= SyncFromPainter; + SaveHudOnTeardown(); } private void OnModeChanged(GameMode mode) @@ -240,6 +244,12 @@ public class PaintHud : MonoBehaviour SetSlider(panel, SliderOcclusionAlpha, painter.occlusionOpacity); SetSlider(panel, SliderEmissionIntensity, painter.emissionIntensity); SetSlider(panel, SliderBrushSize, painter.brushSize); + + SetSlider(panel, SliderSprayDots, painter.sprayDots); + SetSlider(panel, SliderSprayDotSizeMin, painter.sprayDotSizeMin); + SetSlider(panel, SliderSprayDotSizeMax, painter.sprayDotSizeMax); + SetSlider(panel, SliderSprayDotAlphaMin, painter.sprayDotAlphaMin); + SetSlider(panel, SliderSprayDotAlphaMax, painter.sprayDotAlphaMax); } private static void SetToggle(Transform panel, string name, bool value)