Editor/VertexSnapService.cs

Editor utility for vertex snapping in the BlenderActions namespace. It captures world-space vertices from selected ModelRenderers, caches deduplicated model vertices, projects vertices into screen-space cells per renderer, and finds nearest source or target vertices for translate/rotate/scale modal transforms.

File Access
#nullable enable
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

namespace BlenderActions;

/// <summary>Represents the result of locating a target vertex.</summary>
public readonly record struct VertexSnapResult(bool Found, Vector3 Vertex);

/// <summary>Stores reusable world-space source vertices for vertex snapping.</summary>
public sealed class VertexSnapSource
{
    /// <summary>Handles new.</summary>
    internal List<Vector3> Vertices { get; } = new(4096);
    /// <summary>Handles new.</summary>
    internal HashSet<ModelRenderer> VisitedRenderers { get; } = new();

    /// <summary>Clears reusable source-vertex collections.</summary>
    internal void Clear()
    {
        Vertices.Clear();
        VisitedRenderers.Clear();
    }
}

/// <summary>Provides cached screen-space vertex snapping for modal transforms.</summary>
public static class VertexSnapService
{
    /// <summary>Defines the maximum world distance used to trace a target renderer.</summary>
    private const float TraceLength = 100000f;
    /// <summary>Defines the logical screen-space radius used to acquire target vertices.</summary>
    private const float SnapRadiusPixels = 16f;
    /// <summary>Defines the projected-vertex spatial hash cell size in pixels.</summary>
    private const float ProjectionCellSize = 32f;
    /// <summary>Defines local-vertex quantization used for model vertex deduplication.</summary>
    private const float Quantization = 10000f;

    /// <summary>Handles new.</summary>
    private static ConditionalWeakTable<Model, CachedVertices> _vertexCache = new();
    /// <summary>Handles new.</summary>
    private static ConditionalWeakTable<ModelRenderer, ProjectedVertexIndex> _targetIndices = new();

    /// <summary>Captures current world-space vertices from selected model renderers.</summary>
    public static void CaptureSourceSnapshot(
        VertexSnapSource destination,
        IReadOnlyCollection<GameObject> selectedObjects)
    {
        destination.Clear();

        foreach(var selectedObject in selectedObjects)
        {
            if(!selectedObject.IsValid())
                continue;

            foreach(var renderer in selectedObject.GetComponentsInChildren<ModelRenderer>(
                includeDisabled: true,
                includeSelf: true))
            {
                if(renderer == null ||
                    !destination.VisitedRenderers.Add(renderer) ||
                    renderer.Model == null ||
                    !renderer.Model.IsValid)
                {
                    continue;
                }

                AppendWorldVertices(renderer, destination.Vertices);
            }
        }
    }

    /// <summary>Finds the nearest target vertex under the pointer on the traced renderer.</summary>
    public static VertexSnapResult FindTargetVertex(
        Scene scene,
        CameraComponent camera,
        SceneViewportWidget viewport,
        IReadOnlyCollection<GameObject> ignoredObjects)
    {
        var mousePosition = SceneViewportWidget.MousePosition;
        var ray = camera.ScreenPixelToRay(mousePosition);
        var trace = scene.Trace
            .Ray(ray, TraceLength)
            .UseRenderMeshes(true, true)
            .UseHitPosition(true);

        foreach(var gameObject in ignoredObjects)
        {
            if(gameObject.IsValid())
                trace = trace.IgnoreGameObjectHierarchy(gameObject);
        }

        var hit = trace.Run();

        if(!hit.Hit || hit.GameObject == null)
            return default;

        var renderer = hit.Component as ModelRenderer ??
            hit.GameObject.GetComponent<ModelRenderer>(true);

        if(renderer == null || renderer.Model == null || !renderer.Model.IsValid)
            return default;

        var threshold = SnapRadiusPixels * MathF.Max(viewport.DpiScale, 1f);
        var index = _targetIndices.GetValue(renderer, _ => new ProjectedVertexIndex());
        index.Update(renderer, camera);

        return index.TryFindNearest(mousePosition, threshold, out var vertex)
            ? new VertexSnapResult(true, vertex)
            : default;
    }

    /// <summary>Finds the source vertex closest on screen after translation.</summary>
    public static bool TryFindClosestTranslatedSource(
        VertexSnapSource source,
        CameraComponent camera,
        Vector3 target,
        Vector3 translation,
        out Vector3 sourceVertex)
    {
        return TryFindClosestSource(
            source,
            camera,
            target,
            SourceTransform.Translate,
            translation,
            Vector3.Zero,
            Rotation.Identity,
            Vector3.One,
            out sourceVertex);
    }

    /// <summary>Finds the source vertex closest on screen after rotation.</summary>
    public static bool TryFindClosestRotatedSource(
        VertexSnapSource source,
        CameraComponent camera,
        Vector3 target,
        Vector3 pivot,
        Rotation rotation,
        out Vector3 sourceVertex)
    {
        return TryFindClosestSource(
            source,
            camera,
            target,
            SourceTransform.Rotate,
            Vector3.Zero,
            pivot,
            rotation,
            Vector3.One,
            out sourceVertex);
    }

    /// <summary>Finds the source vertex closest on screen after scaling.</summary>
    public static bool TryFindClosestScaledSource(
        VertexSnapSource source,
        CameraComponent camera,
        Vector3 target,
        Vector3 pivot,
        Vector3 multiplier,
        out Vector3 sourceVertex)
    {
        return TryFindClosestSource(
            source,
            camera,
            target,
            SourceTransform.Scale,
            Vector3.Zero,
            pivot,
            Rotation.Identity,
            multiplier,
            out sourceVertex);
    }

    /// <summary>Clears model and projected-vertex caches after hotload.</summary>
    [EditorEvent.Hotload]
    private static void ClearCaches()
    {
        _vertexCache = new ConditionalWeakTable<Model, CachedVertices>();
        _targetIndices = new ConditionalWeakTable<ModelRenderer, ProjectedVertexIndex>();
    }

    /// <summary>Finds the screen-space closest source vertex after a supplied transform.</summary>
    private static bool TryFindClosestSource(
        VertexSnapSource source,
        CameraComponent camera,
        Vector3 target,
        SourceTransform transform,
        Vector3 translation,
        Vector3 pivot,
        Rotation rotation,
        Vector3 multiplier,
        out Vector3 sourceVertex)
    {
        sourceVertex = Vector3.Zero;

        if(source.Vertices.Count == 0)
            return false;

        var targetScreen = camera.PointToScreenPixels(target, out var targetBehind);

        if(targetBehind)
            return false;

        var bestDistance = float.MaxValue;
        var found = false;

        for(var index = 0; index < source.Vertices.Count; index++)
        {
            var original = source.Vertices[index];
            var candidate = transform switch
            {
                SourceTransform.Translate => original + translation,
                SourceTransform.Rotate => pivot + rotation * (original - pivot),
                SourceTransform.Scale => pivot + (original - pivot).MultiplyComponents(multiplier),
                _ => original
            };

            var screen = camera.PointToScreenPixels(candidate, out var isBehind);

            if(isBehind)
                continue;

            var distance = (screen - targetScreen).Length;

            if(distance >= bestDistance)
                continue;

            bestDistance = distance;
            sourceVertex = candidate;
            found = true;
        }

        return found;
    }

    /// <summary>Appends one renderer's transformed model vertices to a reusable destination.</summary>
    private static void AppendWorldVertices(
        ModelRenderer renderer,
        List<Vector3> destination)
    {
        var vertices = GetVertices(renderer.Model);

        for(var index = 0; index < vertices.Length; index++)
            destination.Add(ToWorld(renderer.GameObject, vertices[index]));
    }

    /// <summary>Transforms a local model vertex into world space.</summary>
    private static Vector3 ToWorld(GameObject gameObject, Vector3 localVertex)
    {
        var scaled = localVertex.MultiplyComponents(gameObject.WorldScale);
        return gameObject.WorldPosition + gameObject.WorldRotation * scaled;
    }

    /// <summary>Returns cached deduplicated local-space vertices for a model.</summary>
    private static Vector3[] GetVertices(Model model)
    {
        return _vertexCache.GetValue(model, CreateCache).Vertices;
    }

    /// <summary>Creates a deduplicated local-space vertex cache for a model.</summary>
    private static CachedVertices CreateCache(Model model)
    {
        var unique = new Dictionary<QuantizedVertex, Vector3>();

        foreach(var vertex in model.GetVertices())
        {
            var position = vertex.Position;
            var key = new QuantizedVertex(
                (int)MathF.Round(position.x * Quantization),
                (int)MathF.Round(position.y * Quantization),
                (int)MathF.Round(position.z * Quantization));

            if(!unique.ContainsKey(key))
                unique.Add(key, position);
        }

        var vertices = new Vector3[unique.Count];
        unique.Values.CopyTo(vertices, 0);
        return new CachedVertices(vertices);
    }

    /// <summary>Combines two screen-space cell coordinates into one dictionary key.</summary>
    private static long CellKey(int x, int y)
    {
        return ((long)x << 32) ^ (uint)y;
    }

    /// <summary>Identifies the transform applied while evaluating source vertices.</summary>
    private enum SourceTransform
    {
        Translate,
        Rotate,
        Scale
    }

    /// <summary>Stores deduplicated local-space vertices for one model.</summary>
    private sealed class CachedVertices
    {
        /// <summary>Initializes a new cached vertices instance.</summary>
        public CachedVertices(Vector3[] vertices)
        {
            Vertices = vertices;
        }

        /// <summary>Gets the reusable captured world-space vertex list.</summary>
        public Vector3[] Vertices { get; }
    }

    /// <summary>Indexes one renderer's projected vertices in screen-space cells.</summary>
    private sealed class ProjectedVertexIndex
    {
        /// <summary>Handles new.</summary>
        private readonly Dictionary<long, List<ProjectedVertex>> _cells = new();
        /// <summary>Handles new.</summary>
        private readonly Stack<List<ProjectedVertex>> _bucketPool = new();

        /// <summary>Stores the model represented by the current projected index.</summary>
        private Model? _model;
        /// <summary>Stores the indexed renderer world position.</summary>
        private Vector3 _objectPosition;
        /// <summary>Stores the indexed renderer world rotation.</summary>
        private Rotation _objectRotation;
        /// <summary>Stores the indexed renderer world scale.</summary>
        private Vector3 _objectScale;
        /// <summary>Stores the camera position used to build the index.</summary>
        private Vector3 _cameraPosition;
        /// <summary>Stores the camera rotation used to build the index.</summary>
        private Rotation _cameraRotation;
        /// <summary>Stores the camera render size used to build the index.</summary>
        private Vector2? _cameraSize;
        /// <summary>Stores the perspective field of view used to build the index.</summary>
        private float _fieldOfView;
        /// <summary>Stores the orthographic height used to build the index.</summary>
        private float _orthographicHeight;
        /// <summary>Tracks whether the indexed camera uses orthographic projection.</summary>
        private bool _orthographic;

        /// <summary>Rebuilds the projected vertex index when renderer or camera state changes.</summary>
        public void Update(ModelRenderer renderer, CameraComponent camera)
        {
            var gameObject = renderer.GameObject;
            var cameraObject = camera.GameObject;

            if(ReferenceEquals(_model, renderer.Model) &&
                _objectPosition.Equals(gameObject.WorldPosition) &&
                _objectRotation.Equals(gameObject.WorldRotation) &&
                _objectScale.Equals(gameObject.WorldScale) &&
                _cameraPosition.Equals(cameraObject.WorldPosition) &&
                _cameraRotation.Equals(cameraObject.WorldRotation) &&
                _cameraSize.Equals(camera.CustomSize) &&
                _fieldOfView.Equals(camera.FieldOfView) &&
                _orthographicHeight.Equals(camera.OrthographicHeight) &&
                _orthographic == camera.Orthographic)
            {
                return;
            }

            RecycleCells();
            _model = renderer.Model;
            _objectPosition = gameObject.WorldPosition;
            _objectRotation = gameObject.WorldRotation;
            _objectScale = gameObject.WorldScale;
            _cameraPosition = cameraObject.WorldPosition;
            _cameraRotation = cameraObject.WorldRotation;
            _cameraSize = camera.CustomSize;
            _fieldOfView = camera.FieldOfView;
            _orthographicHeight = camera.OrthographicHeight;
            _orthographic = camera.Orthographic;

            var vertices = GetVertices(renderer.Model);

            for(var index = 0; index < vertices.Length; index++)
            {
                var world = ToWorld(gameObject, vertices[index]);
                var screen = camera.PointToScreenPixels(world, out var isBehind);

                if(isBehind)
                    continue;

                var cellX = (int)MathF.Floor(screen.x / ProjectionCellSize);
                var cellY = (int)MathF.Floor(screen.y / ProjectionCellSize);
                var key = CellKey(cellX, cellY);

                if(!_cells.TryGetValue(key, out var bucket))
                {
                    bucket = _bucketPool.Count > 0
                        ? _bucketPool.Pop()
                        : new List<ProjectedVertex>();
                    _cells.Add(key, bucket);
                }

                bucket.Add(new ProjectedVertex(screen, world));
            }
        }

        /// <summary>Finds the nearest indexed vertex within a screen-space radius.</summary>
        public bool TryFindNearest(
            Vector2 screenPosition,
            float radius,
            out Vector3 worldVertex)
        {
            worldVertex = Vector3.Zero;
            var centerX = (int)MathF.Floor(screenPosition.x / ProjectionCellSize);
            var centerY = (int)MathF.Floor(screenPosition.y / ProjectionCellSize);
            var cellRadius = Math.Max(1, (int)MathF.Ceiling(radius / ProjectionCellSize));
            var bestSquaredDistance = radius * radius;
            var found = false;

            for(var x = centerX - cellRadius; x <= centerX + cellRadius; x++)
            {
                for(var y = centerY - cellRadius; y <= centerY + cellRadius; y++)
                {
                    if(!_cells.TryGetValue(CellKey(x, y), out var bucket))
                        continue;

                    for(var index = 0; index < bucket.Count; index++)
                    {
                        var candidate = bucket[index];
                        var delta = candidate.Screen - screenPosition;
                        var squaredDistance = delta.x * delta.x + delta.y * delta.y;

                        if(squaredDistance > bestSquaredDistance)
                            continue;

                        bestSquaredDistance = squaredDistance;
                        worldVertex = candidate.World;
                        found = true;
                    }
                }
            }

            return found;
        }

        /// <summary>Clears projected cells and returns their lists to the bucket pool.</summary>
        private void RecycleCells()
        {
            foreach(var bucket in _cells.Values)
            {
                bucket.Clear();
                _bucketPool.Push(bucket);
            }

            _cells.Clear();
        }
    }

    /// <summary>Pairs a projected screen position with its world-space vertex.</summary>
    private readonly record struct ProjectedVertex(Vector2 Screen, Vector3 World);
    /// <summary>Provides a quantized key for deduplicating model vertices.</summary>
    private readonly record struct QuantizedVertex(int X, int Y, int Z);
}