From 5e15af78794349a7cff07cfb4f116b2de0dfa3c5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 9 Jul 2026 18:41:12 +0900 Subject: [PATCH] =?UTF-8?q?raycast=E3=82=B0=E3=83=AA=E3=83=83=E3=83=89?= =?UTF-8?q?=E3=82=A2=E3=83=8E=E3=83=86=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E5=9F=BA=E7=9B=A4=EF=BC=88RaycastGridAnnotator=E7=AD=894?= =?UTF-8?q?=E3=82=AF=E3=83=A9=E3=82=B9=EF=BC=89=E3=82=92main=E3=81=8B?= =?UTF-8?q?=E3=82=89=E7=A7=BB=E6=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit origin/mainの最終形からRaycastGridAnnotator/RaycastHitClusterer/ RaycastLayerMaskResolver/RaycastSampleOutlineBuilderを移植した。 main実装をレビューしたFable5の指摘に基づき、以下をmainの構成から変更している: - 配置場所: main側はRaycastToolとScreenshotTool両方から使われる想定でUtils/直下に 置かれていたが、実際の利用元はScreenshotToolのみだったため、新規共有asmdefを作らず FirstPartyTools/Screenshot/RaycastAnnotation/サブフォルダに配置した(YAGNI)。 - UIElementInfo拡張: RaycastGridAnnotatorがLayer/Components/RaycastOutlineSegments フィールドに代入するため、UIElementInfo(ToolContracts)にこの3フィールドを先行追加した。 RaycastOutlineSegment型もCommonではなくToolContractsに新設(ToolContractsはCommonを 参照できない最下層のため)。 - UiRaycastHelperにRaycastContextを追加: グリッド全点(最大40x40=1600サンプル)の UI occlusion判定でCanvas全走査を毎回繰り返すとUnity Freeze Prevention上の懸念があるため、 main同様にCanvas走査結果をキャッシュするRaycastContextを追加した。ただしmain版を 逐語コピーすると、v3-beta独自に入っていた正しさ修正(子Canvas帰属チェック、 ignoreReversedGraphics対応)が失われ、スクリーンショットのocclusion判定と simulate-mouse-uiのクリック解決が食い違う不整合を生むため、既存のIsRaycastCandidateは 維持し、Canvas収集/判定ロジックのみをメソッド抽出してRaycastContextから再利用する形にした。 既存のRaycastUIもRaycastContext委譲にし、マージ規則の重複を除去した。 RaycastGridAnnotatorTests.cs(30テスト)を移植し、各テストにWHATコメントを付与。 uloop compileでエラー・warning 0件を確認、既存のUiRaycastHelper利用箇所 (MouseUiDragTargetResolverTests / MouseUiPointerTargetResolverTests / SimulateMouseUiTests / SimulateMouseUiInputSystemTests / InputReplayVerificationE2ETests)が全てgreenのままであることを 確認し、リファクタによる回帰がないことを検証した。 --- .../Tests/Editor/RaycastGridAnnotatorTests.cs | 736 ++++++++++++++++++ .../Editor/RaycastGridAnnotatorTests.cs.meta | 11 + .../Common/GameView/AssemblyInfo.cs | 1 + .../Common/MouseUi/UiRaycastHelper.cs | 114 ++- .../Screenshot/RaycastAnnotation.meta | 8 + .../RaycastAnnotation/RaycastGridAnnotator.cs | 581 ++++++++++++++ .../RaycastGridAnnotator.cs.meta | 11 + .../RaycastAnnotation/RaycastHitClusterer.cs | 222 ++++++ .../RaycastHitClusterer.cs.meta | 11 + .../RaycastLayerMaskResolver.cs | 161 ++++ .../RaycastLayerMaskResolver.cs.meta | 11 + .../RaycastSampleOutlineBuilder.cs | 266 +++++++ .../RaycastSampleOutlineBuilder.cs.meta | 11 + ...p.FirstPartyTools.Screenshot.Editor.asmdef | 3 +- .../ToolContracts/RaycastGridPointInfo.cs | 22 + .../RaycastGridPointInfo.cs.meta | 11 + .../ToolContracts/RaycastLayerSummaryInfo.cs | 13 + .../RaycastLayerSummaryInfo.cs.meta | 11 + .../ToolContracts/RaycastOutlineSegment.cs | 21 + .../RaycastOutlineSegment.cs.meta | 11 + .../src/Editor/ToolContracts/UIElementInfo.cs | 6 + 21 files changed, 2212 insertions(+), 30 deletions(-) create mode 100644 Assets/Tests/Editor/RaycastGridAnnotatorTests.cs create mode 100644 Assets/Tests/Editor/RaycastGridAnnotatorTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs.meta create mode 100644 Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs create mode 100644 Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs.meta create mode 100644 Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs create mode 100644 Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs.meta create mode 100644 Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs create mode 100644 Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs.meta 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(FindObjectsSortMode.None); - RaycastResult? bestHit = null; + List canvasRaycastSources = new List(); foreach (Canvas canvas in canvases) { @@ -65,9 +50,23 @@ internal static class UiRaycastHelper } Graphic[] graphics = canvas.GetComponentsInChildren(); - foreach (Graphic graphic in graphics) + canvasRaycastSources.Add(new CanvasRaycastSource(canvas, raycaster, graphics)); + } + + return canvasRaycastSources; + } + + private static RaycastResult? RaycastCanvasSpaceFromSources( + Vector2 canvasPosition, + List canvasRaycastSources) + { + RaycastResult? bestHit = null; + + foreach (CanvasRaycastSource source in canvasRaycastSources) + { + foreach (Graphic graphic in source.Graphics) { - if (!IsRaycastCandidate(graphic, canvas, raycaster, canvasPosition)) + if (!IsRaycastCandidate(graphic, source.Canvas, source.Raycaster, canvasPosition)) { continue; } @@ -75,10 +74,10 @@ internal static class UiRaycastHelper RaycastResult candidate = new RaycastResult { gameObject = graphic.gameObject, - module = raycaster, + module = source.Raycaster, screenPosition = canvasPosition, - sortingLayer = canvas.sortingLayerID, - sortingOrder = canvas.sortingOrder, + sortingLayer = source.Canvas.sortingLayerID, + sortingOrder = source.Canvas.sortingOrder, depth = graphic.depth }; @@ -92,6 +91,63 @@ internal static class UiRaycastHelper return bestHit; } + // Reuses EventSystem and Canvas-space raycast buffers for callers that test + // many positions in one frame, such as screenshot raycast-grid annotation collection. + // Keep this scoped to one frame because it snapshots Canvas and Graphic state. + internal sealed class RaycastContext + { + private readonly EventSystem _eventSystem; + private readonly PointerEventData _pointerData; + private readonly List _eventSystemResults = new(); + private readonly List _canvasRaycastSources; + + internal RaycastContext(EventSystem eventSystem) + { + Debug.Assert(eventSystem != null, "EventSystem is required for UI raycasting."); + _eventSystem = eventSystem!; + _pointerData = new PointerEventData(eventSystem!); + _canvasRaycastSources = CollectCanvasRaycastSources(); + } + + public RaycastResult? Raycast(Vector2 screenPosition) + { + _eventSystemResults.Clear(); + _pointerData.position = screenPosition; + _eventSystem.RaycastAll(_pointerData, _eventSystemResults); + RaycastResult? canvasSpaceHit = RaycastCanvasSpaceFromSources(screenPosition, _canvasRaycastSources); + + if (_eventSystemResults.Count > 0) + { + RaycastResult firstHit = _eventSystemResults[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; + } + } + + private readonly struct CanvasRaycastSource + { + public Canvas Canvas { get; } + public GraphicRaycaster Raycaster { get; } + public Graphic[] Graphics { get; } + + public CanvasRaycastSource(Canvas canvas, GraphicRaycaster raycaster, Graphic[] graphics) + { + Canvas = canvas; + Raycaster = raycaster; + Graphics = graphics; + } + } + // Respects CanvasGroup.blocksRaycasts, Mask, RectMask2D, and custom ICanvasRaycastFilter private static bool IsRaycastCandidate(Graphic graphic, Canvas canvas, GraphicRaycaster raycaster, Vector2 canvasPosition) { diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation.meta new file mode 100644 index 0000000000..7027e7988b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d6ccf75a95e6b4a03ab8aa8962c2b7e9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs new file mode 100644 index 0000000000..9c17da8025 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs @@ -0,0 +1,581 @@ +#nullable enable +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Creates 3D physics click candidates for rendering screenshots. + /// + internal static class RaycastGridAnnotator + { + private const int GRID_COLUMNS = 5; + private const int GRID_ROWS = 5; + private const int CLUSTERED_GRID_COLUMNS = 40; + private const int CLUSTERED_GRID_ROWS = 40; + private const float MARKER_SIZE = 18f; + + internal static List CollectRaycastGridPoints( + Vector2 renderingImageSize, + int imageToInputOffsetY) + { + return CollectRaycastGridPointsForGrid( + renderingImageSize, + imageToInputOffsetY, + GRID_ROWS, + GRID_COLUMNS); + } + + internal static List CollectRaycastLayerSummaries( + Vector2 renderingImageSize, + int imageToInputOffsetY) + { + List points = CollectRaycastGridPointsForGrid( + renderingImageSize, + imageToInputOffsetY, + CLUSTERED_GRID_ROWS, + CLUSTERED_GRID_COLUMNS); + return CreateLayerSummaries(points); + } + + private static List CollectRaycastGridPointsForGrid( + Vector2 renderingImageSize, + int imageToInputOffsetY, + int rowCount, + int columnCount) + { + List points = new List(); + int labelIndex = 1; + // Sync once for the whole grid; each candidate raycast then reads the same current physics state. + Physics.SyncTransforms(); + + for (int row = 1; row <= rowCount; row++) + { + for (int column = 1; column <= columnCount; column++) + { + Vector2 inputPosition = CalculateGridInputPositionForGrid( + renderingImageSize, + imageToInputOffsetY, + rowCount, + columnCount, + row, + column); + GameViewRaycastResult raycastResult = GameViewRaycastUtility.RaycastFromInputPosition( + inputPosition, + UnityCliLoopConstants.RAYCAST_DEFAULT_MAX_DISTANCE, + Physics.DefaultRaycastLayers, + false); + + points.Add(CreatePointInfo($"R{labelIndex}", inputPosition, raycastResult)); + labelIndex++; + } + } + + return points; + } + + internal static Vector2 CalculateGridInputPosition( + Vector2 renderingImageSize, + int imageToInputOffsetY, + int row, + int column) + { + return CalculateGridInputPositionForGrid( + renderingImageSize, + imageToInputOffsetY, + GRID_ROWS, + GRID_COLUMNS, + row, + column); + } + + internal static Vector2 CalculateGridInputPositionForGrid( + Vector2 renderingImageSize, + int imageToInputOffsetY, + int rowCount, + int columnCount, + int row, + int column) + { + Debug.Assert(renderingImageSize.x >= 0f, "Rendering image width must not be negative."); + Debug.Assert(renderingImageSize.y >= 0f, "Rendering image height must not be negative."); + Debug.Assert(rowCount > 0, "Grid row count must be positive."); + Debug.Assert(columnCount > 0, "Grid column count must be positive."); + Debug.Assert(row >= 1 && row <= rowCount, "Grid row must be within the configured grid."); + Debug.Assert(column >= 1 && column <= columnCount, "Grid column must be within the configured grid."); + + // Grid points must be visible in the captured PNG, so sample image space before adding the input Y offset. + return new Vector2( + renderingImageSize.x * column / (columnCount + 1f), + imageToInputOffsetY + renderingImageSize.y * row / (rowCount + 1f)); + } + + internal static List CollectPhysicsColliderElements( + Vector2 renderingImageSize, + int imageToInputOffsetY, + int layerMask) + { + RaycastClusterCollection clusterCollection = CollectClusterSamples( + renderingImageSize, + imageToInputOffsetY, + layerMask); + List clusters = RaycastHitClusterer.CreateClusters(clusterCollection.Samples); + List elements = new List(); + UiRaycastHelper.RaycastContext? uiRaycastContext = CreateUiRaycastContext(); + Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); + RaycastSampleCoverage sampleCoverage = + CreateClusterSampleCoverage(renderingImageSize, imageToInputOffsetY); + + for (int i = 0; i < clusters.Count; i++) + { + RaycastClusterInfo? reachableCluster = CreateReachableClusterForUiContext( + clusters[i], + uiRaycastContext, + gameViewSize); + if (reachableCluster == null) + { + continue; + } + + RaycastColliderMetadata metadata = + clusterCollection.MetadataByClusterKey[reachableCluster.Representative.ClusterKey]; + elements.Add(CreatePhysicsColliderElement( + $"R{elements.Count + 1}", + reachableCluster, + metadata, + sampleCoverage)); + } + + return elements; + } + + internal static List CreateOverlayElements(List points) + { + List overlayElements = new List(); + + foreach (RaycastGridPointInfo point in points) + { + if (!point.Hit) + { + continue; + } + + float halfSize = MARKER_SIZE / 2f; + overlayElements.Add(new UIElementInfo + { + Label = point.Label, + Name = point.HitGameObjectName ?? "", + Path = point.HitGameObjectPath ?? "", + Type = "RaycastHit", + Interaction = "Raycast", + SimX = point.InputX, + SimY = point.InputY, + BoundsMinX = point.InjectedUnityPositionX - halfSize, + BoundsMinY = point.InjectedUnityPositionY - halfSize, + BoundsMaxX = point.InjectedUnityPositionX + halfSize, + BoundsMaxY = point.InjectedUnityPositionY + halfSize, + SortingOrder = 0, + SiblingIndex = 0 + }); + } + + return overlayElements; + } + + private static RaycastGridPointInfo CreatePointInfo( + string label, + Vector2 inputPosition, + GameViewRaycastResult raycastResult) + { + RaycastGridPointInfo pointInfo = new RaycastGridPointInfo + { + Label = label, + Hit = raycastResult.Hits.Length > 0, + InputX = inputPosition.x, + InputY = inputPosition.y, + InjectedUnityPositionX = raycastResult.Conversion.InjectedUnityPosition.x, + InjectedUnityPositionY = raycastResult.Conversion.InjectedUnityPosition.y + }; + + if (!pointInfo.Hit) + { + return pointInfo; + } + + RaycastHit hit = raycastResult.Hits[0]; + pointInfo.HitGameObjectName = hit.collider.gameObject.name; + pointInfo.HitGameObjectPath = GameObjectPathUtility.GetFullPath(hit.collider.gameObject); + pointInfo.HitLayerIndex = hit.collider.gameObject.layer; + pointInfo.HitLayer = LayerMask.LayerToName(hit.collider.gameObject.layer); + pointInfo.Distance = hit.distance; + return pointInfo; + } + + internal static List CreateLayerSummaries(List points) + { + Dictionary accumulatorsByLayerIndex = + new Dictionary(); + + foreach (RaycastGridPointInfo point in points) + { + if (!point.Hit || point.HitLayerIndex == null) + { + continue; + } + + int layerIndex = point.HitLayerIndex.Value; + if (!accumulatorsByLayerIndex.ContainsKey(layerIndex)) + { + accumulatorsByLayerIndex.Add( + layerIndex, + new RaycastLayerSummaryAccumulator(point.HitLayer ?? "", layerIndex)); + } + + RaycastLayerSummaryAccumulator accumulator = accumulatorsByLayerIndex[layerIndex]; + accumulator.AddHit(point.HitGameObjectPath ?? ""); + } + + List summaries = new List(); + foreach (RaycastLayerSummaryAccumulator accumulator in accumulatorsByLayerIndex.Values) + { + summaries.Add(accumulator.CreateSummary()); + } + + summaries.Sort(CompareLayerSummaries); + return summaries; + } + + private static int CompareLayerSummaries( + RaycastLayerSummaryInfo left, + RaycastLayerSummaryInfo right) + { + int hitCountComparison = right.HitCount.CompareTo(left.HitCount); + if (hitCountComparison != 0) + { + return hitCountComparison; + } + + return left.LayerIndex.CompareTo(right.LayerIndex); + } + + private sealed class RaycastLayerSummaryAccumulator + { + private readonly Dictionary _objectHitCounts = new Dictionary(); + private string _representativeObjectPath = ""; + private int _representativeObjectHitCount; + + public string Layer { get; } + public int LayerIndex { get; } + public int HitCount { get; private set; } + + public RaycastLayerSummaryAccumulator(string layer, int layerIndex) + { + Layer = layer; + LayerIndex = layerIndex; + } + + public void AddHit(string objectPath) + { + HitCount++; + int objectHitCount = 1; + if (_objectHitCounts.ContainsKey(objectPath)) + { + objectHitCount = _objectHitCounts[objectPath] + 1; + } + + _objectHitCounts[objectPath] = objectHitCount; + if (ShouldUseAsRepresentative(objectPath, objectHitCount)) + { + _representativeObjectPath = objectPath; + _representativeObjectHitCount = objectHitCount; + } + } + + public RaycastLayerSummaryInfo CreateSummary() + { + return new RaycastLayerSummaryInfo + { + Layer = Layer, + LayerIndex = LayerIndex, + HitCount = HitCount, + RepresentativeObjectPath = _representativeObjectPath + }; + } + + private bool ShouldUseAsRepresentative(string objectPath, int objectHitCount) + { + if (objectHitCount > _representativeObjectHitCount) + { + return true; + } + + if (objectHitCount < _representativeObjectHitCount) + { + return false; + } + + return string.CompareOrdinal(objectPath, _representativeObjectPath) < 0; + } + } + + private static RaycastClusterCollection CollectClusterSamples( + Vector2 renderingImageSize, + int imageToInputOffsetY, + int layerMask) + { + RaycastClusterCollection clusterCollection = new RaycastClusterCollection(); + // Sync once before the dense pass so every sample reads the same current physics state. + Physics.SyncTransforms(); + + for (int row = 1; row <= CLUSTERED_GRID_ROWS; row++) + { + for (int column = 1; column <= CLUSTERED_GRID_COLUMNS; column++) + { + Vector2 inputPosition = CalculateGridInputPositionForGrid( + renderingImageSize, + imageToInputOffsetY, + CLUSTERED_GRID_ROWS, + CLUSTERED_GRID_COLUMNS, + row, + column); + GameViewRaycastResult raycastResult = GameViewRaycastUtility.RaycastFromInputPosition( + inputPosition, + UnityCliLoopConstants.RAYCAST_DEFAULT_MAX_DISTANCE, + layerMask, + false); + + if (raycastResult.Hits.Length == 0) + { + continue; + } + + RaycastHit hit = raycastResult.Hits[0]; + Collider collider = hit.collider; + int clusterKey = CreateClusterKey(collider); + if (!clusterCollection.MetadataByClusterKey.ContainsKey(clusterKey)) + { + clusterCollection.MetadataByClusterKey.Add( + clusterKey, + CreateColliderMetadata(collider)); + } + + clusterCollection.Samples.Add(CreateClusterSample(inputPosition, clusterKey, row, column)); + } + } + + return clusterCollection; + } + + internal static int CreateClusterKey(Collider collider) + { + Debug.Assert(collider != null, "Collider is required for raycast clustering."); + + // A placement area can use several colliders on one object; one annotation should describe the clickable object. + return collider!.gameObject.GetInstanceID(); + } + + private static RaycastClusterSample CreateClusterSample( + Vector2 inputPosition, + int clusterKey, + int row, + int column) + { + return new RaycastClusterSample + { + ClusterKey = clusterKey, + InputX = inputPosition.x, + InputY = inputPosition.y, + Row = row, + Column = column + }; + } + + private static RaycastColliderMetadata CreateColliderMetadata(Collider collider) + { + GameObject hitObject = collider.gameObject; + return new RaycastColliderMetadata + { + Name = hitObject.name, + Path = GameObjectPathUtility.GetFullPath(hitObject), + Layer = LayerMask.LayerToName(hitObject.layer), + Components = GetRelevantComponentTypeNames(hitObject) + }; + } + + internal static UIElementInfo CreatePhysicsColliderElement( + string label, + RaycastClusterInfo cluster, + RaycastColliderMetadata metadata, + RaycastSampleCoverage sampleCoverage) + { + Debug.Assert(cluster.Samples.Count > 0, "Physics collider cluster must contain sampled hits."); + RaycastClusterSample representative = cluster.Representative; + RaycastSampleBounds sampleBounds = CalculateSampleCellBounds(cluster.Samples, sampleCoverage); + List outlineSegments = + RaycastSampleOutlineBuilder.CreateOutlineSegments(cluster.Samples, sampleCoverage); + + UIElementInfo element = new UIElementInfo + { + Label = label, + Name = metadata.Name, + Path = metadata.Path, + Type = "PhysicsCollider", + Interaction = "Raycast", + SimX = representative.InputX, + SimY = representative.InputY, + BoundsMinX = sampleBounds.MinX, + BoundsMinY = sampleBounds.MinY, + BoundsMaxX = sampleBounds.MaxX, + BoundsMaxY = sampleBounds.MaxY, + SortingOrder = 0, + SiblingIndex = 0, + Layer = metadata.Layer, + Components = new List(metadata.Components), + RaycastOutlineSegments = outlineSegments + }; + + Debug.Assert( + element.SimX >= element.BoundsMinX && + element.SimX <= element.BoundsMaxX && + element.SimY >= element.BoundsMinY && + element.SimY <= element.BoundsMaxY, + "Physics collider bounds must use the same top-left input coordinate space as SimX/SimY."); + return element; + } + + private static RaycastSampleCoverage CreateClusterSampleCoverage( + Vector2 renderingImageSize, + int imageToInputOffsetY) + { + float stepX = renderingImageSize.x / (CLUSTERED_GRID_COLUMNS + 1f); + float stepY = renderingImageSize.y / (CLUSTERED_GRID_ROWS + 1f); + return new RaycastSampleCoverage( + stepX / 2f, + stepY / 2f, + 0f, + imageToInputOffsetY, + renderingImageSize.x, + imageToInputOffsetY + renderingImageSize.y); + } + + private static RaycastSampleBounds CalculateSampleCellBounds( + List samples, + RaycastSampleCoverage sampleCoverage) + { + Debug.Assert(samples.Count > 0, "At least one raycast sample is required."); + + float minX = samples[0].InputX - sampleCoverage.HalfStepX; + float minY = samples[0].InputY - sampleCoverage.HalfStepY; + float maxX = samples[0].InputX + sampleCoverage.HalfStepX; + float maxY = samples[0].InputY + sampleCoverage.HalfStepY; + + for (int i = 1; i < samples.Count; i++) + { + RaycastClusterSample sample = samples[i]; + minX = Mathf.Min(minX, sample.InputX - sampleCoverage.HalfStepX); + minY = Mathf.Min(minY, sample.InputY - sampleCoverage.HalfStepY); + maxX = Mathf.Max(maxX, sample.InputX + sampleCoverage.HalfStepX); + maxY = Mathf.Max(maxY, sample.InputY + sampleCoverage.HalfStepY); + } + + return new RaycastSampleBounds( + Mathf.Clamp(minX, sampleCoverage.MinX, sampleCoverage.MaxX), + Mathf.Clamp(minY, sampleCoverage.MinY, sampleCoverage.MaxY), + Mathf.Clamp(maxX, sampleCoverage.MinX, sampleCoverage.MaxX), + Mathf.Clamp(maxY, sampleCoverage.MinY, sampleCoverage.MaxY)); + } + + private static UiRaycastHelper.RaycastContext? CreateUiRaycastContext() + { + EventSystem currentEventSystem = EventSystem.current; + if (currentEventSystem == null || !currentEventSystem.isActiveAndEnabled) + { + return null; + } + + return new UiRaycastHelper.RaycastContext(currentEventSystem); + } + + private static RaycastClusterInfo? CreateReachableClusterForUiContext( + RaycastClusterInfo cluster, + UiRaycastHelper.RaycastContext? uiRaycastContext, + Vector2 gameViewSize) + { + if (uiRaycastContext == null) + { + return cluster; + } + + return RaycastHitClusterer.CreateReachableCluster( + cluster.Samples, + (RaycastClusterSample sample) => IsSampleOccludedByUi(sample, uiRaycastContext, gameViewSize)); + } + + private static bool IsSampleOccludedByUi( + RaycastClusterSample sample, + UiRaycastHelper.RaycastContext uiRaycastContext, + Vector2 gameViewSize) + { + Vector2 inputPosition = new Vector2(sample.InputX, sample.InputY); + GameViewCoordinateConversion conversion = + GameViewCoordinateUtility.ConvertInputToUnity(inputPosition, gameViewSize); + return IsUiOcclusionRaycastResult(uiRaycastContext.Raycast(conversion.InjectedUnityPosition)); + } + + internal static bool IsUiOcclusionRaycastResult(RaycastResult? raycastResult) + { + return raycastResult != null && raycastResult.Value.module is GraphicRaycaster; + } + + private static List GetRelevantComponentTypeNames(GameObject hitObject) + { + List componentTypeNames = new List(); + HashSet seenTypes = new HashSet(); + Component[] components = hitObject.GetComponents(); + + foreach (Component component in components) + { + if (component == null) + { + continue; + } + + if (!(component is Collider) && !(component is MonoBehaviour)) + { + continue; + } + + System.Type componentType = component.GetType(); + if (seenTypes.Contains(componentType)) + { + continue; + } + + seenTypes.Add(componentType); + componentTypeNames.Add(componentType.Name); + } + + return componentTypeNames; + } + + private readonly struct RaycastSampleBounds + { + public readonly float MinX; + public readonly float MinY; + public readonly float MaxX; + public readonly float MaxY; + + public RaycastSampleBounds(float minX, float minY, float maxX, float maxY) + { + MinX = minX; + MinY = minY; + MaxX = maxX; + MaxY = maxY; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs.meta new file mode 100644 index 0000000000..bd9e0858eb --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d769dba8bee114a328f70fb2c7153801 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs new file mode 100644 index 0000000000..e291731dfe --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs @@ -0,0 +1,222 @@ +#nullable enable +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Groups raycast samples by collider and chooses a real sampled hit as each representative. + /// + internal static class RaycastHitClusterer + { + internal static List CreateClusters(List samples) + { + System.Diagnostics.Debug.Assert(samples != null, "Raycast samples must not be null."); + List validSamples = samples!; + + Dictionary> samplesByClusterKey = + new Dictionary>(); + List clusterKeys = new List(); + + foreach (RaycastClusterSample sample in validSamples) + { + if (!samplesByClusterKey.ContainsKey(sample.ClusterKey)) + { + samplesByClusterKey.Add(sample.ClusterKey, new List()); + clusterKeys.Add(sample.ClusterKey); + } + + samplesByClusterKey[sample.ClusterKey].Add(sample); + } + + List clusters = new List(); + foreach (int clusterKey in clusterKeys) + { + List clusterSamples = samplesByClusterKey[clusterKey]; + clusters.Add(new RaycastClusterInfo + { + Representative = SelectRepresentativeSample(clusterSamples), + SampleCount = clusterSamples.Count, + Samples = new List(clusterSamples) + }); + } + + return clusters; + } + + internal static RaycastClusterSample SelectRepresentativeSample(List samples) + { + System.Diagnostics.Debug.Assert(samples != null, "Raycast samples must not be null."); + List validSamples = samples!; + System.Diagnostics.Debug.Assert(validSamples.Count > 0, "At least one raycast sample is required."); + + Vector2Like centroid = CalculateCentroid(validSamples); + RaycastClusterSample representative = validSamples[0]; + float nearestSquaredDistance = CalculateSquaredDistance(representative, centroid); + + for (int i = 1; i < validSamples.Count; i++) + { + RaycastClusterSample candidate = validSamples[i]; + float candidateSquaredDistance = CalculateSquaredDistance(candidate, centroid); + if (candidateSquaredDistance >= nearestSquaredDistance) + { + continue; + } + + representative = candidate; + nearestSquaredDistance = candidateSquaredDistance; + } + + return representative; + } + + internal static RaycastClusterSample? SelectReachableRepresentativeSample( + List samples, + RaycastClusterSampleOcclusionCheck isOccluded) + { + RaycastClusterInfo? reachableCluster = CreateReachableCluster(samples, isOccluded); + return reachableCluster?.Representative; + } + + internal static RaycastClusterInfo? CreateReachableCluster( + List samples, + RaycastClusterSampleOcclusionCheck isOccluded) + { + System.Diagnostics.Debug.Assert(samples != null, "Raycast samples must not be null."); + System.Diagnostics.Debug.Assert(isOccluded != null, "Raycast sample occlusion check must not be null."); + List validSamples = samples!; + RaycastClusterSampleOcclusionCheck validIsOccluded = isOccluded!; + System.Diagnostics.Debug.Assert(validSamples.Count > 0, "At least one raycast sample is required."); + + List reachableSamples = new List(); + foreach (RaycastClusterSample sample in validSamples) + { + if (validIsOccluded(sample)) + { + continue; + } + + reachableSamples.Add(sample); + } + + if (reachableSamples.Count == 0) + { + return null; + } + + return new RaycastClusterInfo + { + Representative = SelectRepresentativeSample(reachableSamples), + SampleCount = reachableSamples.Count, + Samples = reachableSamples + }; + } + + private static Vector2Like CalculateCentroid(List samples) + { + float sumX = 0f; + float sumY = 0f; + foreach (RaycastClusterSample sample in samples) + { + sumX += sample.InputX; + sumY += sample.InputY; + } + + return new Vector2Like + { + X = sumX / samples.Count, + Y = sumY / samples.Count + }; + } + + private static float CalculateSquaredDistance(RaycastClusterSample sample, Vector2Like point) + { + float deltaX = sample.InputX - point.X; + float deltaY = sample.InputY - point.Y; + return deltaX * deltaX + deltaY * deltaY; + } + + private struct Vector2Like + { + public float X { get; set; } + public float Y { get; set; } + } + } + + /// + /// Represents one screen-space raycast hit sample before collider clustering. + /// + internal sealed class RaycastClusterSample + { + public int ClusterKey { get; set; } + public float InputX { get; set; } + public float InputY { get; set; } + public int Row { get; set; } + public int Column { get; set; } + } + + /// + /// Contains one collider cluster and its selected representative hit sample. + /// + internal sealed class RaycastClusterInfo + { + public RaycastClusterSample Representative { get; set; } = new RaycastClusterSample(); + public int SampleCount { get; set; } + public List Samples { get; set; } = new List(); + } + + /// + /// Reports whether a raycast sample cannot be used as a click target. + /// + internal delegate bool RaycastClusterSampleOcclusionCheck(RaycastClusterSample sample); + + /// + /// Carries GameObject metadata for one clustered collider. + /// + internal sealed class RaycastColliderMetadata + { + public string Name { get; set; } = ""; + public string Path { get; set; } = ""; + public string Layer { get; set; } = ""; + public List Components { get; set; } = new List(); + } + + /// + /// Keeps pure raycast samples separate from per-collider metadata. + /// + internal sealed class RaycastClusterCollection + { + public List Samples { get; set; } = new List(); + + public Dictionary MetadataByClusterKey { get; set; } = + new Dictionary(); + } + + /// + /// Describes the screen-space cell area represented by each dense raycast sample. + /// + internal readonly struct RaycastSampleCoverage + { + public readonly float HalfStepX; + public readonly float HalfStepY; + public readonly float MinX; + public readonly float MinY; + public readonly float MaxX; + public readonly float MaxY; + + public RaycastSampleCoverage( + float halfStepX, + float halfStepY, + float minX, + float minY, + float maxX, + float maxY) + { + HalfStepX = halfStepX; + HalfStepY = halfStepY; + MinX = minX; + MinY = minY; + MaxX = maxX; + MaxY = maxY; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs.meta new file mode 100644 index 0000000000..dbbc9cf2d4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastHitClusterer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d537a30b5a0a8419e841d41b51431ff3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs new file mode 100644 index 0000000000..9664eef577 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs @@ -0,0 +1,161 @@ +#nullable enable +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves comma-separated physics layer names into a Unity layer mask. + /// + internal static class RaycastLayerMaskResolver + { + private const int MIN_LAYER_INDEX = 0; + private const int MAX_LAYER_INDEX = 31; + + internal static RaycastLayerMaskResolution Resolve( + string layerNamesText, + IReadOnlyList availableLayers) + { + System.Diagnostics.Debug.Assert(layerNamesText != null, "Layer mask text must not be null."); + System.Diagnostics.Debug.Assert(availableLayers != null, "Available layers must not be null."); + string validLayerNamesText = layerNamesText!; + IReadOnlyList validAvailableLayers = availableLayers!; + + List parsedLayerNames = ParseLayerNames(validLayerNamesText); + List validLayerNames = CreateValidLayerNames(validAvailableLayers); + if (parsedLayerNames.Count == 0) + { + return new RaycastLayerMaskResolution + { + IsValid = true, + HasLayerNames = false, + Mask = 0, + LayerNames = parsedLayerNames, + InvalidLayerNames = new List(), + ValidLayerNames = validLayerNames + }; + } + + Dictionary layerIndexByName = CreateLayerIndexByName(validAvailableLayers); + List resolvedLayerNames = new List(); + List invalidLayerNames = new List(); + int mask = 0; + + foreach (string layerName in parsedLayerNames) + { + if (!layerIndexByName.ContainsKey(layerName)) + { + invalidLayerNames.Add(layerName); + continue; + } + + int layerIndex = layerIndexByName[layerName]; + mask |= 1 << layerIndex; + resolvedLayerNames.Add(layerName); + } + + return new RaycastLayerMaskResolution + { + IsValid = invalidLayerNames.Count == 0, + HasLayerNames = true, + Mask = mask, + LayerNames = resolvedLayerNames, + InvalidLayerNames = invalidLayerNames, + ValidLayerNames = validLayerNames + }; + } + + private static List ParseLayerNames(string layerNamesText) + { + List layerNames = new List(); + HashSet seenLayerNames = new HashSet(); + string[] parts = layerNamesText.Split(','); + + foreach (string part in parts) + { + string layerName = part.Trim(); + if (layerName.Length == 0) + { + continue; + } + + if (seenLayerNames.Contains(layerName)) + { + continue; + } + + seenLayerNames.Add(layerName); + layerNames.Add(layerName); + } + + return layerNames; + } + + private static Dictionary CreateLayerIndexByName( + IReadOnlyList availableLayers) + { + Dictionary layerIndexByName = new Dictionary(); + foreach (RaycastLayerDefinition layer in availableLayers) + { + if (!IsValidLayer(layer)) + { + continue; + } + + if (layerIndexByName.ContainsKey(layer.Name)) + { + continue; + } + + layerIndexByName.Add(layer.Name, layer.Index); + } + + return layerIndexByName; + } + + private static List CreateValidLayerNames( + IReadOnlyList availableLayers) + { + List layerNames = new List(); + foreach (RaycastLayerDefinition layer in availableLayers) + { + if (!IsValidLayer(layer)) + { + continue; + } + + layerNames.Add(layer.Name); + } + + return layerNames; + } + + private static bool IsValidLayer(RaycastLayerDefinition layer) + { + return !string.IsNullOrEmpty(layer.Name) && + layer.Index >= MIN_LAYER_INDEX && + layer.Index <= MAX_LAYER_INDEX; + } + } + + /// + /// Describes one named Unity physics layer. + /// + internal sealed class RaycastLayerDefinition + { + public string Name { get; set; } = ""; + public int Index { get; set; } + } + + /// + /// Carries the result of layer-mask name resolution without throwing from pure logic. + /// + internal sealed class RaycastLayerMaskResolution + { + public bool IsValid { get; set; } + public bool HasLayerNames { get; set; } + public int Mask { get; set; } + public List LayerNames { get; set; } = new List(); + public List InvalidLayerNames { get; set; } = new List(); + public List ValidLayerNames { get; set; } = new List(); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs.meta new file mode 100644 index 0000000000..7e83880009 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerMaskResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fbb9de45ce61f454788761741ac9aa5f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs new file mode 100644 index 0000000000..ca99ed22c3 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs @@ -0,0 +1,266 @@ +#nullable enable +using System.Collections.Generic; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds screen-space outlines from sampled raycast hit cells. + /// + internal static class RaycastSampleOutlineBuilder + { + internal static List CreateOutlineSegments( + List samples, + RaycastSampleCoverage sampleCoverage) + { + Debug.Assert(samples != null, "Raycast samples must not be null."); + List validSamples = samples!; + HashSet occupiedCells = CreateOccupiedCells(validSamples); + List segments = new List(); + + foreach (RaycastClusterSample sample in validSamples) + { + if (!HasGridCell(sample)) + { + continue; + } + + RaycastSampleCellBounds cellBounds = CalculateCellBounds(sample, sampleCoverage); + AddBoundarySegments(sample, cellBounds, occupiedCells, segments); + } + + return MergeCollinearSegments(segments); + } + + private static HashSet CreateOccupiedCells(List samples) + { + HashSet occupiedCells = new HashSet(); + foreach (RaycastClusterSample sample in samples) + { + if (!HasGridCell(sample)) + { + continue; + } + + occupiedCells.Add(new RaycastSampleCellKey(sample.Row, sample.Column)); + } + + return occupiedCells; + } + + private static bool HasGridCell(RaycastClusterSample sample) + { + return sample.Row > 0 && sample.Column > 0; + } + + private static void AddBoundarySegments( + RaycastClusterSample sample, + RaycastSampleCellBounds cellBounds, + HashSet occupiedCells, + List segments) + { + if (!occupiedCells.Contains(new RaycastSampleCellKey(sample.Row - 1, sample.Column))) + { + segments.Add(new RaycastOutlineSegment( + cellBounds.MinX, + cellBounds.MinY, + cellBounds.MaxX, + cellBounds.MinY)); + } + + if (!occupiedCells.Contains(new RaycastSampleCellKey(sample.Row + 1, sample.Column))) + { + segments.Add(new RaycastOutlineSegment( + cellBounds.MinX, + cellBounds.MaxY, + cellBounds.MaxX, + cellBounds.MaxY)); + } + + if (!occupiedCells.Contains(new RaycastSampleCellKey(sample.Row, sample.Column - 1))) + { + segments.Add(new RaycastOutlineSegment( + cellBounds.MinX, + cellBounds.MinY, + cellBounds.MinX, + cellBounds.MaxY)); + } + + if (!occupiedCells.Contains(new RaycastSampleCellKey(sample.Row, sample.Column + 1))) + { + segments.Add(new RaycastOutlineSegment( + cellBounds.MaxX, + cellBounds.MinY, + cellBounds.MaxX, + cellBounds.MaxY)); + } + } + + private static RaycastSampleCellBounds CalculateCellBounds( + RaycastClusterSample sample, + RaycastSampleCoverage sampleCoverage) + { + return new RaycastSampleCellBounds( + Mathf.Clamp(sample.InputX - sampleCoverage.HalfStepX, sampleCoverage.MinX, sampleCoverage.MaxX), + Mathf.Clamp(sample.InputY - sampleCoverage.HalfStepY, sampleCoverage.MinY, sampleCoverage.MaxY), + Mathf.Clamp(sample.InputX + sampleCoverage.HalfStepX, sampleCoverage.MinX, sampleCoverage.MaxX), + Mathf.Clamp(sample.InputY + sampleCoverage.HalfStepY, sampleCoverage.MinY, sampleCoverage.MaxY)); + } + + private static List MergeCollinearSegments(List segments) + { + segments.Sort(CompareSegments); + List mergedSegments = new List(); + + foreach (RaycastOutlineSegment segment in segments) + { + if (mergedSegments.Count == 0) + { + mergedSegments.Add(segment); + continue; + } + + int lastIndex = mergedSegments.Count - 1; + RaycastOutlineSegment lastSegment = mergedSegments[lastIndex]; + if (!CanMerge(lastSegment, segment)) + { + mergedSegments.Add(segment); + continue; + } + + mergedSegments[lastIndex] = MergeSegments(lastSegment, segment); + } + + return mergedSegments; + } + + private static int CompareSegments(RaycastOutlineSegment left, RaycastOutlineSegment right) + { + int orientationComparison = GetOrientationIndex(left).CompareTo(GetOrientationIndex(right)); + if (orientationComparison != 0) + { + return orientationComparison; + } + + if (IsHorizontal(left)) + { + int yComparison = left.StartY.CompareTo(right.StartY); + if (yComparison != 0) + { + return yComparison; + } + + return left.StartX.CompareTo(right.StartX); + } + + int xComparison = left.StartX.CompareTo(right.StartX); + if (xComparison != 0) + { + return xComparison; + } + + return left.StartY.CompareTo(right.StartY); + } + + private static bool CanMerge(RaycastOutlineSegment left, RaycastOutlineSegment right) + { + if (IsHorizontal(left) && IsHorizontal(right)) + { + return Approximately(left.StartY, right.StartY) && + Approximately(left.EndY, right.EndY) && + Approximately(left.EndX, right.StartX); + } + + if (IsVertical(left) && IsVertical(right)) + { + return Approximately(left.StartX, right.StartX) && + Approximately(left.EndX, right.EndX) && + Approximately(left.EndY, right.StartY); + } + + return false; + } + + private static RaycastOutlineSegment MergeSegments( + RaycastOutlineSegment left, + RaycastOutlineSegment right) + { + if (IsHorizontal(left)) + { + return new RaycastOutlineSegment(left.StartX, left.StartY, right.EndX, right.EndY); + } + + return new RaycastOutlineSegment(left.StartX, left.StartY, right.EndX, right.EndY); + } + + private static int GetOrientationIndex(RaycastOutlineSegment segment) + { + if (IsHorizontal(segment)) + { + return 0; + } + + return 1; + } + + private static bool IsHorizontal(RaycastOutlineSegment segment) + { + return Approximately(segment.StartY, segment.EndY); + } + + private static bool IsVertical(RaycastOutlineSegment segment) + { + return Approximately(segment.StartX, segment.EndX); + } + + private static bool Approximately(float left, float right) + { + return Mathf.Abs(left - right) <= 0.001f; + } + + private readonly struct RaycastSampleCellKey + { + private readonly int _row; + private readonly int _column; + + public RaycastSampleCellKey(int row, int column) + { + _row = row; + _column = column; + } + + public override bool Equals(object obj) + { + if (!(obj is RaycastSampleCellKey other)) + { + return false; + } + + return _row == other._row && _column == other._column; + } + + public override int GetHashCode() + { + return (_row * 397) ^ _column; + } + } + + private readonly struct RaycastSampleCellBounds + { + public readonly float MinX; + public readonly float MinY; + public readonly float MaxX; + public readonly float MaxY; + + public RaycastSampleCellBounds(float minX, float minY, float maxX, float maxY) + { + MinX = minX; + MinY = minY; + MaxX = maxX; + MaxY = maxY; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs.meta new file mode 100644 index 0000000000..db93b26f2c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastSampleOutlineBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ef3ffdb92ead046b0ac197554a47256d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef index 55c4576d25..5fa51f9de4 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UnityCLILoop.FirstPartyTools.Screenshot.Editor.asmdef @@ -5,7 +5,8 @@ "GUID:214998e563c124e8a88199b2dd1f522d", "GUID:5079a8d3a72924a81aa1cbc25f65ed1b", "GUID:a2d87883023de4cf59b7d1962d5dd8aa", - "GUID:fc3fd32eddbee40e39c2d76dc184957b" + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:96b8d4624fb74ea2849fed17e7ad69d3" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs b/Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs new file mode 100644 index 0000000000..3fef9ff4a1 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs @@ -0,0 +1,22 @@ +#nullable enable + +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Describes one coarse raycast grid sample point used to preview 3D physics hits on a screenshot. + /// + public class RaycastGridPointInfo + { + public string Label { get; set; } = ""; + public bool Hit { get; set; } + public float InputX { get; set; } + public float InputY { get; set; } + public float InjectedUnityPositionX { get; set; } + public float InjectedUnityPositionY { get; set; } + public string? HitGameObjectName { get; set; } + public string? HitGameObjectPath { get; set; } + public string? HitLayer { get; set; } + public int? HitLayerIndex { get; set; } + public float? Distance { get; set; } + } +} diff --git a/Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs.meta b/Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs.meta new file mode 100644 index 0000000000..cc11daf730 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/RaycastGridPointInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21d97469b484c437aa867ba4141d02f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs b/Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs new file mode 100644 index 0000000000..853e028bde --- /dev/null +++ b/Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs @@ -0,0 +1,13 @@ +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Summarizes raycast grid hits for one physics layer. + /// + public class RaycastLayerSummaryInfo + { + public string Layer { get; set; } = ""; + public int LayerIndex { get; set; } + public int HitCount { get; set; } + public string RepresentativeObjectPath { get; set; } = ""; + } +} diff --git a/Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs.meta b/Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs.meta new file mode 100644 index 0000000000..7f428c20d6 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/RaycastLayerSummaryInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 803ceb1dce11c4d9789e9177a96ddcca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs b/Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs new file mode 100644 index 0000000000..d259b36ac5 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs @@ -0,0 +1,21 @@ +namespace io.github.hatayama.UnityCliLoop.ToolContracts +{ + /// + /// Represents one axis-aligned outline segment in top-left Game View input coordinates. + /// + internal readonly struct RaycastOutlineSegment + { + public readonly float StartX; + public readonly float StartY; + public readonly float EndX; + public readonly float EndY; + + public RaycastOutlineSegment(float startX, float startY, float endX, float endY) + { + StartX = startX; + StartY = startY; + EndX = endX; + EndY = endY; + } + } +} diff --git a/Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs.meta b/Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs.meta new file mode 100644 index 0000000000..abe2454462 --- /dev/null +++ b/Packages/src/Editor/ToolContracts/RaycastOutlineSegment.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6b76ae83ca9342a19e4239a34fe640c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/UIElementInfo.cs b/Packages/src/Editor/ToolContracts/UIElementInfo.cs index 8f04573979..1516b528eb 100644 --- a/Packages/src/Editor/ToolContracts/UIElementInfo.cs +++ b/Packages/src/Editor/ToolContracts/UIElementInfo.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; namespace io.github.hatayama.UnityCliLoop.ToolContracts { @@ -10,6 +11,8 @@ public class UIElementInfo public string Path { get; set; } = ""; public string Type { get; set; } = ""; public string Interaction { get; set; } = ""; + public string Layer { get; set; } = ""; + public List Components { get; set; } = new List(); public float SimX { get; set; } public float SimY { get; set; } public float BoundsMinX { get; set; } @@ -19,5 +22,8 @@ public class UIElementInfo public string Label { get; set; } = ""; public int SortingOrder { get; set; } public int SiblingIndex { get; set; } + + internal List RaycastOutlineSegments { get; set; } = + new List(); } }