diff --git a/Assets/Scripts/MeshPainter.Pick.cs b/Assets/Scripts/MeshPainter.Pick.cs
index defe685..d8372b6 100644
--- a/Assets/Scripts/MeshPainter.Pick.cs
+++ b/Assets/Scripts/MeshPainter.Pick.cs
@@ -17,8 +17,10 @@ public partial class MeshPainter
// same canonical layout, so the pick matches what is stored bit-for-bit.
// - Any foreign object: read texel-exact from its material maps at the hit UV
// (BaseMap/MetallicGloss/Occlusion/Emission) combined with the material scalars, per
- // the URP/Lit conventions, falling back to scalar properties where a map is absent or
- // the collider carries no UV (non-MeshCollider).
+ // the URP/Lit conventions. The UV comes from hit.textureCoord (non-convex MeshCollider)
+ // or, when the collider can't supply one (convex MeshCollider / primitive collider), is
+ // recovered from the renderer's mesh; it falls back to scalars where a map is absent and
+ // to the on-screen pixel where a base map exists but no UV can be resolved.
[Header("Brush - pipette (eyedropper)")]
[Tooltip("Pipette mode: a click samples ALL channel values at the hit point on ANY surface " +
@@ -141,7 +143,7 @@ public partial class MeshPainter
///
/// Picks from a foreign object's material at the hit UV: texel-exact texture samples
- /// (where the surface has a MeshCollider and the map exists) combined with the material
+ /// (where a UV at the hit can be resolved and the map exists) combined with the material
/// scalars per the URP/Lit conventions, with scalar fallbacks everywhere else.
///
private bool PickFromForeignMaterial(RaycastHit hit)
@@ -154,10 +156,27 @@ public partial class MeshPainter
Material mat = rend.sharedMaterial;
if (!mat) return false;
- // textureCoord is only meaningful for a MeshCollider; other collider shapes carry no UV,
- // so we fall back to the material's uniform scalar values for those.
- bool hasUv = col is MeshCollider;
- Vector2 uv = hasUv ? hit.textureCoord : Vector2.zero;
+ // A UV at the hit is required to sample any material map texel-exact. hit.textureCoord is
+ // only populated for a NON-convex MeshCollider; convex MeshColliders and primitive colliders
+ // (Box/Sphere/Capsule/Terrain) return Vector2.zero. In that case recover the UV from the
+ // renderer's own mesh (ray vs. triangles), which works for any solid collider whose mesh is
+ // readable.
+ Vector2 uv;
+ bool hasUv;
+ if (col is MeshCollider mc && !mc.convex && hit.triangleIndex >= 0)
+ {
+ uv = hit.textureCoord;
+ hasUv = true;
+ }
+ else
+ {
+ hasUv = TryRecoverMeshUv(rend, hit, out uv);
+ }
+
+ // A base map exists but no UV could be resolved (e.g. a non-readable mesh behind a convex
+ // collider): a texel-exact read is impossible, so defer to the rendered on-screen pixel,
+ // which at least reflects the texture - rather than silently picking the flat _BaseColor.
+ if (!hasUv && GetTexProp(mat, "_BaseMap", "_MainTex")) return false;
// --- Albedo: BaseMap (sRGB) * BaseColor ---
Color baseCol = GetColorProp(mat, "_BaseColor", "_Color", Color.white);
@@ -405,4 +424,85 @@ public partial class MeshPainter
if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp);
return c;
}
+
+ ///
+ /// Recovers the UV at a hit point by intersecting the camera ray with the renderer's own mesh
+ /// (Moller-Trumbore against each triangle, nearest hit wins). Needed when the collider that was
+ /// hit cannot supply hit.textureCoord - a convex MeshCollider or any primitive collider. Requires
+ /// a MeshFilter whose mesh is readable and UV-mapped; returns false otherwise so the caller can
+ /// fall back. A one-shot click cost, so the O(triangles) sweep is acceptable.
+ ///
+ private bool TryRecoverMeshUv(Renderer rend, RaycastHit hit, out Vector2 uv)
+ {
+ uv = default;
+ if (!rend) return false;
+ MeshFilter mf = rend.GetComponent();
+ Mesh mesh = mf ? mf.sharedMesh : null;
+ if (!mesh || !mesh.isReadable) return false;
+
+ Vector2[] uvs = mesh.uv;
+ if (uvs == null || uvs.Length == 0) return false;
+ Vector3[] verts = mesh.vertices;
+ int[] tris = mesh.triangles;
+
+ // Ray from the paint camera through the surface point (its UV is what we want). Fall back to
+ // shooting along the inverted hit normal when no camera is bound.
+ Vector3 ro, rd;
+ if (paintCamera)
+ {
+ ro = paintCamera.transform.position;
+ rd = (hit.point - ro).normalized;
+ }
+ else
+ {
+ rd = -hit.normal;
+ ro = hit.point - rd * 0.01f;
+ }
+
+ Matrix4x4 l2w = rend.transform.localToWorldMatrix;
+ float bestT = float.MaxValue;
+ Vector3 bestBary = default;
+ int bestTri = -1;
+ for (int i = 0; i + 2 < tris.Length; i += 3)
+ {
+ Vector3 a = l2w.MultiplyPoint3x4(verts[tris[i]]);
+ Vector3 b = l2w.MultiplyPoint3x4(verts[tris[i + 1]]);
+ Vector3 c = l2w.MultiplyPoint3x4(verts[tris[i + 2]]);
+ if (RayTriangle(ro, rd, a, b, c, out float t, out float u, out float v) && t < bestT)
+ {
+ bestT = t;
+ bestBary = new Vector3(1f - u - v, u, v);
+ bestTri = i;
+ }
+ }
+ if (bestTri < 0) return false;
+
+ uv = uvs[tris[bestTri]] * bestBary.x + uvs[tris[bestTri + 1]] * bestBary.y + uvs[tris[bestTri + 2]] * bestBary.z;
+ return true;
+ }
+
+ ///
+ /// Moller-Trumbore ray/triangle intersection. On a hit, is the ray distance
+ /// and (, ) are the barycentric weights of vertices b and
+ /// c (vertex a's weight is 1 - u - v). Small epsilons admit edge hits.
+ ///
+ private static bool RayTriangle(Vector3 ro, Vector3 rd, Vector3 a, Vector3 b, Vector3 c,
+ out float t, out float u, out float v)
+ {
+ t = u = v = 0f;
+ const float eps = 1e-7f;
+ Vector3 e1 = b - a, e2 = c - a;
+ Vector3 p = Vector3.Cross(rd, e2);
+ float det = Vector3.Dot(e1, p);
+ if (det > -eps && det < eps) return false; // ray parallel to the triangle
+ float inv = 1f / det;
+ Vector3 tvec = ro - a;
+ u = Vector3.Dot(tvec, p) * inv;
+ if (u < -1e-4f || u > 1f + 1e-4f) return false;
+ Vector3 q = Vector3.Cross(tvec, e1);
+ v = Vector3.Dot(rd, q) * inv;
+ if (v < -1e-4f || u + v > 1f + 1e-4f) return false;
+ t = Vector3.Dot(e2, q) * inv;
+ return t > eps;
+ }
}