Weapons/ToolGun/SnapGrid.cs
/// <summary>
/// Manages a world-space snap grid overlay projected onto a surface plane.
/// </summary>
public sealed class SnapGrid
{
public float CellSize { get; set; }
public float MaskRadius { get; set; }
/// <summary>
/// Fallback half-extent used when the hovered object has no usable bounds.
/// </summary>
public float GridSize { get; set; }
[ConVar( "snap_grid_no_fade", ConVarFlags.Cheat )]
private static bool NoFade { get; set; } = false;
public SnapGrid( float cellSize = 4f, float maskRadius = 64f, float gridSize = 48f )
{
CellSize = cellSize;
MaskRadius = maskRadius;
GridSize = gridSize;
}
private sealed class SnapGridSceneObject : SceneDynamicObject
{
public SnapGridSceneObject( SceneWorld world ) : base( world )
{
Transform = Transform.Zero;
Flags.IsOpaque = false;
Flags.IsTranslucent = true;
Flags.CastShadows = false;
RenderLayer = SceneRenderLayer.OverlayWithDepth;
}
public void Write( Vector3 faceOrigin, Vector3 faceRight, Vector3 faceUp, Vector2 faceHalfExtents, Vector3 aimPos, float maskRadius, float cellSize )
{
var halfW = faceHalfExtents.x;
var halfH = faceHalfExtents.y;
// The quad is always centred at the projected bounds centre.
var quadCenter = faceOrigin;
var v00 = quadCenter - faceRight * halfW - faceUp * halfH;
var v10 = quadCenter + faceRight * halfW - faceUp * halfH;
var v11 = quadCenter + faceRight * halfW + faceUp * halfH;
var v01 = quadCenter - faceRight * halfW + faceUp * halfH;
Bounds = BBox.FromPositionAndSize( quadCenter, MathF.Max( halfW, halfH ) * 2f );
Span<Vertex> verts = stackalloc Vertex[6]
{
new Vertex( v00 ),
new Vertex( v10 ),
new Vertex( v11 ),
new Vertex( v00 ),
new Vertex( v11 ),
new Vertex( v01 ),
};
Init( Graphics.PrimitiveType.Triangles );
AddVertex( verts );
}
}
private SnapGridSceneObject _sceneObj;
private Material _material;
private Vector3 _cachedOrigin;
private Vector3 _cachedNormal;
private Vector3 _cachedRight;
private Vector3 _cachedUp;
private Vector2 _cachedHalfExtents;
private GameObject _cachedObject;
private bool _hasPlane;
/// <summary>
/// The snapped world-space position of the highlighted corner
/// </summary>
public Vector3 LastSnapWorldPos { get; private set; }
/// <summary>
/// Returns the nearest grid corner index (cx, cy) and its world-space position,
/// given the face plane in world space and the aim world position.
/// </summary>
public static (int cx, int cy, Vector3 snapPos, float snapU, float snapV, bool snapAxisX, bool snapAxisY) ComputeSnap(
Vector3 faceOriginWs,
Vector3 faceRightWs,
Vector3 faceUpWs,
float cellSize,
Vector3 aimPosWs )
{
var offset = aimPosWs - faceOriginWs;
var u = Vector3.Dot( offset, faceRightWs );
var v = Vector3.Dot( offset, faceUpWs );
var cx = (int)MathF.Round( u / cellSize );
var cy = (int)MathF.Round( v / cellSize );
var distX = MathF.Abs( u - cx * cellSize );
var distY = MathF.Abs( v - cy * cellSize );
float snapU, snapV;
bool snapAxisX, snapAxisY;
// Near a corner: snap to intersection and show both lines.
if ( distX < cellSize * 0.35f && distY < cellSize * 0.35f )
{
snapU = cx * cellSize;
snapV = cy * cellSize;
snapAxisX = true;
snapAxisY = true;
}
else if ( distX <= distY )
{
snapU = cx * cellSize;
snapV = v;
snapAxisX = true;
snapAxisY = false;
}
else
{
snapU = u;
snapV = cy * cellSize;
snapAxisX = false;
snapAxisY = true;
}
var snapPos = faceOriginWs + faceRightWs * snapU + faceUpWs * snapV;
return (cx, cy, snapPos, snapU, snapV, snapAxisX, snapAxisY);
}
/// <summary>
/// Called every frame when the weld tool hovers a valid object.
/// After calling, read <see cref="LastSnapWorldPos"/> for a snapped position
/// </summary>
public void Update( SceneWorld world, GameObject hoveredObject, Vector3 aimWorldPos, Vector3 hitNormalWorld )
{
if ( !_sceneObj.IsValid() )
{
_material ??= Material.FromShader( "shaders/snap_grid.shader" );
_sceneObj = new SnapGridSceneObject( world ) { Material = _material };
}
_sceneObj.RenderingEnabled = true;
// Only recalculate the plane when the surface normal or hovered object changes
var faceNormal = hitNormalWorld.Normal;
var holdingUse = Input.Down( "use" );
var objectChanged = hoveredObject != _cachedObject;
var planeChanged = !_hasPlane || (objectChanged) || (!holdingUse && Vector3.Dot( faceNormal, _cachedNormal ) < 0.999f);
if ( planeChanged )
{
_cachedNormal = faceNormal;
_cachedObject = hoveredObject;
var (obbCenter, obbHalfExtents, obbRotation, obbValid) = GetObjectOBB( hoveredObject );
if ( obbValid )
{
// Align the grid so its "up" direction matches the object's world-space up
// projected onto the face plane. This orients grid lines with the object's
// natural rotation on every face, including tilted surfaces.
var objUp = obbRotation * Vector3.Up;
var dotUp = Vector3.Dot( objUp, faceNormal );
if ( MathF.Abs( dotUp ) < 0.99f )
{
// Project the object's up onto the face plane and use it as the grid up.
_cachedUp = (objUp - faceNormal * dotUp).Normal;
_cachedRight = Vector3.Cross( faceNormal, _cachedUp ).Normal;
}
else
{
// Degenerate: object's up is nearly parallel to the face normal
// (e.g., hovering the top/bottom face of an upright object).
// Fall back to the OBB axis most parallel to the face plane.
var a0 = obbRotation * new Vector3( 1, 0, 0 );
var a1 = obbRotation * new Vector3( 0, 1, 0 );
var a2 = obbRotation * new Vector3( 0, 0, 1 );
var d0 = MathF.Abs( Vector3.Dot( a0, faceNormal ) );
var d1 = MathF.Abs( Vector3.Dot( a1, faceNormal ) );
var d2 = MathF.Abs( Vector3.Dot( a2, faceNormal ) );
var refAxis = d0 <= d1 && d0 <= d2 ? a0 : d1 <= d2 ? a1 : a2;
_cachedRight = (refAxis - faceNormal * Vector3.Dot( refAxis, faceNormal )).Normal;
_cachedUp = Vector3.Cross( _cachedRight, faceNormal ).Normal;
}
_cachedOrigin = ProjectOntoPlane( obbCenter, aimWorldPos, faceNormal );
_cachedHalfExtents = ProjectedHalfExtents( obbHalfExtents, obbRotation, _cachedRight, _cachedUp );
}
else
{
// Fallback: generic tangent for world geometry.
var refAxis = MathF.Abs( Vector3.Dot( faceNormal, Vector3.Up ) ) > 0.9f
? Vector3.Forward
: Vector3.Up;
_cachedRight = Vector3.Cross( faceNormal, refAxis ).Normal;
_cachedUp = Vector3.Cross( _cachedRight, faceNormal ).Normal;
_cachedOrigin = aimWorldPos;
_cachedHalfExtents = new Vector2( GridSize, GridSize );
}
_hasPlane = true;
}
var halfExtents = _cachedHalfExtents;
// Scale down cell size so at least one cell fits within the object's face.
var cellSize = CellSize;
var minHalf = MathF.Min( halfExtents.x, halfExtents.y );
while ( minHalf < cellSize && cellSize > 0.1f )
cellSize *= 0.5f;
// Compute the nearest snap line
var (cx, cy, snapPos, snapU, snapV, snapAxisX, snapAxisY) = ComputeSnap( _cachedOrigin, _cachedRight, _cachedUp, cellSize, aimWorldPos );
LastSnapWorldPos = snapPos;
_sceneObj.Write( _cachedOrigin, _cachedRight, _cachedUp, halfExtents, aimWorldPos, MaskRadius, cellSize );
_sceneObj.Attributes.Set( "GridOrigin", _cachedOrigin );
_sceneObj.Attributes.Set( "GridRight", _cachedRight );
_sceneObj.Attributes.Set( "GridUp", _cachedUp );
_sceneObj.Attributes.Set( "AimPoint", aimWorldPos );
_sceneObj.Attributes.Set( "MaskRadius", NoFade ? float.MaxValue : MaskRadius );
_sceneObj.Attributes.Set( "HalfExtents", halfExtents );
_sceneObj.Attributes.Set( "CellSize", cellSize );
_sceneObj.Attributes.Set( "SnapCornerX", snapU / cellSize );
_sceneObj.Attributes.Set( "SnapCornerY", snapV / cellSize );
_sceneObj.Attributes.Set( "SnapAxisX", snapAxisX ? 1.0f : 0.0f );
_sceneObj.Attributes.Set( "SnapAxisY", snapAxisY ? 1.0f : 0.0f );
}
/// <summary>
/// Hide the overlay
/// </summary>
public void Hide()
{
if ( _sceneObj != null && _sceneObj.IsValid() )
_sceneObj.RenderingEnabled = false;
_hasPlane = false;
}
// ---------------------------------------------------------------------------
// Helpers
/// <summary>
/// Returns the OBB (oriented bounding box) of <paramref name="go"/> in world space.
/// Prefers model-local bounds (tight, rotation-aware) over collider world AABB.
/// </summary>
private static (Vector3 Center, Vector3 HalfExtents, Rotation Rotation, bool Valid) GetObjectOBB( GameObject go )
{
var worldTx = go.WorldTransform;
var rot = worldTx.Rotation;
var scale = worldTx.Scale;
// Try model-local bounds first — these are tight and unaffected by world rotation.
BBox? localBox = null;
var mr = go.GetComponentInChildren<ModelRenderer>( false );
if ( mr != null && mr.Model != null )
localBox = mr.Model.Bounds;
if ( localBox == null )
{
var smr = go.GetComponentInChildren<SkinnedModelRenderer>( false );
if ( smr != null && smr.Model != null )
localBox = smr.Model.Bounds;
}
if ( localBox != null )
{
var lb = localBox.Value;
if ( lb.Size.Length > 0.1f )
{
var center = worldTx.PointToWorld( lb.Center );
var halfExtents = lb.Size * 0.5f * scale;
return (center, halfExtents, rot, true);
}
}
// Fallback: world AABB from colliders, treated as an identity-rotation OBB.
var colliders = go.GetComponentsInChildren<Collider>( false, true ).ToArray();
if ( colliders.Length > 0 )
{
var box = colliders[0].GetWorldBounds();
for ( int i = 1; i < colliders.Length; i++ )
box = box.AddBBox( colliders[i].GetWorldBounds() );
if ( box.Size.Length > 0.1f )
return (box.Center, box.Size * 0.5f, Rotation.Identity, true);
}
return (Vector3.Zero, Vector3.Zero, Rotation.Identity, false);
}
/// <summary>
/// Projects <paramref name="point"/> onto the plane defined by <paramref name="planePoint"/> and <paramref name="normal"/>.
/// </summary>
private static Vector3 ProjectOntoPlane( Vector3 point, Vector3 planePoint, Vector3 normal )
{
return point - normal * Vector3.Dot( point - planePoint, normal );
}
/// <summary>
/// Returns the half-extents of an OBB projected along <paramref name="right"/> and <paramref name="up"/>.
/// Uses the OBB support function, which correctly handles rotated objects.
/// </summary>
private static Vector2 ProjectedHalfExtents( Vector3 obbHalfExtents, Rotation obbRotation, Vector3 right, Vector3 up )
{
// Explicitly rotate the three local unit axes into world space to avoid
// ambiguity with named helpers (Right/Up/Forward vary by coordinate convention).
var ax = obbRotation * new Vector3( 1, 0, 0 );
var ay = obbRotation * new Vector3( 0, 1, 0 );
var az = obbRotation * new Vector3( 0, 0, 1 );
var e = obbHalfExtents;
var halfRight = MathF.Abs( e.x * Vector3.Dot( ax, right ) )
+ MathF.Abs( e.y * Vector3.Dot( ay, right ) )
+ MathF.Abs( e.z * Vector3.Dot( az, right ) );
var halfUp = MathF.Abs( e.x * Vector3.Dot( ax, up ) )
+ MathF.Abs( e.y * Vector3.Dot( ay, up ) )
+ MathF.Abs( e.z * Vector3.Dot( az, up ) );
return new Vector2( halfRight, halfUp );
}
/// <summary>
/// Destroys the underlying scene object.
/// </summary>
public void Destroy()
{
_sceneObj?.Delete();
_sceneObj = null;
}
}