From fd2cc9eb98f2bc7347c5167f1bf369b3f3199b40 Mon Sep 17 00:00:00 2001 From: MelekTaus Moribundus Date: Mon, 29 Jun 2026 20:58:07 +0200 Subject: [PATCH] Block paint dabs that wrap too far around corners Add a geometry-based gate in PaintAtScreen that estimates how much of the brush footprint lands on faces whose normals deviate sharply from the hit surface. When that fraction exceeds the threshold the whole dab is suppressed (no draw, emit, or undo mark) instead of smearing around the edge. Backend-independent (no GPU readback) and winding-robust. Tunable via cornerGuard / cornerGuardAngle / cornerGuardMaxFraction. Co-Authored-By: Claude Opus 4.8 --- Assets/Scripts/MeshPainter.cs | 113 ++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 4 deletions(-) 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];