diff --git a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
index 6f138e3..eeed0b1 100644
--- a/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
+++ b/Assets/Scripts/Components/MeshPainter/MeshPainter.cs
@@ -422,6 +422,8 @@ public partial class MeshPainter : MonoBehaviour
private bool _rayHitsSaturated; // log-once: a saturated buffer can silently drop our own collider
// --- preview overlay ---
+ /// True while the brush preview decal is actually drawn this frame (hover hits the paintable surface).
+ public bool BrushPreviewVisible { get; private set; }
public Shader previewShader; // optional; else Shader.Find("Hidden/MeshPaintPreview")
private Material _previewMat;
private Mesh _previewQuad; // unit quad in the tangent plane (flat decal mode)
@@ -785,6 +787,7 @@ public partial class MeshPainter : MonoBehaviour
// the same local space as the raycast hit and valid for skinned (baked) and static meshes.
Mesh previewMesh = _collider ? _collider.sharedMesh : null;
if (!previewMesh) return;
+ BrushPreviewVisible = true;
Matrix4x4 m = transform.localToWorldMatrix;
int sub = previewMesh.subMeshCount;
for (int i = 0; i < sub; i++)
@@ -804,6 +807,7 @@ public partial class MeshPainter : MonoBehaviour
frame.SetColumn(3, new Vector4(pos.x, pos.y, pos.z, 1f));
Graphics.DrawMesh(_previewQuad, transform.localToWorldMatrix * frame, _previewMat, layer, paintCamera, 0);
+ BrushPreviewVisible = true;
}
}
@@ -811,6 +815,8 @@ public partial class MeshPainter : MonoBehaviour
private void Update()
{
+ BrushPreviewVisible = false; // set again below only when the decal really draws this frame
+
if (!_setupValid) return;
if (useGpu != _gpuActive) SwitchBackend(useGpu);
diff --git a/Assets/Scripts/Components/UI/CursorController.cs b/Assets/Scripts/Components/UI/CursorController.cs
new file mode 100644
index 0000000..ff72572
--- /dev/null
+++ b/Assets/Scripts/Components/UI/CursorController.cs
@@ -0,0 +1,98 @@
+using UnityEngine;
+
+///
+/// Swaps the hardware mouse cursor with the paint state of the local painter: normal outside
+/// paint mode, brush while painting, eyedropper while the pipette is active. Lives once in the
+/// Main scene; remote painters never have PaintingEnabled, so polling the manager's local
+/// painter is unambiguous. While a camera rig locks/hides the cursor this is simply invisible.
+///
+public class CursorController : MonoBehaviour
+{
+ [Tooltip("Source of the local painter. Found automatically when left empty.")]
+ [SerializeField] private GameModeManager modeManager;
+
+ [Header("Cursors (import type: Cursor)")]
+ [SerializeField] private Texture2D normalCursor;
+ [Tooltip("Click point inside the texture, in pixels from the top-left.")]
+ [SerializeField] private Vector2 normalHotspot = Vector2.zero;
+ [SerializeField] private Texture2D paintCursor;
+ [SerializeField] private Vector2 paintHotspot = new(16f, 16f);
+ [SerializeField] private Texture2D pipetteCursor;
+ [SerializeField] private Vector2 pipetteHotspot = new(1f, 30f);
+
+ private enum State { None, Normal, Paint, Pipette }
+
+ private State _applied = State.None;
+ private CursorMode _appliedMode = CursorMode.Auto;
+
+#if UNITY_EDITOR
+ // Hardware cursors are composited by the OS and never end up in Unity Recorder captures.
+ // While a recording runs we force the software cursor (rendered by Unity -> captured).
+ // RecorderWindow lives in the editor-only Unity.Recorder.Editor assembly, hence reflection.
+ private static readonly System.Type RecorderWindowType =
+ System.Type.GetType("UnityEditor.Recorder.RecorderWindow, Unity.Recorder.Editor");
+ private static readonly System.Reflection.MethodInfo RecorderIsRecording =
+ RecorderWindowType?.GetMethod("IsRecording");
+ private Object _recorderWindow;
+ private float _nextRecorderPoll;
+ private bool _recorderActive;
+
+ private bool RecorderActive()
+ {
+ if (RecorderIsRecording == null) return false;
+ if (Time.unscaledTime < _nextRecorderPoll) return _recorderActive;
+ _nextRecorderPoll = Time.unscaledTime + 0.5f;
+ if (!_recorderWindow)
+ {
+ var windows = Resources.FindObjectsOfTypeAll(RecorderWindowType);
+ _recorderWindow = windows.Length > 0 ? windows[0] : null;
+ }
+ _recorderActive = _recorderWindow && (bool)RecorderIsRecording.Invoke(_recorderWindow, null);
+ return _recorderActive;
+ }
+#endif
+
+ private void Awake()
+ {
+ if (!modeManager) modeManager = FindFirstObjectByType();
+ }
+
+ // LateUpdate: MeshPainter.Update settles this frame's pipette state (Shift/Ctrl momentary
+ // pipette) first, so the cursor never lags a frame behind the modifier.
+ private void LateUpdate()
+ {
+ var painter = modeManager ? modeManager.Painter : null;
+ State want = State.Normal;
+ // Over UI neither the pipette nor the brush can act -> normal arrow.
+ if (painter && painter.PaintingEnabled && !InputUtil.PointerOverUi())
+ {
+ if (painter.pipette) want = State.Pipette;
+ // Paint cursor only while painting is actually possible, i.e. the brush preview
+ // decal is drawn at the hover point this frame; hovering off the mesh -> normal.
+ else if (painter.BrushPreviewVisible) want = State.Paint;
+ }
+
+ var mode = CursorMode.Auto;
+#if UNITY_EDITOR
+ if (RecorderActive()) mode = CursorMode.ForceSoftware;
+#endif
+
+ if (want == _applied && mode == _appliedMode) return;
+ _applied = want;
+ _appliedMode = mode;
+
+ switch (want)
+ {
+ case State.Paint: Cursor.SetCursor(paintCursor, paintHotspot, mode); break;
+ case State.Pipette: Cursor.SetCursor(pipetteCursor, pipetteHotspot, mode); break;
+ default: Cursor.SetCursor(normalCursor, normalHotspot, mode); break;
+ }
+ }
+
+ private void OnDisable()
+ {
+ Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto); // back to the OS cursor
+ _applied = State.None;
+ _appliedMode = CursorMode.Auto;
+ }
+}
diff --git a/Assets/Scripts/Components/UI/CursorController.cs.meta b/Assets/Scripts/Components/UI/CursorController.cs.meta
new file mode 100644
index 0000000..d6e3b06
--- /dev/null
+++ b/Assets/Scripts/Components/UI/CursorController.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: aa11882355996204c8d14bb57c6a1838
\ No newline at end of file
diff --git a/Assets/Textures/Cursors.meta b/Assets/Textures/Cursors.meta
new file mode 100644
index 0000000..746e8dc
--- /dev/null
+++ b/Assets/Textures/Cursors.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5e6c24c7713f1fa4c9294993fc8cf72f
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Textures/Cursors/Cursor_Normal.png b/Assets/Textures/Cursors/Cursor_Normal.png
new file mode 100644
index 0000000..9ccc10e
Binary files /dev/null and b/Assets/Textures/Cursors/Cursor_Normal.png differ
diff --git a/Assets/Textures/Cursors/Cursor_Normal.png.meta b/Assets/Textures/Cursors/Cursor_Normal.png.meta
new file mode 100644
index 0000000..bd0d528
--- /dev/null
+++ b/Assets/Textures/Cursors/Cursor_Normal.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 1c39e79aab8ef894ebdf1c1bc6d1df3b
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 1
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 0
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 1
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 7
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 0
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Textures/Cursors/Cursor_Paint.png b/Assets/Textures/Cursors/Cursor_Paint.png
new file mode 100644
index 0000000..68758f0
Binary files /dev/null and b/Assets/Textures/Cursors/Cursor_Paint.png differ
diff --git a/Assets/Textures/Cursors/Cursor_Paint.png.meta b/Assets/Textures/Cursors/Cursor_Paint.png.meta
new file mode 100644
index 0000000..f3c4922
--- /dev/null
+++ b/Assets/Textures/Cursors/Cursor_Paint.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: ce31367b5800e9841a6a226c44bbe684
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 1
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 0
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 1
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 7
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 0
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Textures/Cursors/Cursor_Pipette.png b/Assets/Textures/Cursors/Cursor_Pipette.png
new file mode 100644
index 0000000..e0a0c73
Binary files /dev/null and b/Assets/Textures/Cursors/Cursor_Pipette.png differ
diff --git a/Assets/Textures/Cursors/Cursor_Pipette.png.meta b/Assets/Textures/Cursors/Cursor_Pipette.png.meta
new file mode 100644
index 0000000..76f182b
--- /dev/null
+++ b/Assets/Textures/Cursors/Cursor_Pipette.png.meta
@@ -0,0 +1,117 @@
+fileFormatVersion: 2
+guid: 74a0bb51745955c4ea6d1e59c32b2a1a
+TextureImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 13
+ mipmaps:
+ mipMapMode: 0
+ enableMipMap: 0
+ sRGBTexture: 1
+ linearTexture: 0
+ fadeOut: 0
+ borderMipMap: 0
+ mipMapsPreserveCoverage: 0
+ alphaTestReferenceValue: 0.5
+ mipMapFadeDistanceStart: 1
+ mipMapFadeDistanceEnd: 3
+ bumpmap:
+ convertToNormalMap: 0
+ externalNormalMap: 0
+ heightScale: 0.25
+ normalMapFilter: 0
+ flipGreenChannel: 0
+ isReadable: 1
+ streamingMipmaps: 0
+ streamingMipmapsPriority: 0
+ vTOnly: 0
+ ignoreMipmapLimit: 0
+ grayScaleToAlpha: 0
+ generateCubemap: 6
+ cubemapConvolution: 0
+ seamlessCubemap: 0
+ textureFormat: 1
+ maxTextureSize: 2048
+ textureSettings:
+ serializedVersion: 2
+ filterMode: 0
+ aniso: 1
+ mipBias: 0
+ wrapU: 1
+ wrapV: 1
+ wrapW: 1
+ nPOTScale: 0
+ lightmap: 0
+ compressionQuality: 50
+ spriteMode: 0
+ spriteExtrude: 1
+ spriteMeshType: 1
+ alignment: 0
+ spritePivot: {x: 0.5, y: 0.5}
+ spritePixelsToUnits: 100
+ spriteBorder: {x: 0, y: 0, z: 0, w: 0}
+ spriteGenerateFallbackPhysicsShape: 1
+ alphaUsage: 1
+ alphaIsTransparency: 1
+ spriteTessellationDetail: -1
+ textureType: 7
+ textureShape: 1
+ singleChannelComponent: 0
+ flipbookRows: 1
+ flipbookColumns: 1
+ maxTextureSizeSet: 0
+ compressionQualitySet: 0
+ textureFormatSet: 0
+ ignorePngGamma: 0
+ applyGammaDecoding: 0
+ swizzle: 50462976
+ cookieLightType: 0
+ platformSettings:
+ - serializedVersion: 4
+ buildTarget: DefaultTexturePlatform
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 0
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ - serializedVersion: 4
+ buildTarget: Standalone
+ maxTextureSize: 2048
+ resizeAlgorithm: 0
+ textureFormat: -1
+ textureCompression: 1
+ compressionQuality: 50
+ crunchedCompression: 0
+ allowsAlphaSplitting: 0
+ overridden: 0
+ ignorePlatformSupport: 0
+ androidETC2FallbackOverride: 0
+ forceMaximumCompressionQuality_BC6H_BC7: 0
+ spriteSheet:
+ serializedVersion: 2
+ sprites: []
+ outline: []
+ customData:
+ physicsShape: []
+ bones: []
+ spriteID:
+ internalID: 0
+ vertices: []
+ indices:
+ edges: []
+ weights: []
+ secondaryTextures: []
+ spriteCustomMetadata:
+ entries: []
+ nameFileIdTable: {}
+ mipmapLimitGroupName:
+ pSDRemoveMatte: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant: