diff --git a/Assets/Tests/Editor/RaycastGridAnnotatorTests.cs b/Assets/Tests/Editor/RaycastGridAnnotatorTests.cs
new file mode 100644
index 0000000000..b79876d212
--- /dev/null
+++ b/Assets/Tests/Editor/RaycastGridAnnotatorTests.cs
@@ -0,0 +1,736 @@
+#nullable enable
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.EventSystems;
+using UnityEngine.UI;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Test fixture that verifies Raycast Grid Annotator behavior.
+ ///
+ public class RaycastGridAnnotatorTests
+ {
+ [Test]
+ public void CalculateGridInputPosition_WhenRenderingHasTopOffset_ShouldSampleInsideCapturedImage()
+ {
+ // Tests that a grid input position samples inside the captured image when rendering has a top offset.
+ Vector2 renderingImageSize = new Vector2(1200f, 1080f);
+
+ Vector2 inputPosition =
+ RaycastGridAnnotator.CalculateGridInputPosition(renderingImageSize, 303, 1, 3);
+
+ Assert.That(inputPosition.x, Is.EqualTo(600f));
+ Assert.That(inputPosition.y, Is.EqualTo(483f));
+ }
+
+ [Test]
+ public void CalculateGridInputPosition_WhenRenderingHasTopOffset_ShouldKeepBottomRowVisible()
+ {
+ // Tests that the bottom grid row stays visible within the rendering image when a top offset is applied.
+ Vector2 renderingImageSize = new Vector2(1200f, 1080f);
+
+ Vector2 inputPosition =
+ RaycastGridAnnotator.CalculateGridInputPosition(renderingImageSize, 303, 5, 3);
+
+ Assert.That(inputPosition.x, Is.EqualTo(600f));
+ Assert.That(inputPosition.y, Is.EqualTo(1203f));
+ }
+
+ [Test]
+ public void CreateOverlayElements_WhenPointsIncludeMisses_ShouldAnnotateOnlyHits()
+ {
+ // Tests that overlay elements are created only for grid points that registered a physics hit.
+ List points = new List
+ {
+ new RaycastGridPointInfo
+ {
+ Label = "R1",
+ Hit = true,
+ InputX = 100f,
+ InputY = 200f,
+ InjectedUnityPositionX = 100f,
+ InjectedUnityPositionY = 880f,
+ HitGameObjectName = "Cube",
+ HitGameObjectPath = "Cube"
+ },
+ new RaycastGridPointInfo
+ {
+ Label = "R2",
+ Hit = false,
+ InputX = 200f,
+ InputY = 300f,
+ InjectedUnityPositionX = 200f,
+ InjectedUnityPositionY = 780f
+ }
+ };
+
+ List overlayElements = RaycastGridAnnotator.CreateOverlayElements(points);
+
+ Assert.That(overlayElements.Count, Is.EqualTo(1));
+ Assert.That(overlayElements[0].Label, Is.EqualTo("R1"));
+ Assert.That(overlayElements[0].Name, Is.EqualTo("Cube"));
+ Assert.That(overlayElements[0].Type, Is.EqualTo("RaycastHit"));
+ Assert.That(overlayElements[0].Interaction, Is.EqualTo("Raycast"));
+ }
+
+ [Test]
+ public void Resolve_WhenLayerNamesAreCommaSeparated_ShouldBuildMaskFromKnownLayers()
+ {
+ // Tests that comma-separated known layer names resolve into the combined layer mask.
+ List availableLayers = new List
+ {
+ new RaycastLayerDefinition { Name = "Default", Index = 0 },
+ new RaycastLayerDefinition { Name = "Ground", Index = 8 },
+ new RaycastLayerDefinition { Name = "Clickable", Index = 9 }
+ };
+
+ RaycastLayerMaskResolution resolution =
+ RaycastLayerMaskResolver.Resolve("Ground, Clickable", availableLayers);
+
+ Assert.That(resolution.IsValid, Is.True);
+ Assert.That(resolution.HasLayerNames, Is.True);
+ Assert.That(resolution.Mask, Is.EqualTo((1 << 8) | (1 << 9)));
+ Assert.That(resolution.LayerNames, Is.EqualTo(new List { "Ground", "Clickable" }));
+ }
+
+ [Test]
+ public void Resolve_WhenLayerNameDoesNotExist_ShouldReturnInvalidNamesAndValidNames()
+ {
+ // Tests that an unknown layer name is reported as invalid alongside the list of valid layer names.
+ List availableLayers = new List
+ {
+ new RaycastLayerDefinition { Name = "Default", Index = 0 },
+ new RaycastLayerDefinition { Name = "Ground", Index = 8 }
+ };
+
+ RaycastLayerMaskResolution resolution =
+ RaycastLayerMaskResolver.Resolve("Missing, Ground", availableLayers);
+
+ Assert.That(resolution.IsValid, Is.False);
+ Assert.That(resolution.HasLayerNames, Is.True);
+ Assert.That(resolution.InvalidLayerNames, Is.EqualTo(new List { "Missing" }));
+ Assert.That(resolution.ValidLayerNames, Is.EqualTo(new List { "Default", "Ground" }));
+ }
+
+ [Test]
+ public void CreateClusters_WhenSamplesHitSameCollider_ShouldReturnOneCluster()
+ {
+ // Tests that samples sharing the same cluster key are grouped into a single cluster.
+ List samples = new List
+ {
+ CreateSample(1, 100f, 100f),
+ CreateSample(1, 110f, 100f),
+ CreateSample(1, 100f, 110f)
+ };
+
+ List clusters = RaycastHitClusterer.CreateClusters(samples);
+
+ Assert.That(clusters.Count, Is.EqualTo(1));
+ Assert.That(clusters[0].SampleCount, Is.EqualTo(3));
+ }
+
+ [Test]
+ public void CreateClusters_WhenSamplesHitDifferentColliders_ShouldReturnClusterPerCollider()
+ {
+ // Tests that samples with distinct cluster keys produce one cluster per collider.
+ List samples = new List
+ {
+ CreateSample(1, 100f, 100f),
+ CreateSample(2, 300f, 300f)
+ };
+
+ List clusters = RaycastHitClusterer.CreateClusters(samples);
+
+ Assert.That(clusters.Count, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void CreateClusterKey_WhenGameObjectHasMultipleColliders_ShouldGroupByGameObject()
+ {
+ // Tests that the cluster key groups multiple colliders on the same GameObject together.
+ GameObject gameObject = new GameObject("ClusterKeyTest");
+ try
+ {
+ BoxCollider firstCollider = gameObject.AddComponent();
+ SphereCollider secondCollider = gameObject.AddComponent();
+
+ int firstKey = RaycastGridAnnotator.CreateClusterKey(firstCollider);
+ int secondKey = RaycastGridAnnotator.CreateClusterKey(secondCollider);
+
+ Assert.That(firstKey, Is.EqualTo(secondKey));
+ }
+ finally
+ {
+ Object.DestroyImmediate(gameObject);
+ }
+ }
+
+ [Test]
+ public void CreateClusters_WhenSamplesFormLShape_ShouldChooseActualHitClosestToCentroid()
+ {
+ // Tests that the cluster representative is an actual sampled hit closest to the centroid, not a synthesized point.
+ List samples = new List
+ {
+ CreateSample(1, 0f, 0f),
+ CreateSample(1, 100f, 0f),
+ CreateSample(1, 0f, 100f)
+ };
+
+ RaycastClusterSample representative = RaycastHitClusterer.SelectRepresentativeSample(samples);
+
+ Assert.That(samples, Has.Member(representative));
+ }
+
+ [Test]
+ public void CreateClusters_WhenSamplesFormDonut_ShouldNotSynthesizeCentroidPoint()
+ {
+ // Tests that the representative sample for a donut-shaped cluster is a real sample, not the empty centroid.
+ List samples = new List
+ {
+ CreateSample(1, 0f, 0f),
+ CreateSample(1, 100f, 0f),
+ CreateSample(1, 100f, 100f),
+ CreateSample(1, 0f, 100f)
+ };
+
+ RaycastClusterSample representative = RaycastHitClusterer.SelectRepresentativeSample(samples);
+
+ Assert.That(samples, Has.Member(representative));
+ }
+
+ [Test]
+ public void SelectReachableRepresentativeSample_WhenNearestSampleIsOccluded_ShouldPromoteNextNearestSample()
+ {
+ // Tests that occluded samples are skipped so the next nearest reachable sample becomes representative.
+ RaycastClusterSample occludedNearestSample = CreateSample(1, 50f, 50f);
+ RaycastClusterSample reachableSample = CreateSample(1, 40f, 40f);
+ List samples = new List
+ {
+ occludedNearestSample,
+ reachableSample
+ };
+ HashSet occludedSamples = new HashSet
+ {
+ occludedNearestSample
+ };
+
+ RaycastClusterSample? representative = RaycastHitClusterer.SelectReachableRepresentativeSample(
+ samples,
+ (RaycastClusterSample sample) => occludedSamples.Contains(sample));
+
+ Assert.That(representative, Is.EqualTo(reachableSample));
+ }
+
+ [Test]
+ public void SelectReachableRepresentativeSample_WhenAllSamplesAreOccluded_ShouldReturnNull()
+ {
+ // Tests that no representative is selected when every sample in the cluster is occluded.
+ List samples = new List
+ {
+ CreateSample(1, 50f, 50f),
+ CreateSample(1, 60f, 60f)
+ };
+
+ RaycastClusterSample? representative = RaycastHitClusterer.SelectReachableRepresentativeSample(
+ samples,
+ (RaycastClusterSample sample) => true);
+
+ Assert.That(representative, Is.Null);
+ }
+
+ [Test]
+ public void CreateReachableCluster_WhenSamplesAreOccluded_ShouldExcludeOccludedSamples()
+ {
+ // Tests that the reachable cluster only contains samples that were not reported as occluded.
+ RaycastClusterSample occludedSample = CreateSample(1, 50f, 50f);
+ RaycastClusterSample reachableLeftSample = CreateSample(1, 40f, 40f);
+ RaycastClusterSample reachableRightSample = CreateSample(1, 60f, 40f);
+ List samples = new List
+ {
+ occludedSample,
+ reachableLeftSample,
+ reachableRightSample
+ };
+ HashSet occludedSamples = new HashSet
+ {
+ occludedSample
+ };
+
+ RaycastClusterInfo? reachableCluster = RaycastHitClusterer.CreateReachableCluster(
+ samples,
+ (RaycastClusterSample sample) => occludedSamples.Contains(sample));
+
+ Assert.That(reachableCluster, Is.Not.Null);
+ Assert.That(reachableCluster!.SampleCount, Is.EqualTo(2));
+ Assert.That(reachableCluster.Samples, Is.EqualTo(new List
+ {
+ reachableLeftSample,
+ reachableRightSample
+ }));
+ }
+
+ [Test]
+ public void CreatePhysicsColliderElement_ShouldUseSampleCellBoundsInTopLeftInputSpace()
+ {
+ // Tests that a physics collider element uses the sampled cell bounds in top-left input space.
+ RaycastClusterInfo cluster = new RaycastClusterInfo
+ {
+ Representative = new RaycastClusterSample
+ {
+ InputX = 100f,
+ InputY = 200f
+ },
+ SampleCount = 3,
+ Samples = new List
+ {
+ CreateSample(1, 80f, 180f),
+ CreateSample(1, 100f, 200f),
+ CreateSample(1, 130f, 220f)
+ }
+ };
+ RaycastColliderMetadata metadata = CreateMetadata();
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 10f, 0f, 0f, 200f, 300f);
+
+ UIElementInfo element =
+ RaycastGridAnnotator.CreatePhysicsColliderElement("R1", cluster, metadata, coverage);
+
+ Assert.That(element.Type, Is.EqualTo("PhysicsCollider"));
+ Assert.That(element.Interaction, Is.EqualTo("Raycast"));
+ Assert.That(element.SimX, Is.EqualTo(100f));
+ Assert.That(element.SimY, Is.EqualTo(200f));
+ Assert.That(element.BoundsMinX, Is.EqualTo(75f));
+ Assert.That(element.BoundsMinY, Is.EqualTo(170f));
+ Assert.That(element.BoundsMaxX, Is.EqualTo(135f));
+ Assert.That(element.BoundsMaxY, Is.EqualTo(230f));
+ Assert.That(element.SimX, Is.InRange(element.BoundsMinX, element.BoundsMaxX));
+ Assert.That(element.SimY, Is.InRange(element.BoundsMinY, element.BoundsMaxY));
+ }
+
+ [Test]
+ public void CreatePhysicsColliderElement_WhenSamplesTouchViewportEdge_ShouldClampCellBounds()
+ {
+ // Tests that cell bounds touching the viewport edge are clamped to the sample coverage area.
+ RaycastClusterInfo cluster = new RaycastClusterInfo
+ {
+ Representative = CreateSample(1, 3f, 4f),
+ SampleCount = 1,
+ Samples = new List
+ {
+ CreateSample(1, 3f, 4f)
+ }
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 10f, 0f, 0f, 200f, 300f);
+
+ UIElementInfo element = RaycastGridAnnotator.CreatePhysicsColliderElement(
+ "R1",
+ cluster,
+ CreateMetadata(),
+ coverage);
+
+ Assert.That(element.BoundsMinX, Is.EqualTo(0f));
+ Assert.That(element.BoundsMinY, Is.EqualTo(0f));
+ Assert.That(element.BoundsMaxX, Is.EqualTo(8f));
+ Assert.That(element.BoundsMaxY, Is.EqualTo(14f));
+ }
+
+ [Test]
+ public void CreatePhysicsColliderElement_WhenSamplesFormLShape_ShouldUseAxisAlignedCellBoundingBox()
+ {
+ // Tests that an L-shaped sample cluster produces an axis-aligned bounding box covering all cells.
+ RaycastClusterInfo cluster = new RaycastClusterInfo
+ {
+ Representative = CreateSample(1, 0f, 0f),
+ SampleCount = 3,
+ Samples = new List
+ {
+ CreateSample(1, 0f, 0f),
+ CreateSample(1, 20f, 0f),
+ CreateSample(1, 0f, 20f)
+ }
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, -10f, -10f, 100f, 100f);
+
+ UIElementInfo element = RaycastGridAnnotator.CreatePhysicsColliderElement(
+ "R1",
+ cluster,
+ CreateMetadata(),
+ coverage);
+
+ Assert.That(element.BoundsMinX, Is.EqualTo(-5f));
+ Assert.That(element.BoundsMinY, Is.EqualTo(-5f));
+ Assert.That(element.BoundsMaxX, Is.EqualTo(25f));
+ Assert.That(element.BoundsMaxY, Is.EqualTo(25f));
+ }
+
+ [Test]
+ public void CreatePhysicsColliderElement_WhenClusterHasSingleSample_ShouldUseOneSampleCellBounds()
+ {
+ // Tests that a single-sample cluster produces bounds sized to exactly one sample cell.
+ RaycastClusterInfo cluster = new RaycastClusterInfo
+ {
+ Representative = CreateSample(1, 50f, 50f),
+ SampleCount = 1,
+ Samples = new List
+ {
+ CreateSample(1, 50f, 50f)
+ }
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ UIElementInfo element = RaycastGridAnnotator.CreatePhysicsColliderElement(
+ "R1",
+ cluster,
+ CreateMetadata(),
+ coverage);
+
+ Assert.That(element.BoundsMinX, Is.EqualTo(45f));
+ Assert.That(element.BoundsMinY, Is.EqualTo(45f));
+ Assert.That(element.BoundsMaxX, Is.EqualTo(55f));
+ Assert.That(element.BoundsMaxY, Is.EqualTo(55f));
+ }
+
+ [Test]
+ public void CreateOutlineSegments_WhenSingleCell_ShouldReturnFourEdges()
+ {
+ // Tests that a single occupied cell produces exactly four boundary edges.
+ List samples = new List
+ {
+ CreateSample(1, 10f, 10f, 1, 1)
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ List segments =
+ RaycastSampleOutlineBuilder.CreateOutlineSegments(samples, coverage);
+
+ Assert.That(segments.Count, Is.EqualTo(4));
+ }
+
+ [Test]
+ public void CreateOutlineSegments_WhenCellsAreAdjacent_ShouldMergeSharedEdges()
+ {
+ // Tests that adjacent occupied cells merge their shared internal edge into fewer outline segments.
+ List samples = new List
+ {
+ CreateSample(1, 10f, 10f, 1, 1),
+ CreateSample(1, 20f, 10f, 1, 2)
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ List segments =
+ RaycastSampleOutlineBuilder.CreateOutlineSegments(samples, coverage);
+
+ Assert.That(segments.Count, Is.EqualTo(4));
+ Assert.That(ContainsSegment(segments, 5f, 5f, 25f, 5f), Is.True);
+ Assert.That(ContainsSegment(segments, 5f, 15f, 25f, 15f), Is.True);
+ }
+
+ [Test]
+ public void CreateOutlineSegments_WhenCellsFormLShape_ShouldKeepConcaveOutline()
+ {
+ // Tests that an L-shaped set of cells keeps its concave outline instead of collapsing to a rectangle.
+ List samples = new List
+ {
+ CreateSample(1, 10f, 10f, 1, 1),
+ CreateSample(1, 20f, 10f, 1, 2),
+ CreateSample(1, 10f, 20f, 2, 1)
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ List segments =
+ RaycastSampleOutlineBuilder.CreateOutlineSegments(samples, coverage);
+
+ Assert.That(ContainsSegment(segments, 15f, 15f, 15f, 25f), Is.True);
+ }
+
+ [Test]
+ public void CreateOutlineSegments_WhenCellsHaveHole_ShouldReturnInnerAndOuterEdges()
+ {
+ // Tests that a donut-shaped set of cells with a missing center cell produces both inner and outer edges.
+ List samples = new List
+ {
+ CreateSample(1, 10f, 10f, 1, 1),
+ CreateSample(1, 20f, 10f, 1, 2),
+ CreateSample(1, 30f, 10f, 1, 3),
+ CreateSample(1, 10f, 20f, 2, 1),
+ CreateSample(1, 30f, 20f, 2, 3),
+ CreateSample(1, 10f, 30f, 3, 1),
+ CreateSample(1, 20f, 30f, 3, 2),
+ CreateSample(1, 30f, 30f, 3, 3)
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ List segments =
+ RaycastSampleOutlineBuilder.CreateOutlineSegments(samples, coverage);
+
+ Assert.That(ContainsSegment(segments, 15f, 15f, 25f, 15f), Is.True);
+ Assert.That(segments.Count, Is.GreaterThan(4));
+ }
+
+ [Test]
+ public void CreateOutlineSegments_WhenCellsAreDisconnected_ShouldKeepSeparateComponents()
+ {
+ // Tests that disconnected groups of cells produce separate outline components instead of merging.
+ List samples = new List
+ {
+ CreateSample(1, 10f, 10f, 1, 1),
+ CreateSample(1, 90f, 90f, 9, 9)
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ List segments =
+ RaycastSampleOutlineBuilder.CreateOutlineSegments(samples, coverage);
+
+ Assert.That(segments.Count, Is.EqualTo(8));
+ }
+
+ [Test]
+ public void CreatePhysicsColliderElement_WhenSamplesHaveGridCells_ShouldAttachOutlineSegments()
+ {
+ // Tests that a physics collider element created from grid-cell samples carries outline segments.
+ RaycastClusterInfo cluster = new RaycastClusterInfo
+ {
+ Representative = CreateSample(1, 10f, 10f, 1, 1),
+ SampleCount = 2,
+ Samples = new List
+ {
+ CreateSample(1, 10f, 10f, 1, 1),
+ CreateSample(1, 20f, 10f, 1, 2)
+ }
+ };
+ RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);
+
+ UIElementInfo element = RaycastGridAnnotator.CreatePhysicsColliderElement(
+ "R1",
+ cluster,
+ CreateMetadata(),
+ coverage);
+
+ Assert.That(element.RaycastOutlineSegments.Count, Is.EqualTo(4));
+ AssertSegment(element.RaycastOutlineSegments[0], 5f, 5f, 25f, 5f);
+ }
+
+ [Test]
+ public void IsUiOcclusionRaycastResult_WhenGraphicRaycasterHit_ShouldReturnTrue()
+ {
+ // Tests that a raycast result routed through a GraphicRaycaster is treated as a UI occlusion.
+ GameObject canvasObject = new GameObject("GraphicRaycasterOcclusionTest");
+ try
+ {
+ GraphicRaycaster graphicRaycaster = canvasObject.AddComponent();
+ RaycastResult raycastResult = new RaycastResult
+ {
+ module = graphicRaycaster
+ };
+
+ bool isOccluded = RaycastGridAnnotator.IsUiOcclusionRaycastResult(raycastResult);
+
+ Assert.That(isOccluded, Is.True);
+ }
+ finally
+ {
+ Object.DestroyImmediate(canvasObject);
+ }
+ }
+
+ [Test]
+ public void IsUiOcclusionRaycastResult_WhenPhysicsRaycasterHit_ShouldReturnFalse()
+ {
+ // Tests that a raycast result routed through a PhysicsRaycaster is not treated as a UI occlusion.
+ GameObject cameraObject = new GameObject("PhysicsRaycasterOcclusionTest");
+ try
+ {
+ Camera camera = cameraObject.AddComponent();
+ PhysicsRaycaster physicsRaycaster = cameraObject.AddComponent();
+ RaycastResult raycastResult = new RaycastResult
+ {
+ module = physicsRaycaster
+ };
+
+ bool isOccluded = RaycastGridAnnotator.IsUiOcclusionRaycastResult(raycastResult);
+
+ Assert.That(isOccluded, Is.False);
+ Assert.That(camera, Is.Not.Null);
+ }
+ finally
+ {
+ Object.DestroyImmediate(cameraObject);
+ }
+ }
+
+ [Test]
+ public void CreateLayerSummaries_ShouldCountHitsByLayerAndSortByHitCount()
+ {
+ // Tests that layer summaries count hits per layer and sort by descending hit count.
+ List points = new List
+ {
+ CreateLayerHitPoint("Ground", 8, "Ground/A"),
+ CreateLayerHitPoint("Ground", 8, "Ground/B"),
+ CreateLayerHitPoint("Clickable", 9, "Clickable/A")
+ };
+
+ List summaries = RaycastGridAnnotator.CreateLayerSummaries(points);
+
+ Assert.That(summaries.Count, Is.EqualTo(2));
+ Assert.That(summaries[0].Layer, Is.EqualTo("Ground"));
+ Assert.That(summaries[0].HitCount, Is.EqualTo(2));
+ Assert.That(summaries[1].Layer, Is.EqualTo("Clickable"));
+ Assert.That(summaries[1].HitCount, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void CreateLayerSummaries_WhenLayerCountsTie_ShouldSortByLayerIndex()
+ {
+ // Tests that tied hit counts fall back to sorting by ascending layer index.
+ List points = new List
+ {
+ CreateLayerHitPoint("Clickable", 9, "Clickable/A"),
+ CreateLayerHitPoint("Ground", 8, "Ground/A")
+ };
+
+ List summaries = RaycastGridAnnotator.CreateLayerSummaries(points);
+
+ Assert.That(summaries[0].Layer, Is.EqualTo("Ground"));
+ Assert.That(summaries[1].Layer, Is.EqualTo("Clickable"));
+ }
+
+ [Test]
+ public void CreateLayerSummaries_ShouldUseMostFrequentObjectPathAsRepresentative()
+ {
+ // Tests that the representative object path is the most frequently hit path within the layer.
+ List points = new List
+ {
+ CreateLayerHitPoint("Ground", 8, "Ground/A"),
+ CreateLayerHitPoint("Ground", 8, "Ground/A"),
+ CreateLayerHitPoint("Ground", 8, "Ground/B")
+ };
+
+ List summaries = RaycastGridAnnotator.CreateLayerSummaries(points);
+
+ Assert.That(summaries[0].RepresentativeObjectPath, Is.EqualTo("Ground/A"));
+ }
+
+ [Test]
+ public void CreateLayerSummaries_WhenObjectCountsTie_ShouldUseAlphabeticalPath()
+ {
+ // Tests that a tie in per-object hit counts is broken by picking the alphabetically first path.
+ List points = new List
+ {
+ CreateLayerHitPoint("Ground", 8, "Ground/B"),
+ CreateLayerHitPoint("Ground", 8, "Ground/A")
+ };
+
+ List summaries = RaycastGridAnnotator.CreateLayerSummaries(points);
+
+ Assert.That(summaries[0].RepresentativeObjectPath, Is.EqualTo("Ground/A"));
+ }
+
+ [Test]
+ public void CreateLayerSummaries_WhenNoHits_ShouldReturnEmptyList()
+ {
+ // Tests that no layer summaries are produced when none of the grid points registered a hit.
+ List points = new List
+ {
+ new RaycastGridPointInfo
+ {
+ Hit = false
+ }
+ };
+
+ List summaries = RaycastGridAnnotator.CreateLayerSummaries(points);
+
+ Assert.That(summaries, Is.Empty);
+ }
+
+ private static RaycastClusterSample CreateSample(
+ int clusterKey,
+ float inputX,
+ float inputY,
+ int row = 0,
+ int column = 0)
+ {
+ return new RaycastClusterSample
+ {
+ ClusterKey = clusterKey,
+ InputX = inputX,
+ InputY = inputY,
+ Row = row,
+ Column = column
+ };
+ }
+
+ private static bool ContainsSegment(
+ List segments,
+ float startX,
+ float startY,
+ float endX,
+ float endY)
+ {
+ foreach (RaycastOutlineSegment segment in segments)
+ {
+ if (segment.StartX == startX &&
+ segment.StartY == startY &&
+ segment.EndX == endX &&
+ segment.EndY == endY)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void AssertSegment(
+ RaycastOutlineSegment segment,
+ float startX,
+ float startY,
+ float endX,
+ float endY)
+ {
+ Assert.That(segment.StartX, Is.EqualTo(startX));
+ Assert.That(segment.StartY, Is.EqualTo(startY));
+ Assert.That(segment.EndX, Is.EqualTo(endX));
+ Assert.That(segment.EndY, Is.EqualTo(endY));
+ }
+
+ private static RaycastGridPointInfo CreateLayerHitPoint(
+ string layer,
+ int layerIndex,
+ string objectPath)
+ {
+ return new RaycastGridPointInfo
+ {
+ Hit = true,
+ HitLayer = layer,
+ HitLayerIndex = layerIndex,
+ HitGameObjectPath = objectPath
+ };
+ }
+
+ private static RaycastColliderMetadata CreateMetadata()
+ {
+ return new RaycastColliderMetadata
+ {
+ Name = "Cube",
+ Path = "Cube",
+ Layer = "Default",
+ Components = new List { "BoxCollider" }
+ };
+ }
+
+ private static RaycastSampleCoverage CreateCoverage(
+ float halfStepX,
+ float halfStepY,
+ float minX,
+ float minY,
+ float maxX,
+ float maxY)
+ {
+ return new RaycastSampleCoverage(halfStepX, halfStepY, minX, minY, maxX, maxY);
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/RaycastGridAnnotatorTests.cs.meta b/Assets/Tests/Editor/RaycastGridAnnotatorTests.cs.meta
new file mode 100644
index 0000000000..6daca69db0
--- /dev/null
+++ b/Assets/Tests/Editor/RaycastGridAnnotatorTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8c982948ab5e04bc7a3df8d31e6b8efc
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs
index 395e0c5bd5..f33d113bb1 100644
--- a/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs
+++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Raycast.Editor")]
+[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Screenshot.Editor")]
[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")]
[assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")]
[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Demo.Editor")]
diff --git a/Packages/src/Editor/FirstPartyTools/Common/MouseUi/UiRaycastHelper.cs b/Packages/src/Editor/FirstPartyTools/Common/MouseUi/UiRaycastHelper.cs
index 40f9521b8a..166447a8c8 100644
--- a/Packages/src/Editor/FirstPartyTools/Common/MouseUi/UiRaycastHelper.cs
+++ b/Packages/src/Editor/FirstPartyTools/Common/MouseUi/UiRaycastHelper.cs
@@ -14,37 +14,22 @@ internal static class UiRaycastHelper
{
public static RaycastResult? RaycastUI(Vector2 screenPosition, EventSystem eventSystem)
{
- PointerEventData pointerData = new(eventSystem)
- {
- position = screenPosition
- };
- List results = new();
- eventSystem.RaycastAll(pointerData, results);
- RaycastResult? canvasSpaceHit = RaycastCanvasSpace(screenPosition);
-
- if (results.Count > 0)
- {
- RaycastResult firstHit = results[0];
-
- if (canvasSpaceHit != null && ShouldPreferCanvasSpaceHit(canvasSpaceHit.Value, firstHit))
- {
- return canvasSpaceHit;
- }
-
- return firstHit;
- }
-
- // EventSystem clips at Screen.width/height, which can be smaller than the
- // Canvas layout space (Game view target resolution). Fall back to manual hit testing.
- return canvasSpaceHit;
+ RaycastContext context = new(eventSystem);
+ return context.Raycast(screenPosition);
}
// Bypass EventSystem's Screen-bounds clipping by directly testing Graphic rects in Canvas space.
// Only supports ScreenSpaceOverlay canvases where world positions equal Canvas-space positions.
public static RaycastResult? RaycastCanvasSpace(Vector2 canvasPosition)
+ {
+ List canvasRaycastSources = CollectCanvasRaycastSources();
+ return RaycastCanvasSpaceFromSources(canvasPosition, canvasRaycastSources);
+ }
+
+ private static List CollectCanvasRaycastSources()
{
Canvas[] canvases = Object.FindObjectsByType