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;