diff --git a/Assets/Tests/Editor/ScreenshotUseCaseCharacterizationTests.cs b/Assets/Tests/Editor/ScreenshotUseCaseCharacterizationTests.cs new file mode 100644 index 0000000000..c196e5ccce --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotUseCaseCharacterizationTests.cs @@ -0,0 +1,152 @@ +#nullable enable +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Characterization tests that lock ScreenshotUseCase pure helpers before god-class splits. + /// + public class ScreenshotUseCaseCharacterizationTests + { + /// + /// Pins timeout response shaping including TimedOut flag and wait-name message text. + /// + [Test] + public void CreateTimedOutResult_WhenCalled_ShouldMarkTimedOutAndIncludeWaitName() + { + List screenshots = new() + { + new ScreenshotInfo { ImagePath = "a.png" } + }; + + ScreenshotResponse response = ScreenshotUseCase.CreateTimedOutResult( + "raycast grid rendering info capture", + "corr-1", + screenshots); + + Assert.That(response.TimedOut, Is.True); + Assert.That(response.Screenshots, Is.SameAs(screenshots)); + Assert.That( + response.Message, + Is.EqualTo( + $"Timed out after {UnityCliLoopConstants.EDITOR_FRAME_WAIT_TIMEOUT_MS}ms while waiting for raycast grid rendering info capture frames.")); + } + + /// + /// Pins rendering-mode coordinate metadata fields written onto ScreenshotInfo. + /// + [Test] + public void ApplyRenderingCoordinateMetadata_WhenCalled_ShouldSetGameViewFormulas() + { + ScreenshotInfo info = new(); + + ScreenshotUseCase.ApplyRenderingCoordinateMetadata(info, new Vector2(1920f, 1080f), 24); + + Assert.That(info.ImageCoordinateSystem, Is.EqualTo(UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_GAME_VIEW)); + Assert.That(info.GameViewWidth, Is.EqualTo(1920f)); + Assert.That(info.GameViewHeight, Is.EqualTo(1080f)); + Assert.That(info.ImageToInputOffsetY, Is.EqualTo(24)); + Assert.That(info.ScreenshotToInputFormula, Is.EqualTo(UnityCliLoopConstants.SCREENSHOT_RENDERING_TO_INPUT_FORMULA)); + Assert.That(info.UnityInputFormula, Is.EqualTo(UnityCliLoopConstants.COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY)); + } + + /// + /// Pins window-mode coordinate metadata fields written onto ScreenshotInfo. + /// + [Test] + public void ApplyWindowCoordinateMetadata_WhenCalled_ShouldSetWindowFormulas() + { + ScreenshotInfo info = new(); + + ScreenshotUseCase.ApplyWindowCoordinateMetadata(info); + + Assert.That(info.ImageCoordinateSystem, Is.EqualTo(UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_WINDOW)); + Assert.That(info.ScreenshotToInputFormula, Is.EqualTo(UnityCliLoopConstants.SCREENSHOT_WINDOW_TO_INPUT_FORMULA_UNAVAILABLE)); + Assert.That(info.UnityInputFormula, Is.EqualTo("")); + } + + /// + /// Pins response annotated-element concatenation order (UI first, then physics). + /// + [Test] + public void CreateResponseAnnotatedElements_WhenBothListsProvided_ShouldConcatenateUiThenPhysics() + { + List uiElements = new() + { + new UIElementInfo { Label = "A" } + }; + List physicsElements = new() + { + new UIElementInfo { Label = "P1" }, + new UIElementInfo { Label = "P2" } + }; + + List combined = ScreenshotUseCase.CreateResponseAnnotatedElements( + uiElements, + physicsElements); + + Assert.That(combined, Has.Count.EqualTo(3)); + Assert.That(combined[0].Label, Is.EqualTo("A")); + Assert.That(combined[1].Label, Is.EqualTo("P1")); + Assert.That(combined[2].Label, Is.EqualTo("P2")); + Assert.That(combined, Is.Not.SameAs(uiElements)); + } + + /// + /// Pins invalid RaycastLayerMask message text including invalid and valid layer names. + /// + [Test] + public void CreateInvalidRaycastLayerMaskMessage_WhenInvalidNamesExist_ShouldListInvalidAndValidLayers() + { + RaycastLayerMaskResolution resolution = new() + { + InvalidLayerNames = new List { "Missing", "AlsoMissing" }, + ValidLayerNames = new List { "Default", "UI" } + }; + + string message = ScreenshotUseCase.CreateInvalidRaycastLayerMaskMessage(resolution); + + Assert.That( + message, + Is.EqualTo( + "RaycastLayerMask contains unknown layer name(s): Missing, AlsoMissing. Valid layers: Default, UI")); + } + + /// + /// Pins empty valid-layer list rendering as (none) in the invalid-mask message. + /// + [Test] + public void CreateInvalidRaycastLayerMaskMessage_WhenNoValidLayers_ShouldRenderNonePlaceholder() + { + RaycastLayerMaskResolution resolution = new() + { + InvalidLayerNames = new List { "Missing" }, + ValidLayerNames = new List() + }; + + string message = ScreenshotUseCase.CreateInvalidRaycastLayerMaskMessage(resolution); + + Assert.That( + message, + Is.EqualTo( + "RaycastLayerMask contains unknown layer name(s): Missing. Valid layers: (none)")); + } + + /// + /// Pins file-name sanitization replacing platform-invalid path characters with underscores. + /// + [Test] + public void SanitizeFileName_WhenNameContainsInvalidChars_ShouldReplaceWithUnderscore() + { + // why: on this Unity/macOS runtime Path.GetInvalidFileNameChars is only NUL and '/', so ':' stays + string sanitized = ScreenshotUseCase.SanitizeFileName("Game/View:Main"); + + Assert.That(sanitized, Is.EqualTo("Game_View:Main")); + } + } +} diff --git a/Assets/Tests/Editor/ScreenshotUseCaseCharacterizationTests.cs.meta b/Assets/Tests/Editor/ScreenshotUseCaseCharacterizationTests.cs.meta new file mode 100644 index 0000000000..107407bec2 --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotUseCaseCharacterizationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c2435ac45cc418b97fc705b003b5419 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs index 878cdd67db..8c30b6aa06 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastGridAnnotator.cs @@ -13,9 +13,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// internal static class RaycastGridAnnotator { - private const int CLUSTERED_GRID_COLUMNS = 40; - private const int CLUSTERED_GRID_ROWS = 40; - internal static List CollectRaycastLayerSummaries( Vector2 renderingImageSize, int imageToInputOffsetY) @@ -23,8 +20,8 @@ internal static List CollectRaycastLayerSummaries( List samples = CollectLayerHitSamples( renderingImageSize, imageToInputOffsetY, - CLUSTERED_GRID_ROWS, - CLUSTERED_GRID_COLUMNS); + RaycastPhysicsColliderBuilder.CLUSTERED_GRID_ROWS, + RaycastPhysicsColliderBuilder.CLUSTERED_GRID_COLUMNS); return CreateLayerSummaries(samples); } @@ -55,7 +52,7 @@ private static List CollectLayerHitSamples( Physics.DefaultRaycastLayers, false); - samples.Add(CreateLayerHitSample(raycastResult)); + samples.Add(RaycastLayerSummaryBuilder.CreateLayerHitSample(raycastResult)); } } @@ -97,7 +94,7 @@ internal static List CollectPhysicsColliderElements( UiRaycastHelper.RaycastContext? uiRaycastContext = CreateUiRaycastContext(); Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); RaycastSampleCoverage sampleCoverage = - CreateClusterSampleCoverage(renderingImageSize, imageToInputOffsetY); + RaycastPhysicsColliderBuilder.CreateClusterSampleCoverage(renderingImageSize, imageToInputOffsetY); for (int i = 0; i < clusters.Count; i++) { @@ -123,237 +120,17 @@ internal static List CollectPhysicsColliderElements( return elements; } - // Split the reachable cluster into 4-connected regions and materialize one UIElementInfo per region. - // Why: a single GameObject can produce multiple visually closed outline regions when UI occlusion - // splits its reachable samples, and agents need one annotation per closed region so they can address - // each one with its own Label, Bounds, SimX/SimY, and RaycastOutlineSegments. internal static List CreateComponentElements( RaycastClusterInfo reachableCluster, RaycastColliderMetadata metadata, RaycastSampleCoverage sampleCoverage, int startLabelNumber) { - List> components = - RaycastHitClusterer.SplitIntoConnectedComponents(reachableCluster.Samples); - components.Sort(CompareComponentsByTopLeft); - - List componentElements = new List(); - for (int j = 0; j < components.Count; j++) - { - List componentSamples = components[j]; - RaycastClusterInfo componentCluster = new RaycastClusterInfo - { - Samples = componentSamples, - SampleCount = componentSamples.Count, - Representative = RaycastHitClusterer.SelectRepresentativeSample(componentSamples) - }; - componentElements.Add(CreatePhysicsColliderElement( - $"R{startLabelNumber + j}", - componentCluster, - metadata, - sampleCoverage)); - } - return componentElements; - } - - // Order components so labels are assigned in a fully deterministic top-left-first sequence. - // Why: List.Sort is not stable, so two components sharing the same min InputY and min InputX - // would otherwise flip order between runs. The min (Row, Column) tiebreaker pins the order. - private static int CompareComponentsByTopLeft( - List left, - List right) - { - float leftMinY = MinInputY(left); - float rightMinY = MinInputY(right); - int yComparison = leftMinY.CompareTo(rightMinY); - if (yComparison != 0) - { - return yComparison; - } - - float leftMinX = MinInputX(left); - float rightMinX = MinInputX(right); - int xComparison = leftMinX.CompareTo(rightMinX); - if (xComparison != 0) - { - return xComparison; - } - - (int, int) leftMinCell = MinRowColumn(left); - (int, int) rightMinCell = MinRowColumn(right); - int rowComparison = leftMinCell.Item1.CompareTo(rightMinCell.Item1); - if (rowComparison != 0) - { - return rowComparison; - } - return leftMinCell.Item2.CompareTo(rightMinCell.Item2); - } - - private static float MinInputX(List samples) - { - float minValue = samples[0].InputX; - for (int i = 1; i < samples.Count; i++) - { - if (samples[i].InputX < minValue) - { - minValue = samples[i].InputX; - } - } - return minValue; - } - - private static float MinInputY(List samples) - { - float minValue = samples[0].InputY; - for (int i = 1; i < samples.Count; i++) - { - if (samples[i].InputY < minValue) - { - minValue = samples[i].InputY; - } - } - return minValue; - } - - private static (int, int) MinRowColumn(List samples) - { - (int, int) minCell = (samples[0].Row, samples[0].Column); - for (int i = 1; i < samples.Count; i++) - { - (int, int) candidate = (samples[i].Row, samples[i].Column); - if (candidate.Item1 < minCell.Item1 || - (candidate.Item1 == minCell.Item1 && candidate.Item2 < minCell.Item2)) - { - minCell = candidate; - } - } - return minCell; - } - - private static RaycastLayerHitSample CreateLayerHitSample(GameViewRaycastResult raycastResult) - { - RaycastLayerHitSample sample = new RaycastLayerHitSample - { - Hit = raycastResult.Hits.Length > 0 - }; - - if (!sample.Hit) - { - return sample; - } - - RaycastHit hit = raycastResult.Hits[0]; - sample.HitGameObjectPath = GameObjectPathUtility.GetFullPath(hit.collider.gameObject); - sample.HitLayerIndex = hit.collider.gameObject.layer; - sample.HitLayer = LayerMask.LayerToName(hit.collider.gameObject.layer); - return sample; - } - - internal static List CreateLayerSummaries(List samples) - { - Dictionary accumulatorsByLayerIndex = - new Dictionary(); - - foreach (RaycastLayerHitSample sample in samples) - { - if (!sample.Hit || sample.HitLayerIndex == null) - { - continue; - } - - int layerIndex = sample.HitLayerIndex.Value; - if (!accumulatorsByLayerIndex.ContainsKey(layerIndex)) - { - accumulatorsByLayerIndex.Add( - layerIndex, - new RaycastLayerSummaryAccumulator(sample.HitLayer ?? "", layerIndex)); - } - - RaycastLayerSummaryAccumulator accumulator = accumulatorsByLayerIndex[layerIndex]; - accumulator.AddHit(sample.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; - } + return RaycastPhysicsColliderBuilder.CreateComponentElements( + reachableCluster, + metadata, + sampleCoverage, + startLabelNumber); } private static RaycastClusterCollection CollectClusterSamples( @@ -365,15 +142,15 @@ private static RaycastClusterCollection CollectClusterSamples( // 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 row = 1; row <= RaycastPhysicsColliderBuilder.CLUSTERED_GRID_ROWS; row++) { - for (int column = 1; column <= CLUSTERED_GRID_COLUMNS; column++) + for (int column = 1; column <= RaycastPhysicsColliderBuilder.CLUSTERED_GRID_COLUMNS; column++) { Vector2 inputPosition = CalculateGridInputPositionForGrid( renderingImageSize, imageToInputOffsetY, - CLUSTERED_GRID_ROWS, - CLUSTERED_GRID_COLUMNS, + RaycastPhysicsColliderBuilder.CLUSTERED_GRID_ROWS, + RaycastPhysicsColliderBuilder.CLUSTERED_GRID_COLUMNS, row, column); GameViewRaycastResult raycastResult = GameViewRaycastUtility.RaycastFromInputPosition( @@ -394,7 +171,7 @@ private static RaycastClusterCollection CollectClusterSamples( { clusterCollection.MetadataByClusterKey.Add( clusterKey, - CreateColliderMetadata(collider)); + RaycastPhysicsColliderBuilder.CreateColliderMetadata(collider)); } clusterCollection.Samples.Add(CreateClusterSample(inputPosition, clusterKey, row, column)); @@ -431,99 +208,22 @@ private static RaycastClusterSample CreateClusterSample( }; } - 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); + return RaycastPhysicsColliderBuilder.CreatePhysicsColliderElement( + label, + cluster, + metadata, + sampleCoverage); } - private static RaycastSampleBounds CalculateSampleCellBounds( - List samples, - RaycastSampleCoverage sampleCoverage) + internal static List CreateLayerSummaries(List samples) { - 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)); + return RaycastLayerSummaryBuilder.CreateLayerSummaries(samples); } private static UiRaycastHelper.RaycastContext? CreateUiRaycastContext() @@ -567,63 +267,5 @@ 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; - } - } - } - - /// - /// Carries the raycast hit layer/object of one dense grid sample, for internal layer-summary aggregation only. - /// - internal sealed class RaycastLayerHitSample - { - public bool Hit { get; set; } - public string? HitGameObjectPath { get; set; } - public string? HitLayer { get; set; } - public int? HitLayerIndex { get; set; } } } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerSummaryBuilder.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerSummaryBuilder.cs new file mode 100644 index 0000000000..027bc3c19a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerSummaryBuilder.cs @@ -0,0 +1,151 @@ +#nullable enable +using System.Collections.Generic; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Aggregates dense raycast grid samples into per-layer summary records. + /// + internal static class RaycastLayerSummaryBuilder + { + internal static RaycastLayerHitSample CreateLayerHitSample(GameViewRaycastResult raycastResult) + { + RaycastLayerHitSample sample = new RaycastLayerHitSample + { + Hit = raycastResult.Hits.Length > 0 + }; + + if (!sample.Hit) + { + return sample; + } + + RaycastHit hit = raycastResult.Hits[0]; + sample.HitGameObjectPath = GameObjectPathUtility.GetFullPath(hit.collider.gameObject); + sample.HitLayerIndex = hit.collider.gameObject.layer; + sample.HitLayer = LayerMask.LayerToName(hit.collider.gameObject.layer); + return sample; + } + + internal static List CreateLayerSummaries(List samples) + { + Dictionary accumulatorsByLayerIndex = + new Dictionary(); + + foreach (RaycastLayerHitSample sample in samples) + { + if (!sample.Hit || sample.HitLayerIndex == null) + { + continue; + } + + int layerIndex = sample.HitLayerIndex.Value; + if (!accumulatorsByLayerIndex.ContainsKey(layerIndex)) + { + accumulatorsByLayerIndex.Add( + layerIndex, + new RaycastLayerSummaryAccumulator(sample.HitLayer ?? "", layerIndex)); + } + + RaycastLayerSummaryAccumulator accumulator = accumulatorsByLayerIndex[layerIndex]; + accumulator.AddHit(sample.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; + } + } + } + + /// + /// Carries the raycast hit layer/object of one dense grid sample, for internal layer-summary aggregation only. + /// + internal sealed class RaycastLayerHitSample + { + public bool Hit { get; set; } + public string? HitGameObjectPath { get; set; } + public string? HitLayer { get; set; } + public int? HitLayerIndex { get; set; } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerSummaryBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerSummaryBuilder.cs.meta new file mode 100644 index 0000000000..6390d918fd --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastLayerSummaryBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df2f7aa425554f12ba159e5f7040d69d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastPhysicsColliderBuilder.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastPhysicsColliderBuilder.cs new file mode 100644 index 0000000000..a94c8a8b8e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastPhysicsColliderBuilder.cs @@ -0,0 +1,269 @@ +#nullable enable +using System.Collections.Generic; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds physics-collider UIElementInfo records from clustered raycast grid samples. + /// + internal static class RaycastPhysicsColliderBuilder + { + // why: shared with RaycastGridAnnotator so sample loop density and cell half-steps stay in lockstep + internal const int CLUSTERED_GRID_COLUMNS = 40; + internal const int CLUSTERED_GRID_ROWS = 40; + // why: shared with UIElementAnnotationRenderer so physics outline Y-flip keeps matching element Type + internal const string PhysicsColliderElementType = "PhysicsCollider"; + + // Split the reachable cluster into 4-connected regions and materialize one UIElementInfo per region. + // Why: a single GameObject can produce multiple visually closed outline regions when UI occlusion + // splits its reachable samples, and agents need one annotation per closed region so they can address + // each one with its own Label, Bounds, SimX/SimY, and RaycastOutlineSegments. + internal static List CreateComponentElements( + RaycastClusterInfo reachableCluster, + RaycastColliderMetadata metadata, + RaycastSampleCoverage sampleCoverage, + int startLabelNumber) + { + List> components = + RaycastHitClusterer.SplitIntoConnectedComponents(reachableCluster.Samples); + components.Sort(CompareComponentsByTopLeft); + + List componentElements = new List(); + for (int j = 0; j < components.Count; j++) + { + List componentSamples = components[j]; + RaycastClusterInfo componentCluster = new RaycastClusterInfo + { + Samples = componentSamples, + SampleCount = componentSamples.Count, + Representative = RaycastHitClusterer.SelectRepresentativeSample(componentSamples) + }; + componentElements.Add(CreatePhysicsColliderElement( + $"R{startLabelNumber + j}", + componentCluster, + metadata, + sampleCoverage)); + } + return componentElements; + } + + 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 = PhysicsColliderElementType, + 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; + } + + internal 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); + } + + internal 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 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; + } + + // Order components so labels are assigned in a fully deterministic top-left-first sequence. + // Why: List.Sort is not stable, so two components sharing the same min InputY and min InputX + // would otherwise flip order between runs. The min (Row, Column) tiebreaker pins the order. + private static int CompareComponentsByTopLeft( + List left, + List right) + { + float leftMinY = MinInputY(left); + float rightMinY = MinInputY(right); + int yComparison = leftMinY.CompareTo(rightMinY); + if (yComparison != 0) + { + return yComparison; + } + + float leftMinX = MinInputX(left); + float rightMinX = MinInputX(right); + int xComparison = leftMinX.CompareTo(rightMinX); + if (xComparison != 0) + { + return xComparison; + } + + (int, int) leftMinCell = MinRowColumn(left); + (int, int) rightMinCell = MinRowColumn(right); + int rowComparison = leftMinCell.Item1.CompareTo(rightMinCell.Item1); + if (rowComparison != 0) + { + return rowComparison; + } + return leftMinCell.Item2.CompareTo(rightMinCell.Item2); + } + + private static float MinInputX(List samples) + { + float minValue = samples[0].InputX; + for (int i = 1; i < samples.Count; i++) + { + if (samples[i].InputX < minValue) + { + minValue = samples[i].InputX; + } + } + return minValue; + } + + private static float MinInputY(List samples) + { + float minValue = samples[0].InputY; + for (int i = 1; i < samples.Count; i++) + { + if (samples[i].InputY < minValue) + { + minValue = samples[i].InputY; + } + } + return minValue; + } + + private static (int, int) MinRowColumn(List samples) + { + (int, int) minCell = (samples[0].Row, samples[0].Column); + for (int i = 1; i < samples.Count; i++) + { + (int, int) candidate = (samples[i].Row, samples[i].Column); + if (candidate.Item1 < minCell.Item1 || + (candidate.Item1 == minCell.Item1 && candidate.Item2 < minCell.Item2)) + { + minCell = candidate; + } + } + return minCell; + } + + 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 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/RaycastPhysicsColliderBuilder.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastPhysicsColliderBuilder.cs.meta new file mode 100644 index 0000000000..5edfb1393e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/RaycastAnnotation/RaycastPhysicsColliderBuilder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df32b919d3fc4b328ae558c89ad1f670 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCoordinateMetadata.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCoordinateMetadata.cs new file mode 100644 index 0000000000..1e6c177985 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCoordinateMetadata.cs @@ -0,0 +1,32 @@ +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Applies screenshot coordinate-system metadata onto ScreenshotInfo responses. + /// + internal static class ScreenshotCoordinateMetadata + { + internal static void ApplyRendering( + ScreenshotInfo info, + Vector2 gameViewSize, + int imageToInputOffsetY = 0) + { + info.ImageCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_GAME_VIEW; + info.GameViewWidth = gameViewSize.x; + info.GameViewHeight = gameViewSize.y; + info.ImageToInputOffsetY = imageToInputOffsetY; + info.ScreenshotToInputFormula = UnityCliLoopConstants.SCREENSHOT_RENDERING_TO_INPUT_FORMULA; + info.UnityInputFormula = UnityCliLoopConstants.COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY; + } + + internal static void ApplyWindow(ScreenshotInfo info) + { + info.ImageCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_WINDOW; + info.ScreenshotToInputFormula = UnityCliLoopConstants.SCREENSHOT_WINDOW_TO_INPUT_FORMULA_UNAVAILABLE; + info.UnityInputFormula = ""; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCoordinateMetadata.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCoordinateMetadata.cs.meta new file mode 100644 index 0000000000..f111aab8dd --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotCoordinateMetadata.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 403c6338ff28409293311fb02c9f1d7e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotFileWriter.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotFileWriter.cs new file mode 100644 index 0000000000..1382fbcc1f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotFileWriter.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves screenshot output paths and writes captured textures to disk. + /// + internal static class ScreenshotFileWriter + { + internal static string EnsureOutputDirectoryExists(string outputDirectory) + { + string resolvedDirectory; + + if (string.IsNullOrEmpty(outputDirectory)) + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + resolvedDirectory = Path.Combine(projectRoot, UnityCliLoopConstants.OUTPUT_ROOT_DIR, UnityCliLoopConstants.SCREENSHOTS_DIR); + } + else + { + resolvedDirectory = Path.GetFullPath(outputDirectory); + } + + Directory.CreateDirectory(resolvedDirectory); + + return resolvedDirectory; + } + + internal static string SanitizeFileName(string name) + { + char[] invalidChars = Path.GetInvalidFileNameChars(); + string sanitized = name; + foreach (char c in invalidChars) + { + sanitized = sanitized.Replace(c, '_'); + } + return sanitized; + } + + internal static void SaveTextureAsPng(Texture2D texture, string fullPath) + { + byte[] pngData = texture.EncodeToPNG(); + if (pngData == null) + { + throw new InvalidOperationException($"Failed to encode texture to PNG. Format: {texture.format}, Size: {texture.width}x{texture.height}"); + } + File.WriteAllBytes(fullPath, pngData); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotFileWriter.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotFileWriter.cs.meta new file mode 100644 index 0000000000..58a6111ea8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotFileWriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1b54dc29221946e4af50763527638e22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs new file mode 100644 index 0000000000..4191d755ec --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Validates ScreenshotSchema parameters before capture begins. + /// + internal static class ScreenshotParameterValidator + { + internal static void Validate(ScreenshotSchema request) + { + if (request.CaptureMode != CaptureMode.rendering && + string.IsNullOrEmpty(request.WindowName)) + { + throw new UnityCliLoopToolParameterValidationException("WindowName cannot be null or empty"); + } + + if (request.ResolutionScale < 0.1f || request.ResolutionScale > 1.0f) + { + throw new UnityCliLoopToolParameterValidationException( + $"ResolutionScale must be between 0.1 and 1.0, got: {request.ResolutionScale}"); + } + + // AnnotateElements, ElementsOnly, and AnnotateRaycastGrid rely on PlayMode rendering pipeline + if (request.CaptureMode != CaptureMode.rendering) + { + if (request.AnnotateElements) + { + throw new UnityCliLoopToolParameterValidationException("AnnotateElements is only supported when CaptureMode=rendering"); + } + + if (request.ElementsOnly) + { + throw new UnityCliLoopToolParameterValidationException("ElementsOnly is only supported when CaptureMode=rendering"); + } + + if (request.AnnotateRaycastGrid) + { + throw new UnityCliLoopToolParameterValidationException("AnnotateRaycastGrid is only supported when CaptureMode=rendering"); + } + } + + if (request.ElementsOnly && + !request.AnnotateElements && + !request.AnnotateRaycastGrid) + { + throw new UnityCliLoopToolParameterValidationException( + "ElementsOnly requires AnnotateElements=true or AnnotateRaycastGrid=true"); + } + + RaycastLayerMaskResolution raycastLayerMaskResolution = ResolveRaycastLayerMask(request); + if (raycastLayerMaskResolution.HasLayerNames && !request.AnnotateRaycastGrid) + { + throw new UnityCliLoopToolParameterValidationException( + "RaycastLayerMask requires AnnotateRaycastGrid=true"); + } + + if (!raycastLayerMaskResolution.IsValid) + { + throw new UnityCliLoopToolParameterValidationException( + CreateInvalidRaycastLayerMaskMessage(raycastLayerMaskResolution)); + } + } + + internal static RaycastLayerMaskResolution ResolveRaycastLayerMask(ScreenshotSchema request) + { + string raycastLayerMask = request.RaycastLayerMask ?? ""; + return RaycastLayerMaskResolver.Resolve( + raycastLayerMask, + GetAvailableLayerDefinitions()); + } + + internal static List GetAvailableLayerDefinitions() + { + List layerDefinitions = new(); + for (int layerIndex = 0; layerIndex <= 31; layerIndex++) + { + string layerName = LayerMask.LayerToName(layerIndex); + if (string.IsNullOrEmpty(layerName)) + { + continue; + } + + layerDefinitions.Add(new RaycastLayerDefinition + { + Name = layerName, + Index = layerIndex + }); + } + + return layerDefinitions; + } + + internal static string CreateInvalidRaycastLayerMaskMessage( + RaycastLayerMaskResolution raycastLayerMaskResolution) + { + string invalidLayerNames = string.Join(", ", raycastLayerMaskResolution.InvalidLayerNames); + string validLayerNames = string.Join(", ", raycastLayerMaskResolution.ValidLayerNames); + if (string.IsNullOrEmpty(validLayerNames)) + { + validLayerNames = "(none)"; + } + + return $"RaycastLayerMask contains unknown layer name(s): {invalidLayerNames}. Valid layers: {validLayerNames}"; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs.meta new file mode 100644 index 0000000000..8e0d0b314b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1589cc00a58e4863af06b2d5d690d824 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotResponseFactory.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotResponseFactory.cs new file mode 100644 index 0000000000..7b2ee8d26b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotResponseFactory.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds annotated-element payloads for screenshot responses. + /// + internal static class ScreenshotResponseFactory + { + internal static List CreateResponseAnnotatedElements( + List uiElements, + List physicsColliderElements) + { + List responseElements = new(uiElements); + responseElements.AddRange(physicsColliderElements); + return responseElements; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotResponseFactory.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotResponseFactory.cs.meta new file mode 100644 index 0000000000..67b004c6ad --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotResponseFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0186959abd5743aeb537206e8fc5b81e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs index 68388304a0..caf2ee381e 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotUseCase.cs @@ -34,7 +34,7 @@ public async Task CaptureAsync( aiTodo: "Monitor capture performance and file size" ); - ValidateParameters(request); + ScreenshotParameterValidator.Validate(request); if (request.CaptureMode == CaptureMode.rendering) { @@ -92,8 +92,8 @@ private async Task CaptureRenderingAsync( raycastGridRenderingInfo = renderingImageInfo; gameViewSize = renderingImageInfo.GameViewSize; - RaycastLayerMaskResolution raycastLayerMaskResolution = ResolveRaycastLayerMask(request); - List availableLayerDefinitions = GetAvailableLayerDefinitions(); + RaycastLayerMaskResolution raycastLayerMaskResolution = ScreenshotParameterValidator.ResolveRaycastLayerMask(request); + List availableLayerDefinitions = ScreenshotParameterValidator.GetAvailableLayerDefinitions(); int effectiveLayerMask = raycastLayerMaskResolution.HasLayerNames ? raycastLayerMaskResolution.Mask : Physics.DefaultRaycastLayers; @@ -202,11 +202,11 @@ private async Task CaptureRenderingAsync( try { - string outputDirectory = EnsureOutputDirectoryExists(request.OutputDirectory); + string outputDirectory = ScreenshotFileWriter.EnsureOutputDirectoryExists(request.OutputDirectory); string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"); string savedPath = Path.Combine(outputDirectory, $"Rendering_{timestamp}.png"); - SaveTextureAsPng(texture, savedPath); + ScreenshotFileWriter.SaveTextureAsPng(texture, savedPath); // why: only prune the package default Screenshots folder; never delete files in a user-specified OutputDirectory if (string.IsNullOrEmpty(request.OutputDirectory)) { @@ -300,7 +300,7 @@ private async Task CaptureWindowsAsync( }; } - string outputDirectory = EnsureOutputDirectoryExists(request.OutputDirectory); + string outputDirectory = ScreenshotFileWriter.EnsureOutputDirectoryExists(request.OutputDirectory); string safeWindowName = SanitizeFileName(captureWindowName); string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff"); List screenshots = new(); @@ -339,7 +339,7 @@ private async Task CaptureWindowsAsync( try { - SaveTextureAsPng(texture, savedPath); + ScreenshotFileWriter.SaveTextureAsPng(texture, savedPath); FileInfo savedFileInfo = new(savedPath); ScreenshotInfo info = new() @@ -424,170 +424,35 @@ private static void DestroyAnnotationOverlay( editorContext.Post(_ => UIElementAnnotator.DestroyAnnotationOverlay(annotationOverlay), null); } - private static void ApplyRenderingCoordinateMetadata( + internal static void ApplyRenderingCoordinateMetadata( ScreenshotInfo info, Vector2 gameViewSize, int imageToInputOffsetY = 0) { - info.ImageCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_GAME_VIEW; - info.GameViewWidth = gameViewSize.x; - info.GameViewHeight = gameViewSize.y; - info.ImageToInputOffsetY = imageToInputOffsetY; - info.ScreenshotToInputFormula = UnityCliLoopConstants.SCREENSHOT_RENDERING_TO_INPUT_FORMULA; - info.UnityInputFormula = UnityCliLoopConstants.COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY; + ScreenshotCoordinateMetadata.ApplyRendering(info, gameViewSize, imageToInputOffsetY); } - private static void ApplyWindowCoordinateMetadata(ScreenshotInfo info) + internal static void ApplyWindowCoordinateMetadata(ScreenshotInfo info) { - info.ImageCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_WINDOW; - info.ScreenshotToInputFormula = UnityCliLoopConstants.SCREENSHOT_WINDOW_TO_INPUT_FORMULA_UNAVAILABLE; - info.UnityInputFormula = ""; + ScreenshotCoordinateMetadata.ApplyWindow(info); } - private static List CreateResponseAnnotatedElements( + internal static List CreateResponseAnnotatedElements( List uiElements, List physicsColliderElements) { - List responseElements = new(uiElements); - responseElements.AddRange(physicsColliderElements); - return responseElements; + return ScreenshotResponseFactory.CreateResponseAnnotatedElements(uiElements, physicsColliderElements); } - private void ValidateParameters(ScreenshotSchema request) - { - if (request.CaptureMode != CaptureMode.rendering && - string.IsNullOrEmpty(request.WindowName)) - { - throw new UnityCliLoopToolParameterValidationException("WindowName cannot be null or empty"); - } - - if (request.ResolutionScale < 0.1f || request.ResolutionScale > 1.0f) - { - throw new UnityCliLoopToolParameterValidationException( - $"ResolutionScale must be between 0.1 and 1.0, got: {request.ResolutionScale}"); - } - - // AnnotateElements, ElementsOnly, and AnnotateRaycastGrid rely on PlayMode rendering pipeline - if (request.CaptureMode != CaptureMode.rendering) - { - if (request.AnnotateElements) - { - throw new UnityCliLoopToolParameterValidationException("AnnotateElements is only supported when CaptureMode=rendering"); - } - - if (request.ElementsOnly) - { - throw new UnityCliLoopToolParameterValidationException("ElementsOnly is only supported when CaptureMode=rendering"); - } - - if (request.AnnotateRaycastGrid) - { - throw new UnityCliLoopToolParameterValidationException("AnnotateRaycastGrid is only supported when CaptureMode=rendering"); - } - } - - if (request.ElementsOnly && - !request.AnnotateElements && - !request.AnnotateRaycastGrid) - { - throw new UnityCliLoopToolParameterValidationException( - "ElementsOnly requires AnnotateElements=true or AnnotateRaycastGrid=true"); - } - - RaycastLayerMaskResolution raycastLayerMaskResolution = ResolveRaycastLayerMask(request); - if (raycastLayerMaskResolution.HasLayerNames && !request.AnnotateRaycastGrid) - { - throw new UnityCliLoopToolParameterValidationException( - "RaycastLayerMask requires AnnotateRaycastGrid=true"); - } - - if (!raycastLayerMaskResolution.IsValid) - { - throw new UnityCliLoopToolParameterValidationException( - CreateInvalidRaycastLayerMaskMessage(raycastLayerMaskResolution)); - } - } - - private static RaycastLayerMaskResolution ResolveRaycastLayerMask(ScreenshotSchema request) - { - string raycastLayerMask = request.RaycastLayerMask ?? ""; - return RaycastLayerMaskResolver.Resolve( - raycastLayerMask, - GetAvailableLayerDefinitions()); - } - - private static List GetAvailableLayerDefinitions() - { - List layerDefinitions = new(); - for (int layerIndex = 0; layerIndex <= 31; layerIndex++) - { - string layerName = LayerMask.LayerToName(layerIndex); - if (string.IsNullOrEmpty(layerName)) - { - continue; - } - - layerDefinitions.Add(new RaycastLayerDefinition - { - Name = layerName, - Index = layerIndex - }); - } - - return layerDefinitions; - } - - private static string CreateInvalidRaycastLayerMaskMessage( + internal static string CreateInvalidRaycastLayerMaskMessage( RaycastLayerMaskResolution raycastLayerMaskResolution) { - string invalidLayerNames = string.Join(", ", raycastLayerMaskResolution.InvalidLayerNames); - string validLayerNames = string.Join(", ", raycastLayerMaskResolution.ValidLayerNames); - if (string.IsNullOrEmpty(validLayerNames)) - { - validLayerNames = "(none)"; - } - - return $"RaycastLayerMask contains unknown layer name(s): {invalidLayerNames}. Valid layers: {validLayerNames}"; + return ScreenshotParameterValidator.CreateInvalidRaycastLayerMaskMessage(raycastLayerMaskResolution); } - private string EnsureOutputDirectoryExists(string outputDirectory) + internal static string SanitizeFileName(string name) { - string resolvedDirectory; - - if (string.IsNullOrEmpty(outputDirectory)) - { - string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); - resolvedDirectory = Path.Combine(projectRoot, UnityCliLoopConstants.OUTPUT_ROOT_DIR, UnityCliLoopConstants.SCREENSHOTS_DIR); - } - else - { - resolvedDirectory = Path.GetFullPath(outputDirectory); - } - - Directory.CreateDirectory(resolvedDirectory); - - return resolvedDirectory; - } - - private string SanitizeFileName(string name) - { - char[] invalidChars = Path.GetInvalidFileNameChars(); - string sanitized = name; - foreach (char c in invalidChars) - { - sanitized = sanitized.Replace(c, '_'); - } - return sanitized; - } - - private void SaveTextureAsPng(Texture2D texture, string fullPath) - { - byte[] pngData = texture.EncodeToPNG(); - if (pngData == null) - { - throw new InvalidOperationException($"Failed to encode texture to PNG. Format: {texture.format}, Size: {texture.width}x{texture.height}"); - } - File.WriteAllBytes(fullPath, pngData); + return ScreenshotFileWriter.SanitizeFileName(name); } } } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationRenderer.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationRenderer.cs new file mode 100644 index 0000000000..563b97b1b5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationRenderer.cs @@ -0,0 +1,378 @@ +#nullable enable +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.UI; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Draws annotation borders, outlines, and labels onto a Screen Space Overlay canvas. + /// + internal static class UIElementAnnotationRenderer + { + private const int LABEL_FONT_SIZE = 20; + private const int LABEL_PADDING_H = 6; + private const int LABEL_PADDING_V = 3; + + internal static void RenderAnnotations( + Transform parent, + List elements, + Font font, + UIElementAnnotator.AnnotationBorderMetrics borderMetrics) + { + List drawInfos = new(elements.Count); + float physicsAnnotationScreenHeight = CalculatePhysicsAnnotationScreenHeight(elements); + + foreach (UIElementInfo element in elements) + { + drawInfos.Add(CreateAnnotationDrawInfo(element, physicsAnnotationScreenHeight)); + } + + foreach (AnnotationDrawInfo drawInfo in drawInfos) + { + CreateAnnotationBorderForElement(parent, drawInfo, borderMetrics); + } + + foreach (AnnotationDrawInfo drawInfo in drawInfos) + { + CreateAnnotationLabelForElement(parent, drawInfo, font, borderMetrics); + } + } + + internal static RaycastOutlineSegment ConvertTopLeftOutlineSegmentToScreenSegment( + RaycastOutlineSegment segment, + float screenHeight) + { + return new RaycastOutlineSegment( + segment.StartX, + screenHeight - segment.StartY, + segment.EndX, + screenHeight - segment.EndY); + } + + internal static Rect CalculateOutlineSegmentRect(RaycastOutlineSegment segment, float thickness) + { + Debug.Assert(thickness >= 0f, "Outline thickness must not be negative."); + bool horizontal = Mathf.Approximately(segment.StartY, segment.EndY); + bool vertical = Mathf.Approximately(segment.StartX, segment.EndX); + Debug.Assert(horizontal || vertical, "Raycast outline segments must be axis-aligned."); + + if (horizontal) + { + float minX = Mathf.Min(segment.StartX, segment.EndX) - thickness / 2f; + float width = Mathf.Abs(segment.EndX - segment.StartX) + thickness; + return new Rect(minX, segment.StartY - thickness / 2f, width, thickness); + } + + float minY = Mathf.Min(segment.StartY, segment.EndY) - thickness / 2f; + float height = Mathf.Abs(segment.EndY - segment.StartY) + thickness; + return new Rect(segment.StartX - thickness / 2f, minY, thickness, height); + } + + internal static UIElementAnnotator.BorderEdgeRects CalculateBorderEdgeRects( + float minX, float minY, float maxX, float maxY, float thickness) + { + Debug.Assert(maxX >= minX, "maxX must not be smaller than minX."); + Debug.Assert(maxY >= minY, "maxY must not be smaller than minY."); + Debug.Assert(thickness >= 0f, "thickness must not be negative."); + + float boxWidth = maxX - minX; + float boxHeight = maxY - minY; + float verticalEdgeHeight = Mathf.Max(0f, boxHeight - thickness * 2f); + + return new UIElementAnnotator.BorderEdgeRects( + new Rect(minX, maxY - thickness, boxWidth, thickness), + new Rect(minX, minY, boxWidth, thickness), + new Rect(minX, minY + thickness, thickness, verticalEdgeHeight), + new Rect(maxX - thickness, minY + thickness, thickness, verticalEdgeHeight)); + } + + private static void CreateAnnotationBorderForElement( + Transform parent, + AnnotationDrawInfo drawInfo, + UIElementAnnotator.AnnotationBorderMetrics borderMetrics) + { + if (drawInfo.OutlineSegments.Count > 0) + { + CreateAnnotationOutlineForElement(parent, drawInfo, borderMetrics); + return; + } + + CreateBorder( + parent, + "LightOuter", + drawInfo.ScreenMinX - borderMetrics.OuterOffset, + drawInfo.ScreenMinY - borderMetrics.OuterOffset, + drawInfo.ScreenMaxX + borderMetrics.OuterOffset, + drawInfo.ScreenMaxY + borderMetrics.OuterOffset, + borderMetrics.NeutralThickness, + drawInfo.BorderColors.Outer); + CreateBorder( + parent, + "ColorMiddle", + drawInfo.ScreenMinX - borderMetrics.ColorOffset, + drawInfo.ScreenMinY - borderMetrics.ColorOffset, + drawInfo.ScreenMaxX + borderMetrics.ColorOffset, + drawInfo.ScreenMaxY + borderMetrics.ColorOffset, + borderMetrics.ColorThickness, + drawInfo.BorderColors.Middle); + CreateBorder( + parent, + "DarkInner", + drawInfo.ScreenMinX, + drawInfo.ScreenMinY, + drawInfo.ScreenMaxX, + drawInfo.ScreenMaxY, + borderMetrics.NeutralThickness, + drawInfo.BorderColors.Inner); + } + + private static void CreateAnnotationOutlineForElement( + Transform parent, + AnnotationDrawInfo drawInfo, + UIElementAnnotator.AnnotationBorderMetrics borderMetrics) + { + float outerThickness = borderMetrics.ColorThickness + borderMetrics.NeutralThickness * 2f; + CreateOutline( + parent, + "LightOuter", + drawInfo.OutlineSegments, + outerThickness, + drawInfo.BorderColors.Outer); + CreateOutline( + parent, + "ColorMiddle", + drawInfo.OutlineSegments, + borderMetrics.ColorThickness, + drawInfo.BorderColors.Middle); + CreateOutline( + parent, + "DarkInner", + drawInfo.OutlineSegments, + borderMetrics.NeutralThickness, + drawInfo.BorderColors.Inner); + } + + private static void CreateAnnotationLabelForElement( + Transform parent, + AnnotationDrawInfo drawInfo, + Font font, + UIElementAnnotator.AnnotationBorderMetrics borderMetrics) + { + CreateLabel( + parent, + drawInfo.DisplayLabel, + drawInfo.ScreenMinX, + drawInfo.ScreenMaxY + borderMetrics.OuterOffset + borderMetrics.LabelOutlineDistance + borderMetrics.LabelToBorderGap, + drawInfo.Color, + drawInfo.ContrastColor, + font, + borderMetrics.LabelOutlineDistance); + } + + private static AnnotationDrawInfo CreateAnnotationDrawInfo( + UIElementInfo element, + float physicsAnnotationScreenHeight) + { + Color color = UIElementAnnotationStyling.GetAnnotationColorForElement(element); + Color contrastColor = UIElementAnnotationStyling.GetContrastingTextColor(color); + UIElementAnnotator.AnnotationBorderColors borderColors = UIElementAnnotationStyling.GetAnnotationBorderColors(color); + string displayLabel = UIElementAnnotationStyling.CreateDisplayLabel(element); + float screenMinX = element.BoundsMinX; + float screenMinY = element.BoundsMinY; + float screenMaxX = element.BoundsMaxX; + float screenMaxY = element.BoundsMaxY; + List outlineSegments = element.RaycastOutlineSegments; + + if (IsPhysicsColliderElement(element)) + { + Debug.Assert( + physicsAnnotationScreenHeight >= 0f, + "Physics collider annotations require a non-negative Game View height."); + screenMinY = physicsAnnotationScreenHeight - element.BoundsMaxY; + screenMaxY = physicsAnnotationScreenHeight - element.BoundsMinY; + outlineSegments = ConvertTopLeftOutlineSegmentsToScreenSegments( + element.RaycastOutlineSegments, + physicsAnnotationScreenHeight); + } + + return new AnnotationDrawInfo( + screenMinX, + screenMinY, + screenMaxX, + screenMaxY, + color, + contrastColor, + borderColors, + displayLabel, + outlineSegments); + } + + private static float CalculatePhysicsAnnotationScreenHeight(List elements) + { + foreach (UIElementInfo element in elements) + { + if (!IsPhysicsColliderElement(element)) + { + continue; + } + + return GameViewCoordinateUtility.GetMainGameViewSize().y; + } + + return 0f; + } + + private static bool IsPhysicsColliderElement(UIElementInfo element) + { + return element.Type == RaycastPhysicsColliderBuilder.PhysicsColliderElementType; + } + + private static List ConvertTopLeftOutlineSegmentsToScreenSegments( + List outlineSegments, + float screenHeight) + { + List screenSegments = new(outlineSegments.Count); + foreach (RaycastOutlineSegment segment in outlineSegments) + { + screenSegments.Add(ConvertTopLeftOutlineSegmentToScreenSegment(segment, screenHeight)); + } + + return screenSegments; + } + + private static void CreateBorder( + Transform parent, string name, + float minX, float minY, float maxX, float maxY, + float thickness, Color color) + { + UIElementAnnotator.BorderEdgeRects borderEdgeRects = CalculateBorderEdgeRects(minX, minY, maxX, maxY, thickness); + + CreateBorderEdge(parent, $"{name}_Top", borderEdgeRects.Top, color); + CreateBorderEdge(parent, $"{name}_Bottom", borderEdgeRects.Bottom, color); + CreateBorderEdge(parent, $"{name}_Left", borderEdgeRects.Left, color); + CreateBorderEdge(parent, $"{name}_Right", borderEdgeRects.Right, color); + } + + private static void CreateOutline( + Transform parent, + string name, + List outlineSegments, + float thickness, + Color color) + { + for (int i = 0; i < outlineSegments.Count; i++) + { + Rect rect = CalculateOutlineSegmentRect(outlineSegments[i], thickness); + CreateBorderEdge(parent, $"{name}_Outline_{i}", rect, color); + } + } + + private static void CreateBorderEdge( + Transform parent, string name, + Rect rect, + Color color) + { + GameObject edgeGo = new($"Border_{name}"); + edgeGo.hideFlags = HideFlags.HideAndDontSave; + edgeGo.transform.SetParent(parent, false); + + RectTransform rt = edgeGo.AddComponent(); + rt.anchorMin = Vector2.zero; + rt.anchorMax = Vector2.zero; + rt.pivot = Vector2.zero; + rt.anchoredPosition = new Vector2(rect.x, rect.y); + rt.sizeDelta = new Vector2(rect.width, rect.height); + + Image image = edgeGo.AddComponent(); + image.color = color; + image.raycastTarget = false; + } + + private static void CreateLabel( + Transform parent, string text, + float x, float y, + Color backgroundColor, Color textColor, Font font, float outlineDistance) + { + GameObject bgGo = new("LabelBg"); + bgGo.hideFlags = HideFlags.HideAndDontSave; + bgGo.transform.SetParent(parent, false); + + RectTransform bgRt = bgGo.AddComponent(); + bgRt.anchorMin = Vector2.zero; + bgRt.anchorMax = Vector2.zero; + bgRt.pivot = new Vector2(0f, 0f); + bgRt.anchoredPosition = new Vector2(x, y); + + Image bgImage = bgGo.AddComponent(); + bgImage.color = backgroundColor; + bgImage.raycastTarget = false; + + Outline bgOutline = bgGo.AddComponent(); + bgOutline.effectColor = UIElementAnnotationStyling.GetContrastPartnerColor(backgroundColor); + bgOutline.effectDistance = new Vector2(outlineDistance, -outlineDistance); + + ContentSizeFitter fitter = bgGo.AddComponent(); + fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; + fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + + HorizontalLayoutGroup layout = bgGo.AddComponent(); + layout.padding = new RectOffset(LABEL_PADDING_H, LABEL_PADDING_H, LABEL_PADDING_V, LABEL_PADDING_V); + layout.childAlignment = TextAnchor.MiddleLeft; + + GameObject textGo = new("LabelText"); + textGo.hideFlags = HideFlags.HideAndDontSave; + textGo.transform.SetParent(bgGo.transform, false); + + textGo.AddComponent(); + + Text labelText = textGo.AddComponent(); + labelText.text = text; + labelText.font = font; + labelText.fontSize = LABEL_FONT_SIZE; + labelText.fontStyle = FontStyle.Bold; + labelText.color = textColor; + labelText.alignment = TextAnchor.MiddleLeft; + labelText.horizontalOverflow = HorizontalWrapMode.Overflow; + labelText.verticalOverflow = VerticalWrapMode.Overflow; + labelText.raycastTarget = false; + } + + private readonly struct AnnotationDrawInfo + { + public readonly float ScreenMinX; + public readonly float ScreenMinY; + public readonly float ScreenMaxX; + public readonly float ScreenMaxY; + public readonly Color Color; + public readonly Color ContrastColor; + public readonly UIElementAnnotator.AnnotationBorderColors BorderColors; + public readonly string DisplayLabel; + public readonly List OutlineSegments; + + public AnnotationDrawInfo( + float screenMinX, + float screenMinY, + float screenMaxX, + float screenMaxY, + Color color, + Color contrastColor, + UIElementAnnotator.AnnotationBorderColors borderColors, + string displayLabel, + List outlineSegments) + { + ScreenMinX = screenMinX; + ScreenMinY = screenMinY; + ScreenMaxX = screenMaxX; + ScreenMaxY = screenMaxY; + Color = color; + ContrastColor = contrastColor; + BorderColors = borderColors; + DisplayLabel = displayLabel; + OutlineSegments = outlineSegments; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationRenderer.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationRenderer.cs.meta new file mode 100644 index 0000000000..a3a4571a2c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationRenderer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2757cc6496e94784846878576b215b2e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationStyling.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationStyling.cs new file mode 100644 index 0000000000..9a72ae5105 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationStyling.cs @@ -0,0 +1,171 @@ +#nullable enable +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Resolves annotation colors, contrast, border metrics, and display labels for UI elements. + /// + internal static class UIElementAnnotationStyling + { + private const float OUTPUT_BORDER_NEUTRAL_THICKNESS = 2f; + private const float OUTPUT_BORDER_COLOR_THICKNESS = 4f; + private const float LABEL_DARK_TEXT_LUMINANCE_THRESHOLD = 0.62f; + private const float OUTPUT_LABEL_OUTLINE_DISTANCE = 2f; + private const float OUTPUT_LABEL_TO_BORDER_GAP = 4f; + private const string INTERACTION_CLICK = "Click"; + private const string INTERACTION_DRAG = "Drag"; + private const string INTERACTION_DROP = "Drop"; + private const string INTERACTION_TEXT = "Text"; + private const string DISPLAY_LABEL_SEPARATOR = " / "; + + // Label-based colors separate dense controls where many elements share the same UI type. + private static readonly Color[] ANNOTATION_COLORS = + { + new Color(1f, 0.35f, 0f, 0.95f), + new Color(0f, 0.9f, 1f, 0.95f), + new Color(1f, 0.15f, 0.65f, 0.95f), + new Color(1f, 0.9f, 0f, 0.95f), + new Color(0.2f, 1f, 0.35f, 0.95f), + new Color(0.65f, 0.45f, 1f, 0.95f), + new Color(1f, 1f, 1f, 0.95f), + new Color(0.15f, 0.55f, 1f, 0.95f), + new Color(1f, 0.55f, 0.75f, 0.95f), + new Color(0.45f, 1f, 0.8f, 0.95f), + new Color(0.9f, 0.45f, 0.15f, 0.95f), + new Color(0.45f, 0.85f, 0.1f, 0.95f), + new Color(0.95f, 0.2f, 0.2f, 0.95f), + new Color(0.55f, 0.7f, 1f, 0.95f), + new Color(0.95f, 0.95f, 0.45f, 0.95f), + new Color(0.85f, 0.55f, 1f, 0.95f) + }; + private static readonly Color FALLBACK_COLOR = new Color(1f, 1f, 0f, 0.9f); + private static readonly Color DARK_CONTRAST_COLOR = new Color(0f, 0f, 0f, 0.95f); + private static readonly Color LIGHT_CONTRAST_COLOR = new Color(1f, 1f, 1f, 0.95f); + + internal static Color GetAnnotationColorForElement(UIElementInfo element) + { + Debug.Assert(element != null, "UIElementInfo must not be null."); + + int labelIndex = GetLabelIndex(element!.Label); + if (labelIndex < 0) + { + return FALLBACK_COLOR; + } + + return ANNOTATION_COLORS[labelIndex % ANNOTATION_COLORS.Length]; + } + + internal static Color GetContrastingTextColor(Color backgroundColor) + { + float luminance = CalculateLuminance(backgroundColor); + if (luminance >= LABEL_DARK_TEXT_LUMINANCE_THRESHOLD) + { + return DARK_CONTRAST_COLOR; + } + + return LIGHT_CONTRAST_COLOR; + } + + internal static Color GetContrastPartnerColor(Color color) + { + Color readableColor = GetContrastingTextColor(color); + if (readableColor == DARK_CONTRAST_COLOR) + { + return LIGHT_CONTRAST_COLOR; + } + + return DARK_CONTRAST_COLOR; + } + + internal static UIElementAnnotator.AnnotationBorderColors GetAnnotationBorderColors(Color annotationColor) + { + return new UIElementAnnotator.AnnotationBorderColors(DARK_CONTRAST_COLOR, annotationColor, LIGHT_CONTRAST_COLOR); + } + + internal static UIElementAnnotator.AnnotationBorderMetrics CalculateAnnotationBorderMetrics(float outputResolutionScale) + { + Debug.Assert(outputResolutionScale > 0f, "Output resolution scale must be positive."); + + float neutralThickness = OUTPUT_BORDER_NEUTRAL_THICKNESS / outputResolutionScale; + float colorThickness = OUTPUT_BORDER_COLOR_THICKNESS / outputResolutionScale; + float labelOutlineDistance = OUTPUT_LABEL_OUTLINE_DISTANCE / outputResolutionScale; + float labelToBorderGap = OUTPUT_LABEL_TO_BORDER_GAP / outputResolutionScale; + + return new UIElementAnnotator.AnnotationBorderMetrics( + neutralThickness, + colorThickness, + colorThickness, + colorThickness + neutralThickness, + labelOutlineDistance, + labelToBorderGap); + } + + internal static string GetInteractionForType(string type) + { + if (type == "Slider" || type == "Scrollbar" || type == "Draggable") + { + return INTERACTION_DRAG; + } + + if (type == "DropTarget") + { + return INTERACTION_DROP; + } + + if (type == "InputField") + { + return INTERACTION_TEXT; + } + + return INTERACTION_CLICK; + } + + internal static string CreateDisplayLabel(UIElementInfo element) + { + Debug.Assert(element != null, "UIElementInfo must not be null."); + + string interaction = element!.Interaction; + if (string.IsNullOrEmpty(interaction)) + { + interaction = GetInteractionForType(element.Type); + } + + if (string.IsNullOrEmpty(element.Label)) + { + return interaction.ToUpperInvariant(); + } + + return $"{element.Label}{DISPLAY_LABEL_SEPARATOR}{interaction.ToUpperInvariant()}"; + } + + private static float CalculateLuminance(Color color) + { + return color.r * 0.299f + color.g * 0.587f + color.b * 0.114f; + } + + private static int GetLabelIndex(string label) + { + if (string.IsNullOrEmpty(label)) + { + return -1; + } + + int index = 0; + for (int i = 0; i < label.Length; i++) + { + char labelCharacter = label[i]; + if (labelCharacter < 'A' || labelCharacter > 'Z') + { + return -1; + } + + index = index * 26 + labelCharacter - 'A' + 1; + } + + return index - 1; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationStyling.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationStyling.cs.meta new file mode 100644 index 0000000000..409dc870c1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotationStyling.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9619dc22a1374af4af5fbd787d7be66e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotator.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotator.cs index ed182dfbfc..77569a5723 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotator.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/UIElementAnnotator.cs @@ -17,43 +17,6 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools public static class UIElementAnnotator { private const int OVERLAY_SORT_ORDER = 32767; - private const int LABEL_FONT_SIZE = 20; - private const float OUTPUT_BORDER_NEUTRAL_THICKNESS = 2f; - private const float OUTPUT_BORDER_COLOR_THICKNESS = 4f; - private const int LABEL_PADDING_H = 6; - private const int LABEL_PADDING_V = 3; - private const float LABEL_DARK_TEXT_LUMINANCE_THRESHOLD = 0.62f; - private const float OUTPUT_LABEL_OUTLINE_DISTANCE = 2f; - private const float OUTPUT_LABEL_TO_BORDER_GAP = 4f; - private const string INTERACTION_CLICK = "Click"; - private const string INTERACTION_DRAG = "Drag"; - private const string INTERACTION_DROP = "Drop"; - private const string INTERACTION_TEXT = "Text"; - private const string DISPLAY_LABEL_SEPARATOR = " / "; - - // Label-based colors separate dense controls where many elements share the same UI type. - private static readonly Color[] ANNOTATION_COLORS = - { - new Color(1f, 0.35f, 0f, 0.95f), - new Color(0f, 0.9f, 1f, 0.95f), - new Color(1f, 0.15f, 0.65f, 0.95f), - new Color(1f, 0.9f, 0f, 0.95f), - new Color(0.2f, 1f, 0.35f, 0.95f), - new Color(0.65f, 0.45f, 1f, 0.95f), - new Color(1f, 1f, 1f, 0.95f), - new Color(0.15f, 0.55f, 1f, 0.95f), - new Color(1f, 0.55f, 0.75f, 0.95f), - new Color(0.45f, 1f, 0.8f, 0.95f), - new Color(0.9f, 0.45f, 0.15f, 0.95f), - new Color(0.45f, 0.85f, 0.1f, 0.95f), - new Color(0.95f, 0.2f, 0.2f, 0.95f), - new Color(0.55f, 0.7f, 1f, 0.95f), - new Color(0.95f, 0.95f, 0.45f, 0.95f), - new Color(0.85f, 0.55f, 1f, 0.95f) - }; - private static readonly Color FALLBACK_COLOR = new Color(1f, 1f, 0f, 0.9f); - private static readonly Color DARK_CONTRAST_COLOR = new Color(0f, 0f, 0f, 0.95f); - private static readonly Color LIGHT_CONTRAST_COLOR = new Color(1f, 1f, 1f, 0.95f); public static List CollectInteractiveElements() { @@ -119,23 +82,7 @@ public static GameObject CreateAnnotationOverlay(List elements, f canvas.sortingOrder = OVERLAY_SORT_ORDER; Font font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); - List drawInfos = new(elements.Count); - float physicsAnnotationScreenHeight = CalculatePhysicsAnnotationScreenHeight(elements); - - foreach (UIElementInfo element in elements) - { - drawInfos.Add(CreateAnnotationDrawInfo(element, physicsAnnotationScreenHeight)); - } - - foreach (AnnotationDrawInfo drawInfo in drawInfos) - { - CreateAnnotationBorderForElement(root.transform, drawInfo, borderMetrics); - } - - foreach (AnnotationDrawInfo drawInfo in drawInfos) - { - CreateAnnotationLabelForElement(root.transform, drawInfo, font, borderMetrics); - } + UIElementAnnotationRenderer.RenderAnnotations(root.transform, elements, font, borderMetrics); return root; } @@ -375,426 +322,57 @@ private static bool GetScreenCorners(RectTransform rectTransform, Canvas canvas) return true; } - private static void CreateAnnotationBorderForElement( - Transform parent, - AnnotationDrawInfo drawInfo, - AnnotationBorderMetrics borderMetrics) + internal static Color GetAnnotationColorForElement(UIElementInfo element) { - if (drawInfo.OutlineSegments.Count > 0) - { - CreateAnnotationOutlineForElement(parent, drawInfo, borderMetrics); - return; - } - - CreateBorder( - parent, - "LightOuter", - drawInfo.ScreenMinX - borderMetrics.OuterOffset, - drawInfo.ScreenMinY - borderMetrics.OuterOffset, - drawInfo.ScreenMaxX + borderMetrics.OuterOffset, - drawInfo.ScreenMaxY + borderMetrics.OuterOffset, - borderMetrics.NeutralThickness, - drawInfo.BorderColors.Outer); - CreateBorder( - parent, - "ColorMiddle", - drawInfo.ScreenMinX - borderMetrics.ColorOffset, - drawInfo.ScreenMinY - borderMetrics.ColorOffset, - drawInfo.ScreenMaxX + borderMetrics.ColorOffset, - drawInfo.ScreenMaxY + borderMetrics.ColorOffset, - borderMetrics.ColorThickness, - drawInfo.BorderColors.Middle); - CreateBorder( - parent, - "DarkInner", - drawInfo.ScreenMinX, - drawInfo.ScreenMinY, - drawInfo.ScreenMaxX, - drawInfo.ScreenMaxY, - borderMetrics.NeutralThickness, - drawInfo.BorderColors.Inner); + return UIElementAnnotationStyling.GetAnnotationColorForElement(element); } - private static void CreateAnnotationOutlineForElement( - Transform parent, - AnnotationDrawInfo drawInfo, - AnnotationBorderMetrics borderMetrics) + internal static Color GetContrastingTextColor(Color backgroundColor) { - float outerThickness = borderMetrics.ColorThickness + borderMetrics.NeutralThickness * 2f; - CreateOutline( - parent, - "LightOuter", - drawInfo.OutlineSegments, - outerThickness, - drawInfo.BorderColors.Outer); - CreateOutline( - parent, - "ColorMiddle", - drawInfo.OutlineSegments, - borderMetrics.ColorThickness, - drawInfo.BorderColors.Middle); - CreateOutline( - parent, - "DarkInner", - drawInfo.OutlineSegments, - borderMetrics.NeutralThickness, - drawInfo.BorderColors.Inner); + return UIElementAnnotationStyling.GetContrastingTextColor(backgroundColor); } - private static void CreateAnnotationLabelForElement( - Transform parent, - AnnotationDrawInfo drawInfo, - Font font, - AnnotationBorderMetrics borderMetrics) + internal static Color GetContrastPartnerColor(Color color) { - CreateLabel( - parent, - drawInfo.DisplayLabel, - drawInfo.ScreenMinX, - drawInfo.ScreenMaxY + borderMetrics.OuterOffset + borderMetrics.LabelOutlineDistance + borderMetrics.LabelToBorderGap, - drawInfo.Color, - drawInfo.ContrastColor, - font, - borderMetrics.LabelOutlineDistance); + return UIElementAnnotationStyling.GetContrastPartnerColor(color); } - private static AnnotationDrawInfo CreateAnnotationDrawInfo( - UIElementInfo element, - float physicsAnnotationScreenHeight) + internal static AnnotationBorderColors GetAnnotationBorderColors(Color annotationColor) { - Color color = GetAnnotationColorForElement(element); - Color contrastColor = GetContrastingTextColor(color); - AnnotationBorderColors borderColors = GetAnnotationBorderColors(color); - string displayLabel = CreateDisplayLabel(element); - float screenMinX = element.BoundsMinX; - float screenMinY = element.BoundsMinY; - float screenMaxX = element.BoundsMaxX; - float screenMaxY = element.BoundsMaxY; - List outlineSegments = element.RaycastOutlineSegments; - - if (IsPhysicsColliderElement(element)) - { - Debug.Assert( - physicsAnnotationScreenHeight >= 0f, - "Physics collider annotations require a non-negative Game View height."); - screenMinY = physicsAnnotationScreenHeight - element.BoundsMaxY; - screenMaxY = physicsAnnotationScreenHeight - element.BoundsMinY; - outlineSegments = ConvertTopLeftOutlineSegmentsToScreenSegments( - element.RaycastOutlineSegments, - physicsAnnotationScreenHeight); - } - - return new AnnotationDrawInfo( - screenMinX, - screenMinY, - screenMaxX, - screenMaxY, - color, - contrastColor, - borderColors, - displayLabel, - outlineSegments); + return UIElementAnnotationStyling.GetAnnotationBorderColors(annotationColor); } - private static float CalculatePhysicsAnnotationScreenHeight(List elements) + internal static AnnotationBorderMetrics CalculateAnnotationBorderMetrics(float outputResolutionScale) { - foreach (UIElementInfo element in elements) - { - if (!IsPhysicsColliderElement(element)) - { - continue; - } - - return GameViewCoordinateUtility.GetMainGameViewSize().y; - } - - return 0f; + return UIElementAnnotationStyling.CalculateAnnotationBorderMetrics(outputResolutionScale); } - private static bool IsPhysicsColliderElement(UIElementInfo element) + internal static string GetInteractionForType(string type) { - return element.Type == "PhysicsCollider"; + return UIElementAnnotationStyling.GetInteractionForType(type); } - private static List ConvertTopLeftOutlineSegmentsToScreenSegments( - List outlineSegments, - float screenHeight) + internal static string CreateDisplayLabel(UIElementInfo element) { - List screenSegments = new(outlineSegments.Count); - foreach (RaycastOutlineSegment segment in outlineSegments) - { - screenSegments.Add(ConvertTopLeftOutlineSegmentToScreenSegment(segment, screenHeight)); - } - - return screenSegments; + return UIElementAnnotationStyling.CreateDisplayLabel(element); } internal static RaycastOutlineSegment ConvertTopLeftOutlineSegmentToScreenSegment( RaycastOutlineSegment segment, float screenHeight) { - return new RaycastOutlineSegment( - segment.StartX, - screenHeight - segment.StartY, - segment.EndX, - screenHeight - segment.EndY); - } - - private static void CreateBorder( - Transform parent, string name, - float minX, float minY, float maxX, float maxY, - float thickness, Color color) - { - BorderEdgeRects borderEdgeRects = CalculateBorderEdgeRects(minX, minY, maxX, maxY, thickness); - - CreateBorderEdge(parent, $"{name}_Top", borderEdgeRects.Top, color); - CreateBorderEdge(parent, $"{name}_Bottom", borderEdgeRects.Bottom, color); - CreateBorderEdge(parent, $"{name}_Left", borderEdgeRects.Left, color); - CreateBorderEdge(parent, $"{name}_Right", borderEdgeRects.Right, color); - } - - private static void CreateOutline( - Transform parent, - string name, - List outlineSegments, - float thickness, - Color color) - { - for (int i = 0; i < outlineSegments.Count; i++) - { - Rect rect = CalculateOutlineSegmentRect(outlineSegments[i], thickness); - CreateBorderEdge(parent, $"{name}_Outline_{i}", rect, color); - } + return UIElementAnnotationRenderer.ConvertTopLeftOutlineSegmentToScreenSegment(segment, screenHeight); } internal static Rect CalculateOutlineSegmentRect(RaycastOutlineSegment segment, float thickness) { - Debug.Assert(thickness >= 0f, "Outline thickness must not be negative."); - bool horizontal = Mathf.Approximately(segment.StartY, segment.EndY); - bool vertical = Mathf.Approximately(segment.StartX, segment.EndX); - Debug.Assert(horizontal || vertical, "Raycast outline segments must be axis-aligned."); - - if (horizontal) - { - float minX = Mathf.Min(segment.StartX, segment.EndX) - thickness / 2f; - float width = Mathf.Abs(segment.EndX - segment.StartX) + thickness; - return new Rect(minX, segment.StartY - thickness / 2f, width, thickness); - } - - float minY = Mathf.Min(segment.StartY, segment.EndY) - thickness / 2f; - float height = Mathf.Abs(segment.EndY - segment.StartY) + thickness; - return new Rect(segment.StartX - thickness / 2f, minY, thickness, height); + return UIElementAnnotationRenderer.CalculateOutlineSegmentRect(segment, thickness); } internal static BorderEdgeRects CalculateBorderEdgeRects( float minX, float minY, float maxX, float maxY, float thickness) { - Debug.Assert(maxX >= minX, "maxX must not be smaller than minX."); - Debug.Assert(maxY >= minY, "maxY must not be smaller than minY."); - Debug.Assert(thickness >= 0f, "thickness must not be negative."); - - float boxWidth = maxX - minX; - float boxHeight = maxY - minY; - float verticalEdgeHeight = Mathf.Max(0f, boxHeight - thickness * 2f); - - return new BorderEdgeRects( - new Rect(minX, maxY - thickness, boxWidth, thickness), - new Rect(minX, minY, boxWidth, thickness), - new Rect(minX, minY + thickness, thickness, verticalEdgeHeight), - new Rect(maxX - thickness, minY + thickness, thickness, verticalEdgeHeight)); - } - - private static void CreateBorderEdge( - Transform parent, string name, - Rect rect, - Color color) - { - GameObject edgeGo = new($"Border_{name}"); - edgeGo.hideFlags = HideFlags.HideAndDontSave; - edgeGo.transform.SetParent(parent, false); - - RectTransform rt = edgeGo.AddComponent(); - rt.anchorMin = Vector2.zero; - rt.anchorMax = Vector2.zero; - rt.pivot = Vector2.zero; - rt.anchoredPosition = new Vector2(rect.x, rect.y); - rt.sizeDelta = new Vector2(rect.width, rect.height); - - Image image = edgeGo.AddComponent(); - image.color = color; - image.raycastTarget = false; - } - - private static void CreateLabel( - Transform parent, string text, - float x, float y, - Color backgroundColor, Color textColor, Font font, float outlineDistance) - { - GameObject bgGo = new("LabelBg"); - bgGo.hideFlags = HideFlags.HideAndDontSave; - bgGo.transform.SetParent(parent, false); - - RectTransform bgRt = bgGo.AddComponent(); - bgRt.anchorMin = Vector2.zero; - bgRt.anchorMax = Vector2.zero; - bgRt.pivot = new Vector2(0f, 0f); - bgRt.anchoredPosition = new Vector2(x, y); - - Image bgImage = bgGo.AddComponent(); - bgImage.color = backgroundColor; - bgImage.raycastTarget = false; - - Outline bgOutline = bgGo.AddComponent(); - bgOutline.effectColor = GetContrastPartnerColor(backgroundColor); - bgOutline.effectDistance = new Vector2(outlineDistance, -outlineDistance); - - ContentSizeFitter fitter = bgGo.AddComponent(); - fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; - fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize; - - HorizontalLayoutGroup layout = bgGo.AddComponent(); - layout.padding = new RectOffset(LABEL_PADDING_H, LABEL_PADDING_H, LABEL_PADDING_V, LABEL_PADDING_V); - layout.childAlignment = TextAnchor.MiddleLeft; - - GameObject textGo = new("LabelText"); - textGo.hideFlags = HideFlags.HideAndDontSave; - textGo.transform.SetParent(bgGo.transform, false); - - textGo.AddComponent(); - - Text labelText = textGo.AddComponent(); - labelText.text = text; - labelText.font = font; - labelText.fontSize = LABEL_FONT_SIZE; - labelText.fontStyle = FontStyle.Bold; - labelText.color = textColor; - labelText.alignment = TextAnchor.MiddleLeft; - labelText.horizontalOverflow = HorizontalWrapMode.Overflow; - labelText.verticalOverflow = VerticalWrapMode.Overflow; - labelText.raycastTarget = false; - } - - internal static Color GetAnnotationColorForElement(UIElementInfo element) - { - Debug.Assert(element != null, "UIElementInfo must not be null."); - - int labelIndex = GetLabelIndex(element!.Label); - if (labelIndex < 0) - { - return FALLBACK_COLOR; - } - - return ANNOTATION_COLORS[labelIndex % ANNOTATION_COLORS.Length]; - } - - internal static Color GetContrastingTextColor(Color backgroundColor) - { - float luminance = CalculateLuminance(backgroundColor); - if (luminance >= LABEL_DARK_TEXT_LUMINANCE_THRESHOLD) - { - return DARK_CONTRAST_COLOR; - } - - return LIGHT_CONTRAST_COLOR; - } - - internal static Color GetContrastPartnerColor(Color color) - { - Color readableColor = GetContrastingTextColor(color); - if (readableColor == DARK_CONTRAST_COLOR) - { - return LIGHT_CONTRAST_COLOR; - } - - return DARK_CONTRAST_COLOR; - } - - internal static AnnotationBorderColors GetAnnotationBorderColors(Color annotationColor) - { - return new AnnotationBorderColors(DARK_CONTRAST_COLOR, annotationColor, LIGHT_CONTRAST_COLOR); - } - - internal static AnnotationBorderMetrics CalculateAnnotationBorderMetrics(float outputResolutionScale) - { - Debug.Assert(outputResolutionScale > 0f, "Output resolution scale must be positive."); - - float neutralThickness = OUTPUT_BORDER_NEUTRAL_THICKNESS / outputResolutionScale; - float colorThickness = OUTPUT_BORDER_COLOR_THICKNESS / outputResolutionScale; - float labelOutlineDistance = OUTPUT_LABEL_OUTLINE_DISTANCE / outputResolutionScale; - float labelToBorderGap = OUTPUT_LABEL_TO_BORDER_GAP / outputResolutionScale; - - return new AnnotationBorderMetrics( - neutralThickness, - colorThickness, - colorThickness, - colorThickness + neutralThickness, - labelOutlineDistance, - labelToBorderGap); - } - - internal static string GetInteractionForType(string type) - { - if (type == "Slider" || type == "Scrollbar" || type == "Draggable") - { - return INTERACTION_DRAG; - } - - if (type == "DropTarget") - { - return INTERACTION_DROP; - } - - if (type == "InputField") - { - return INTERACTION_TEXT; - } - - return INTERACTION_CLICK; - } - - internal static string CreateDisplayLabel(UIElementInfo element) - { - Debug.Assert(element != null, "UIElementInfo must not be null."); - - string interaction = element!.Interaction; - if (string.IsNullOrEmpty(interaction)) - { - interaction = GetInteractionForType(element.Type); - } - - if (string.IsNullOrEmpty(element.Label)) - { - return interaction.ToUpperInvariant(); - } - - return $"{element.Label}{DISPLAY_LABEL_SEPARATOR}{interaction.ToUpperInvariant()}"; - } - - private static float CalculateLuminance(Color color) - { - return color.r * 0.299f + color.g * 0.587f + color.b * 0.114f; - } - - private static int GetLabelIndex(string label) - { - if (string.IsNullOrEmpty(label)) - { - return -1; - } - - int index = 0; - for (int i = 0; i < label.Length; i++) - { - char labelCharacter = label[i]; - if (labelCharacter < 'A' || labelCharacter > 'Z') - { - return -1; - } - - index = index * 26 + labelCharacter - 'A' + 1; - } - - return index - 1; + return UIElementAnnotationRenderer.CalculateBorderEdgeRects(minX, minY, maxX, maxY, thickness); } internal readonly struct BorderEdgeRects @@ -827,41 +405,6 @@ public AnnotationBorderColors(Color inner, Color middle, Color outer) } } - private readonly struct AnnotationDrawInfo - { - public readonly float ScreenMinX; - public readonly float ScreenMinY; - public readonly float ScreenMaxX; - public readonly float ScreenMaxY; - public readonly Color Color; - public readonly Color ContrastColor; - public readonly AnnotationBorderColors BorderColors; - public readonly string DisplayLabel; - public readonly List OutlineSegments; - - public AnnotationDrawInfo( - float screenMinX, - float screenMinY, - float screenMaxX, - float screenMaxY, - Color color, - Color contrastColor, - AnnotationBorderColors borderColors, - string displayLabel, - List outlineSegments) - { - ScreenMinX = screenMinX; - ScreenMinY = screenMinY; - ScreenMaxX = screenMaxX; - ScreenMaxY = screenMaxY; - Color = color; - ContrastColor = contrastColor; - BorderColors = borderColors; - DisplayLabel = displayLabel; - OutlineSegments = outlineSegments; - } - } - internal readonly struct AnnotationBorderMetrics { public readonly float NeutralThickness;