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)