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];