Add pipette (eyedropper) brush mode and wire it into the HUD

Pipette samples all painted channel values (albedo, metallic, smoothness,
occlusion, emission) at the clicked texel and adopts them into the brush
with every opacity forced to 1, instead of painting. The screen raycast is
extracted into a shared TryRaycastPaintMesh helper used by painting, preview
and the pipette. A pipette toggle is added to the HUD panel prefab and wired
in PaintHud.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-24 20:25:14 +02:00
1 parent cdeebf9243
commit 24a36c0e61
5 files changed
+509 -12

No files matched your search

+38 -12
View File
@@ -606,6 +606,22 @@ public partial class MeshPainter : MonoBehaviour
if (pressed && IsPointerOverUi())
pressed = false;
// Pipette (eyedropper): a fresh click samples ALL channel values at the hit point
// and adopts them into the brush instead of painting. It takes over the click
// entirely (no painting, no brush preview ring while active).
if (pipette)
{
if (pressed)
{
if (!_pickHeld) PickAtScreen(screenPos);
_pickHeld = true;
}
else _pickHeld = false;
_hasLastScreen = false; // keep the paint stroke state clean for when pipette turns off
return;
}
_pickHeld = false;
// Brush preview (size + mask) at the hover point - independent of painting,
// so you see the brush before pressing the button.
if (previewMode != BrushPreviewMode.Off && _previewMat && paintCamera)
@@ -691,22 +707,19 @@ public partial class MeshPainter : MonoBehaviour
}
/// <summary>
/// Raycasts at a screen position against the paint mesh and computes the brush
/// placement: hit point (local space), local 3D radius (constant visual size via UV
/// density) and the orthonormal tangent frame (u/v/n). Shared by painting and preview.
/// Raycasts at a screen position and returns the nearest hit that belongs to THIS paint
/// mesh. A plain raycast would return only the closest collider, so anything in front of
/// the mesh (the player capsule, props, the camera-collision proxy) could block it; we
/// scan all hits and keep the nearest on our own collider. Shared by painting, preview
/// and the pipette.
/// </summary>
private bool TryComputeBrushPlacement(Vector2 screenPos, out Vector3 pLocal, out float radiusLocal,
out Vector3 u, out Vector3 v, out Vector3 nLocal)
private bool TryRaycastPaintMesh(Vector2 screenPos, out RaycastHit hit)
{
pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default;
hit = default;
if (!paintCamera) return false;
Ray ray = paintCamera.ScreenPointToRay(screenPos);
// Pick the nearest hit that belongs to the paint mesh. A plain raycast would
// return only the closest collider, so anything in front of the mesh (the
// player capsule, props, the camera-collision proxy) could block painting.
int hitCount = Physics.RaycastNonAlloc(ray, _rayHits, 1000f, ~0, QueryTriggerInteraction.Ignore);
RaycastHit hit = default;
float nearest = float.MaxValue;
bool found = false;
for (int i = 0; i < hitCount; i++)
@@ -719,7 +732,20 @@ public partial class MeshPainter : MonoBehaviour
found = true;
}
}
if (!found) return false;
return found;
}
/// <summary>
/// Raycasts at a screen position against the paint mesh and computes the brush
/// placement: hit point (local space), local 3D radius (constant visual size via UV
/// density) and the orthonormal tangent frame (u/v/n). Shared by painting and preview.
/// </summary>
private bool TryComputeBrushPlacement(Vector2 screenPos, out Vector3 pLocal, out float radiusLocal,
out Vector3 u, out Vector3 v, out Vector3 nLocal)
{
pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default;
if (!TryRaycastPaintMesh(screenPos, out RaycastHit hit)) return false;
if (_verts == null || hit.triangleIndex < 0) return false;
pLocal = transform.InverseTransformPoint(hit.point);