diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index 376c662..b81d2b1 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -190,6 +190,25 @@ public partial class MeshPainter : MonoBehaviour
[Min(0f)]
public float paintInterval = 0f;
+ [Header("Brush - corner guard")]
+ [Tooltip("Suppresses a whole brush dab when too much of its footprint would wrap around a " +
+ "corner onto an adjacent, steeply angled surface (the classic 'painted around the " +
+ "corner' bleed, worst with large brushes). The dab is blocked entirely, not clipped: " +
+ "you simply cannot paint at a spot that would smear around the corner - aim cleaner or " +
+ "shrink the brush. OFF = paint anywhere (old behavior).")]
+ public bool cornerGuard = true;
+
+ [Tooltip("Surface under the brush whose normal deviates from the hit face by more than this " +
+ "angle counts as 'around the corner'. Lower = stricter (gentle bends already block); " +
+ "higher = only sharp corners count. 180 = effectively off.")]
+ [Range(0f, 180f)]
+ public float cornerGuardAngle = 50f;
+
+ [Tooltip("Block the dab once this share of the brush footprint is around the corner. " +
+ "0 = block at the slightest wrap, 1 = effectively off (only a fully wrapped dab blocks).")]
+ [Range(0f, 1f)]
+ public float cornerGuardMaxFraction = 0.25f;
+
[Header("Preview")]
[Tooltip("Brush preview at the hover point (shows size + mask without painting). " +
"Flat Decal = a flat quad in the tangent plane (cheap). On Surface = a real " +
@@ -583,7 +602,7 @@ public partial class MeshPainter : MonoBehaviour
private void UpdatePreview(Vector2 screenPos)
{
if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
- out Vector3 u, out Vector3 v, out Vector3 n))
+ out Vector3 u, out Vector3 v, out Vector3 n, out int _))
return;
_previewMat.SetColor(BrushColorId, brushColor);
@@ -771,7 +790,13 @@ public partial class MeshPainter : MonoBehaviour
private void PaintAtScreen(Vector2 screenPos)
{
if (!TryComputeBrushPlacement(screenPos, out Vector3 pLocal, out float radiusLocal,
- out Vector3 u, out Vector3 v, out Vector3 nLocal))
+ out Vector3 u, out Vector3 v, out Vector3 nLocal, out int hitTri))
+ return;
+
+ // Corner guard: if too much of the footprint would wrap around a corner, suppress the
+ // whole dab here - before drawing, emitting or marking the stroke painted, so nothing is
+ // clipped and remotes never replay a dab the owner refused to paint.
+ if (cornerGuard && DabWrapsAroundCorner(pLocal, radiusLocal, u, v, nLocal, hitTri))
return;
// Pick what this stamp paints with: a fresh random spray pattern, or the resolved
@@ -791,6 +816,85 @@ public partial class MeshPainter : MonoBehaviour
MarkStrokePainted(); // a stamp landed -> this stroke is undoable
}
+ ///
+ /// True if a brush dab at this placement would wrap excessively around a corner: more than
+ /// of its footprint sits on surface whose face normal
+ /// deviates from the hit face by more than . Used to suppress
+ /// the whole dab (not clip it). Geometry-only, so it behaves identically on the GPU and CPU
+ /// backends and needs no texture readback.
+ ///
+ private bool DabWrapsAroundCorner(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n, int hitTri)
+ {
+ if (_verts == null || _tris == null || _triCenter == null || _triRadius == null) return false;
+ if (hitTri < 0 || hitTri * 3 + 2 >= _tris.Length) return false;
+
+ // Reference normal = the hit triangle's geometric face normal, built with the SAME
+ // cross-product convention as every candidate below, so mesh winding cancels in the
+ // dot comparison (we only judge relative deviation from the surface we actually hit).
+ int h0 = _tris[hitTri * 3], h1 = _tris[hitTri * 3 + 1], h2 = _tris[hitTri * 3 + 2];
+ Vector3 fnHit = Vector3.Cross(_verts[h1] - _verts[h0], _verts[h2] - _verts[h0]);
+ if (fnHit.sqrMagnitude < 1e-12f) return false; // degenerate hit triangle -> allow
+ fnHit.Normalize();
+
+ float cosThresh = Mathf.Cos(Mathf.Clamp(cornerGuardAngle, 0f, 180f) * Mathf.Deg2Rad);
+ float frac = CornerWrapFraction(_verts, _tris, _triCenter, _triRadius,
+ pLocal, radiusLocal, u, v, n, fnHit, cosThresh);
+ return frac > cornerGuardMaxFraction;
+ }
+
+ ///
+ /// Estimates the share (0..1) of the brush footprint sitting on surface whose geometric face
+ /// normal deviates from by more than the threshold (given as its
+ /// cosine). Per in-range triangle (same circumscribed-circle cull as the painter), the area is
+ /// weighted by how many of {v0,v1,v2,centroid} fall inside the brush box - a cheap, backend-
+ /// agnostic footprint estimate (no GPU readback). Winding cancels because both normals use the
+ /// same cross convention. Returns 0 when nothing is in range (caller then allows the dab).
+ /// Static + array-parameterized so the geometry math is unit-testable without a live component.
+ ///
+ private static float CornerWrapFraction(
+ Vector3[] verts, int[] tris, Vector3[] triCenter, float[] triRadius,
+ Vector3 center, float r, Vector3 u, Vector3 v, Vector3 n, Vector3 fnHit, float cosThresh)
+ {
+ double totalW = 0.0, cornerW = 0.0;
+ int triCount = tris.Length / 3;
+ for (int t = 0; t < triCount; t++)
+ {
+ float reach = r * SquareReach + triRadius[t];
+ if ((triCenter[t] - center).sqrMagnitude > reach * reach) continue;
+
+ int i0 = tris[t * 3], i1 = tris[t * 3 + 1], i2 = tris[t * 3 + 2];
+ Vector3 a = verts[i0], b = verts[i1], c = verts[i2];
+ Vector3 ctr = (a + b + c) * (1f / 3f);
+
+ int inside = (InBrushBox(a, center, u, v, n, r) ? 1 : 0)
+ + (InBrushBox(b, center, u, v, n, r) ? 1 : 0)
+ + (InBrushBox(c, center, u, v, n, r) ? 1 : 0)
+ + (InBrushBox(ctr, center, u, v, n, r) ? 1 : 0);
+ if (inside == 0) continue;
+
+ Vector3 cross = Vector3.Cross(b - a, c - a);
+ float area2 = cross.magnitude; // 2 x triangle area (relative weight is enough)
+ if (area2 < 1e-12f) continue;
+
+ double w = inside * 0.25 * area2;
+ totalW += w;
+
+ float aligned = Vector3.Dot(cross / area2, fnHit); // -1..1, winding-cancelled
+ if (aligned < cosThresh) cornerW += w; // deviates past the threshold angle
+ }
+
+ return totalW > 1e-9 ? (float)(cornerW / totalW) : 0f;
+ }
+
+ /// True if a local-space point lies inside the brush box (same |du|,|dv|,|dn| <= r bound the stamp paints).
+ private static bool InBrushBox(Vector3 p, Vector3 center, Vector3 u, Vector3 v, Vector3 n, float r)
+ {
+ Vector3 d = p - center;
+ return Mathf.Abs(Vector3.Dot(d, u)) <= r
+ && Mathf.Abs(Vector3.Dot(d, v)) <= r
+ && Mathf.Abs(Vector3.Dot(d, n)) <= r;
+ }
+
///
/// 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
@@ -838,9 +942,9 @@ public partial class MeshPainter : MonoBehaviour
/// density) and the orthonormal tangent frame (u/v/n). Shared by painting and preview.
///
private bool TryComputeBrushPlacement(Vector2 screenPos, out Vector3 pLocal, out float radiusLocal,
- out Vector3 u, out Vector3 v, out Vector3 nLocal)
+ out Vector3 u, out Vector3 v, out Vector3 nLocal, out int hitTri)
{
- pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default;
+ pLocal = default; radiusLocal = 0f; u = default; v = default; nLocal = default; hitTri = -1;
if (!TryRaycastPaintMesh(screenPos, out RaycastHit hit)) return false;
if (_verts == null || hit.triangleIndex < 0) return false;
@@ -851,6 +955,7 @@ public partial class MeshPainter : MonoBehaviour
// triangle's UV density so the brush keeps a constant visual size.
float scale = _avgUvToLocal;
int t = hit.triangleIndex;
+ hitTri = t;
if (t * 3 + 2 < _tris.Length)
{
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];