diff --git a/.agents/skills/uloop-raycast/SKILL.md b/.agents/skills/uloop-raycast/SKILL.md new file mode 100644 index 0000000000..e91412fa60 --- /dev/null +++ b/.agents/skills/uloop-raycast/SKILL.md @@ -0,0 +1,60 @@ +--- +name: uloop-raycast +toolName: raycast +description: "Raycast from Camera.main through a Game View coordinate. Use when you need to check what a screenshot coordinate would hit in 3D physics before clicking or long-pressing with simulate-mouse-ui." +--- + +# uloop raycast + +Check what a top-left Game View coordinate hits in 3D physics. + +## Usage + +```bash +uloop raycast --x --y [--layer-mask ] [--max-distance ] +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). | +| `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). | +| `--layer-mask` | number | Unity default raycast layers | Physics layer mask used by the raycast. | +| `--max-distance` | number | `1000` | Maximum raycast distance in world units. | + +## Coordinate System + +- `--x` / `--y` use the same top-left Game View input coordinates as `simulate-mouse-ui`. +- Do not flip Y in the caller. The tool converts internally: + +```text +unity_x = input_x +unity_y = gameViewHeight - input_y +``` + +## Examples + +```bash +# Check what is under a screenshot coordinate +uloop raycast --x 960 --y 540 + +# Check only specific layers +uloop raycast --x 960 --y 540 --layer-mask 1 +``` + +## Output + +Returns JSON with: +- `Success`: Whether the command completed +- `Message`: Status message +- `Hit`: Whether physics hit anything +- `HitGameObjectName` / `HitGameObjectPath`: Hit object identity when `Hit` is true +- `HitLayer` / `HitLayerName`: Hit object layer when `Hit` is true +- `Distance`, `HitPointX/Y/Z`, `HitNormalX/Y/Z`: Hit details when `Hit` is true +- `InputCoordinateSystem`, `UnityCoordinateSystem`, `GameViewWidth/Height`, `InputPositionX/Y`, `InjectedUnityPositionX/Y`, `CoordinateConversionFormula`: Coordinate conversion details + +## Notes + +- Requires an active `Camera.main`. +- Uses Unity Physics raycasts, not UI EventSystem raycasts. diff --git a/.claude/skills/uloop-raycast/SKILL.md b/.claude/skills/uloop-raycast/SKILL.md new file mode 100644 index 0000000000..e91412fa60 --- /dev/null +++ b/.claude/skills/uloop-raycast/SKILL.md @@ -0,0 +1,60 @@ +--- +name: uloop-raycast +toolName: raycast +description: "Raycast from Camera.main through a Game View coordinate. Use when you need to check what a screenshot coordinate would hit in 3D physics before clicking or long-pressing with simulate-mouse-ui." +--- + +# uloop raycast + +Check what a top-left Game View coordinate hits in 3D physics. + +## Usage + +```bash +uloop raycast --x --y [--layer-mask ] [--max-distance ] +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). | +| `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). | +| `--layer-mask` | number | Unity default raycast layers | Physics layer mask used by the raycast. | +| `--max-distance` | number | `1000` | Maximum raycast distance in world units. | + +## Coordinate System + +- `--x` / `--y` use the same top-left Game View input coordinates as `simulate-mouse-ui`. +- Do not flip Y in the caller. The tool converts internally: + +```text +unity_x = input_x +unity_y = gameViewHeight - input_y +``` + +## Examples + +```bash +# Check what is under a screenshot coordinate +uloop raycast --x 960 --y 540 + +# Check only specific layers +uloop raycast --x 960 --y 540 --layer-mask 1 +``` + +## Output + +Returns JSON with: +- `Success`: Whether the command completed +- `Message`: Status message +- `Hit`: Whether physics hit anything +- `HitGameObjectName` / `HitGameObjectPath`: Hit object identity when `Hit` is true +- `HitLayer` / `HitLayerName`: Hit object layer when `Hit` is true +- `Distance`, `HitPointX/Y/Z`, `HitNormalX/Y/Z`: Hit details when `Hit` is true +- `InputCoordinateSystem`, `UnityCoordinateSystem`, `GameViewWidth/Height`, `InputPositionX/Y`, `InjectedUnityPositionX/Y`, `CoordinateConversionFormula`: Coordinate conversion details + +## Notes + +- Requires an active `Camera.main`. +- Uses Unity Physics raycasts, not UI EventSystem raycasts. diff --git a/Assets/Tests/Editor/GameViewCoordinateUtilityTests.cs b/Assets/Tests/Editor/GameViewCoordinateUtilityTests.cs new file mode 100644 index 0000000000..50ab025d30 --- /dev/null +++ b/Assets/Tests/Editor/GameViewCoordinateUtilityTests.cs @@ -0,0 +1,54 @@ +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies Game View Coordinate Utility behavior. + /// + public class GameViewCoordinateUtilityTests + { + [Test] + public void ConvertInputToUnity_WhenInputIsNearTop_ShouldFlipYWithinGameView() + { + // Tests that a coordinate near the top-left origin flips to a high Unity Y within the Game View. + Vector2 gameViewSize = new Vector2(1920f, 1080f); + Vector2 inputPosition = new Vector2(0f, 100f); + + GameViewCoordinateConversion conversion = + GameViewCoordinateUtility.ConvertInputToUnity(inputPosition, gameViewSize); + + Assert.That(conversion.InputPosition, Is.EqualTo(inputPosition)); + Assert.That(conversion.InjectedUnityPosition, Is.EqualTo(new Vector2(0f, 980f))); + Assert.That(conversion.GameViewSize, Is.EqualTo(gameViewSize)); + } + + [Test] + public void ConvertInputToUnity_WhenInputIsAtCenter_ShouldKeepCenterY() + { + // Tests that a coordinate at the Game View center converts to the same center Y in Unity space. + Vector2 gameViewSize = new Vector2(1920f, 1080f); + Vector2 inputPosition = new Vector2(960f, 540f); + + GameViewCoordinateConversion conversion = + GameViewCoordinateUtility.ConvertInputToUnity(inputPosition, gameViewSize); + + Assert.That(conversion.InjectedUnityPosition, Is.EqualTo(new Vector2(960f, 540f))); + } + + [Test] + public void ConvertInputToUnity_WhenInputIsAtBottomRight_ShouldMapToBottomLeftOrigin() + { + // Tests that the top-left Game View bottom-right corner maps to Unity's bottom-left Y origin. + Vector2 gameViewSize = new Vector2(1920f, 1080f); + Vector2 inputPosition = new Vector2(1920f, 1080f); + + GameViewCoordinateConversion conversion = + GameViewCoordinateUtility.ConvertInputToUnity(inputPosition, gameViewSize); + + Assert.That(conversion.InjectedUnityPosition, Is.EqualTo(new Vector2(1920f, 0f))); + } + } +} diff --git a/Assets/Tests/Editor/GameViewCoordinateUtilityTests.cs.meta b/Assets/Tests/Editor/GameViewCoordinateUtilityTests.cs.meta new file mode 100644 index 0000000000..83896f5bdc --- /dev/null +++ b/Assets/Tests/Editor/GameViewCoordinateUtilityTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f930d8a29f1a048218d7f090b530ed76 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/RaycastToolTests.cs b/Assets/Tests/Editor/RaycastToolTests.cs new file mode 100644 index 0000000000..ccc2290761 --- /dev/null +++ b/Assets/Tests/Editor/RaycastToolTests.cs @@ -0,0 +1,230 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies Raycast Tool behavior. + /// + public class RaycastToolTests + { + private GameObject? _cameraObject; + private GameObject? _cubeObject; + private bool _originalAutoSyncTransforms; + private readonly List _retaggedMainCameraObjects = new List(); + private readonly List _disabledAmbientColliders = new List(); + + [SetUp] + public void SetUp() + { + _originalAutoSyncTransforms = Physics.autoSyncTransforms; + } + + [TearDown] + public void TearDown() + { + Physics.autoSyncTransforms = _originalAutoSyncTransforms; + + if (_cubeObject != null) + { + Object.DestroyImmediate(_cubeObject); + } + + if (_cameraObject != null) + { + Object.DestroyImmediate(_cameraObject); + } + + foreach (GameObject retaggedObject in _retaggedMainCameraObjects) + { + if (retaggedObject != null) + { + retaggedObject.tag = "MainCamera"; + } + } + + foreach (Collider ambientCollider in _disabledAmbientColliders) + { + if (ambientCollider != null) + { + ambientCollider.enabled = true; + } + } + + _retaggedMainCameraObjects.Clear(); + _disabledAmbientColliders.Clear(); + _cubeObject = null; + _cameraObject = null; + } + + [Test] + public async Task ExecuteAsync_WhenCoordinateIntersectsCollider_ShouldReturnHitAndConversionMetadata() + { + // Tests that a Game View coordinate landing on a collider reports the hit and coordinate conversion metadata. + CreateRaycastScene(); + Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); + Vector2 inputPosition = new Vector2(gameViewSize.x / 2f, gameViewSize.y / 2f); + + RaycastResponse response = await ExecuteRaycast(inputPosition); + + Assert.That(response.Success, Is.True); + Assert.That(response.Hit, Is.True); + Assert.That(response.HitGameObjectName, Is.EqualTo("RaycastToolTestsCube")); + Assert.That(response.InputCoordinateSystem, Is.EqualTo(UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_GAME_VIEW)); + Assert.That(response.UnityCoordinateSystem, Is.EqualTo(UnityCliLoopConstants.COORDINATE_SYSTEM_BOTTOM_LEFT_GAME_VIEW)); + Assert.That(response.CoordinateConversionFormula, Is.EqualTo(UnityCliLoopConstants.COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY)); + Assert.That(response.InputPositionX, Is.EqualTo(inputPosition.x)); + Assert.That(response.InputPositionY, Is.EqualTo(inputPosition.y)); + Assert.That(response.InjectedUnityPositionX, Is.EqualTo(inputPosition.x)); + Assert.That(response.InjectedUnityPositionY, Is.EqualTo(gameViewSize.y - inputPosition.y)); + } + + [Test] + public async Task ExecuteAsync_WhenCoordinateMissesCollider_ShouldReturnNoHit() + { + // Tests that a Game View coordinate that misses every collider still succeeds with Hit=false. + CreateRaycastScene(); + Vector2 inputPosition = new Vector2(0f, 0f); + + RaycastResponse response = await ExecuteRaycast(inputPosition); + + Assert.That(response.Success, Is.True); + Assert.That(response.Hit, Is.False); + Assert.That(response.HitGameObjectName, Is.Null); + } + + [Test] + public async Task ExecuteAsync_WhenCameraIsMissing_ShouldReturnConversionMetadata() + { + // Tests that a missing Camera.main fails the raycast but still returns coordinate conversion metadata. + RetagExistingMainCameras(); + Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); + Vector2 inputPosition = new Vector2(gameViewSize.x / 2f, gameViewSize.y / 2f); + + RaycastResponse response = await ExecuteRaycast(inputPosition); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Does.Contain("Camera.main")); + Assert.That(response.InputPositionX, Is.EqualTo(inputPosition.x)); + Assert.That(response.InputPositionY, Is.EqualTo(inputPosition.y)); + Assert.That(response.InjectedUnityPositionX, Is.EqualTo(inputPosition.x)); + Assert.That(response.InjectedUnityPositionY, Is.EqualTo(gameViewSize.y - inputPosition.y)); + Assert.That(response.CoordinateConversionFormula, Is.EqualTo(UnityCliLoopConstants.COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY)); + } + + [Test] + public async Task ExecuteAsync_WhenColliderLayerIsHiddenByCamera_ShouldReturnNoHit() + { + // Tests that a collider on a layer excluded from the camera's culling mask is not hit. + CreateRaycastScene(); + if (_cameraObject == null) + { + Assert.Fail("Camera should be created."); + return; + } + + Camera camera = _cameraObject.GetComponent(); + camera.cullingMask = 0; + Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); + Vector2 inputPosition = new Vector2(gameViewSize.x / 2f, gameViewSize.y / 2f); + + RaycastResponse response = await ExecuteRaycast(inputPosition); + + Assert.That(response.Success, Is.True); + Assert.That(response.Hit, Is.False); + } + + [Test] + public async Task ExecuteAsync_WhenAutoSyncTransformsIsDisabled_ShouldRaycastAgainstLatestTransform() + { + // Tests that the raycast syncs physics transforms itself even when Physics.autoSyncTransforms is disabled. + Physics.autoSyncTransforms = false; + CreateRaycastScene(); + if (_cubeObject == null) + { + Assert.Fail("Cube should be created."); + return; + } + + _cubeObject.transform.position = new Vector3(100f, 0f, 0f); + Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); + Vector2 inputPosition = new Vector2(gameViewSize.x / 2f, gameViewSize.y / 2f); + + RaycastResponse response = await ExecuteRaycast(inputPosition); + + Assert.That(response.Success, Is.True); + Assert.That(response.Hit, Is.False); + } + + private void CreateRaycastScene() + { + RetagExistingMainCameras(); + DisableAmbientColliders(); + + _cameraObject = new GameObject("RaycastToolTestsCamera"); + Camera camera = _cameraObject.AddComponent(); + _cameraObject.tag = "MainCamera"; + _cameraObject.transform.position = new Vector3(0f, 0f, -10f); + _cameraObject.transform.rotation = Quaternion.identity; + camera.nearClipPlane = 0.1f; + camera.farClipPlane = 100f; + + _cubeObject = GameObject.CreatePrimitive(PrimitiveType.Cube); + _cubeObject.name = "RaycastToolTestsCube"; + _cubeObject.transform.position = Vector3.zero; + } + + // Other fixtures can leave colliders in the shared scene between test runs; + // this test asserts on the nearest raycast hit, so ambient colliders must not compete with our own. + private void DisableAmbientColliders() + { + Collider[] colliders = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach (Collider ambientCollider in colliders) + { + if (!ambientCollider.enabled) + { + continue; + } + + _disabledAmbientColliders.Add(ambientCollider); + ambientCollider.enabled = false; + } + } + + private void RetagExistingMainCameras() + { + Camera[] cameras = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach (Camera camera in cameras) + { + if (!camera.CompareTag("MainCamera")) + { + continue; + } + + _retaggedMainCameraObjects.Add(camera.gameObject); + camera.gameObject.tag = "Untagged"; + } + } + + private static async Task ExecuteRaycast(Vector2 inputPosition) + { + RaycastTool tool = new RaycastTool(); + JObject parameters = new JObject + { + ["x"] = inputPosition.x, + ["y"] = inputPosition.y + }; + + UnityCliLoopToolResponse baseResponse = await tool.ExecuteAsync(parameters, CancellationToken.None); + return (RaycastResponse)baseResponse; + } + } +} diff --git a/Assets/Tests/Editor/RaycastToolTests.cs.meta b/Assets/Tests/Editor/RaycastToolTests.cs.meta new file mode 100644 index 0000000000..3933e84021 --- /dev/null +++ b/Assets/Tests/Editor/RaycastToolTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9148efb9478af4b6d8c42eafe48c6194 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef index 813d809d51..6a4a4cff2b 100644 --- a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef @@ -37,7 +37,9 @@ "GUID:0acc523941302664db1f4e527237feb3", "GUID:27619889b8ba8c24980f49ee34dbb44a", "GUID:332048ead1ebf4658b9961338e5d68c3", - "GUID:33fef506e6744f2982e8c13b1196f696" + "GUID:33fef506e6744f2982e8c13b1196f696", + "GUID:da253b56f9d34380980fbe7bafe64666", + "GUID:96b8d4624fb74ea2849fed17e7ad69d3" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView.meta b/Packages/src/Editor/FirstPartyTools/Common/GameView.meta new file mode 100644 index 0000000000..3bfa74d3ec --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 097696231bf94ed0a495c0f187b1783e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs new file mode 100644 index 0000000000..395e0c5bd5 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Raycast.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Demo.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..4abf4909c9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67c24f916bc342ad95d52bfcb2cf8414 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewCoordinateUtility.cs b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewCoordinateUtility.cs new file mode 100644 index 0000000000..28cb464a3c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewCoordinateUtility.cs @@ -0,0 +1,53 @@ +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Converts public top-left Game View coordinates into Unity's bottom-left input coordinates. + /// + internal static class GameViewCoordinateUtility + { + internal static GameViewCoordinateConversion ConvertInputToUnity( + Vector2 inputPosition, + Vector2 gameViewSize) + { + Debug.Assert(gameViewSize.x >= 0f, "Game View width must not be negative."); + Debug.Assert(gameViewSize.y >= 0f, "Game View height must not be negative."); + + Vector2 injectedUnityPosition = new Vector2( + inputPosition.x, + gameViewSize.y - inputPosition.y); + + return new GameViewCoordinateConversion( + inputPosition, + injectedUnityPosition, + gameViewSize); + } + + internal static Vector2 GetMainGameViewSize() + { + return Handles.GetMainGameViewSize(); + } + } + + /// + /// Describes one conversion from screenshot-compatible Game View input to Unity input space. + /// + internal readonly struct GameViewCoordinateConversion + { + public readonly Vector2 InputPosition; + public readonly Vector2 InjectedUnityPosition; + public readonly Vector2 GameViewSize; + + public GameViewCoordinateConversion( + Vector2 inputPosition, + Vector2 injectedUnityPosition, + Vector2 gameViewSize) + { + InputPosition = inputPosition; + InjectedUnityPosition = injectedUnityPosition; + GameViewSize = gameViewSize; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewCoordinateUtility.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewCoordinateUtility.cs.meta new file mode 100644 index 0000000000..f0ba00c016 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewCoordinateUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2db1dc78fc9c4a73a94a69e9345a7e8a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewRaycastUtility.cs b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewRaycastUtility.cs new file mode 100644 index 0000000000..cec5d7a3b6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewRaycastUtility.cs @@ -0,0 +1,65 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Runs physics raycasts from screenshot-compatible Game View coordinates. + /// + internal static class GameViewRaycastUtility + { + internal static GameViewRaycastResult RaycastFromInputPosition( + Vector2 inputPosition, + float maxDistance, + int layerMask, + bool syncTransforms) + { + Vector2 gameViewSize = GameViewCoordinateUtility.GetMainGameViewSize(); + GameViewCoordinateConversion conversion = + GameViewCoordinateUtility.ConvertInputToUnity(inputPosition, gameViewSize); + + Camera mainCamera = Camera.main; + if (mainCamera == null) + { + return new GameViewRaycastResult(false, conversion, new RaycastHit[0]); + } + + Ray ray = mainCamera.ScreenPointToRay(conversion.InjectedUnityPosition); + if (syncTransforms) + { + // Transform changes can be visible before the physics scene updates, so sync before screenshot-based queries. + Physics.SyncTransforms(); + } + + int visibleLayerMask = layerMask & mainCamera.cullingMask; + RaycastHit[] hits = Physics.RaycastAll(ray, maxDistance, visibleLayerMask); + System.Array.Sort(hits, CompareHitsByDistance); + + return new GameViewRaycastResult(true, conversion, hits); + } + + private static int CompareHitsByDistance(RaycastHit left, RaycastHit right) + { + return left.distance.CompareTo(right.distance); + } + } + + /// + /// Contains the camera lookup result, coordinate conversion, and sorted physics hits. + /// + internal readonly struct GameViewRaycastResult + { + public readonly bool CameraFound; + public readonly GameViewCoordinateConversion Conversion; + public readonly RaycastHit[] Hits; + + public GameViewRaycastResult( + bool cameraFound, + GameViewCoordinateConversion conversion, + RaycastHit[] hits) + { + CameraFound = cameraFound; + Conversion = conversion; + Hits = hits; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewRaycastUtility.cs.meta b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewRaycastUtility.cs.meta new file mode 100644 index 0000000000..8ff5a05b08 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/GameViewRaycastUtility.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11c95a024755481b951337fa4634d138 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/UnityCLILoop.FirstPartyTools.Common.GameView.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Common/GameView/UnityCLILoop.FirstPartyTools.Common.GameView.Editor.asmdef new file mode 100644 index 0000000000..d1af9654df --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/UnityCLILoop.FirstPartyTools.Common.GameView.Editor.asmdef @@ -0,0 +1,16 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.Common.GameView.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/Common/GameView/UnityCLILoop.FirstPartyTools.Common.GameView.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/Common/GameView/UnityCLILoop.FirstPartyTools.Common.GameView.Editor.asmdef.meta new file mode 100644 index 0000000000..f1f93ac3bc --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Common/GameView/UnityCLILoop.FirstPartyTools.Common.GameView.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 96b8d4624fb74ea2849fed17e7ad69d3 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs index 3bfbc123a2..0ba14f5d0b 100644 --- a/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/Common/MouseUi/AssemblyInfo.cs @@ -5,6 +5,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.ReplayInput.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Screenshot.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.SimulateMouseUi.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Raycast.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Demo.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/Raycast.meta b/Packages/src/Editor/FirstPartyTools/Raycast.meta new file mode 100644 index 0000000000..e19cc018c2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e4ba70697b8a435299842e101330762d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs new file mode 100644 index 0000000000..0e911c2a5d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs @@ -0,0 +1,36 @@ +#nullable enable + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Carries the response data returned by the Raycast tool. + /// + public class RaycastResponse : UnityCliLoopToolResponse + { + public bool Success { get; set; } + public string Message { get; set; } = ""; + public bool Hit { get; set; } + public string? HitGameObjectName { get; set; } + public string? HitGameObjectPath { get; set; } + public int? HitLayer { get; set; } + public string? HitLayerName { get; set; } + public float? Distance { get; set; } + public float? HitPointX { get; set; } + public float? HitPointY { get; set; } + public float? HitPointZ { get; set; } + public float? HitNormalX { get; set; } + public float? HitNormalY { get; set; } + public float? HitNormalZ { get; set; } + public string InputCoordinateSystem { get; set; } = ""; + public string UnityCoordinateSystem { get; set; } = ""; + public float GameViewWidth { get; set; } + public float GameViewHeight { get; set; } + public float InputPositionX { get; set; } + public float InputPositionY { get; set; } + public float InjectedUnityPositionX { get; set; } + public float InjectedUnityPositionY { get; set; } + public string CoordinateConversionFormula { get; set; } = ""; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs.meta b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs.meta new file mode 100644 index 0000000000..7c9bb0826f --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastResponse.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f0600684d3e4a2f93767696480cd96c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastSchema.cs b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastSchema.cs new file mode 100644 index 0000000000..c80e64cb12 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastSchema.cs @@ -0,0 +1,17 @@ +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Describes the parameters accepted by the Raycast tool. + /// + public class RaycastSchema : UnityCliLoopToolSchema + { + public float X { get; set; } = 0f; + public float Y { get; set; } = 0f; + public int LayerMask { get; set; } = Physics.DefaultRaycastLayers; + public float MaxDistance { get; set; } = UnityCliLoopConstants.RAYCAST_DEFAULT_MAX_DISTANCE; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastSchema.cs.meta b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastSchema.cs.meta new file mode 100644 index 0000000000..cf7a6eb7c6 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastSchema.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9fdc798a81144645a346b11a2bff58b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastTool.cs b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastTool.cs new file mode 100644 index 0000000000..0228ef291e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastTool.cs @@ -0,0 +1,90 @@ +#nullable enable +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Checks what a screenshot-compatible Game View coordinate hits in 3D physics. + /// + [UnityCliLoopTool] + public class RaycastTool : UnityCliLoopTool + { + public override string ToolName => "raycast"; + + protected override Task ExecuteAsync(RaycastSchema parameters, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (parameters.MaxDistance <= 0f || float.IsNaN(parameters.MaxDistance) || float.IsInfinity(parameters.MaxDistance)) + { + return Task.FromResult(new RaycastResponse + { + Success = false, + Message = $"MaxDistance must be positive and finite, got: {parameters.MaxDistance}" + }); + } + + Vector2 inputPosition = new Vector2(parameters.X, parameters.Y); + GameViewRaycastResult raycastResult = GameViewRaycastUtility.RaycastFromInputPosition( + inputPosition, + parameters.MaxDistance, + parameters.LayerMask, + true); + + if (!raycastResult.CameraFound) + { + RaycastResponse noCameraResponse = CreateBaseResponse(raycastResult.Conversion); + noCameraResponse.Success = false; + noCameraResponse.Message = "Camera.main was not found. Add an active camera tagged MainCamera before using raycast."; + return Task.FromResult(noCameraResponse); + } + + if (raycastResult.Hits.Length == 0) + { + RaycastResponse noHitResponse = CreateBaseResponse(raycastResult.Conversion); + noHitResponse.Success = true; + noHitResponse.Hit = false; + noHitResponse.Message = $"No physics hit at ({inputPosition.x:F1}, {inputPosition.y:F1})."; + return Task.FromResult(noHitResponse); + } + + RaycastHit nearestHit = raycastResult.Hits[0]; + RaycastResponse response = CreateBaseResponse(raycastResult.Conversion); + response.Success = true; + response.Hit = true; + response.Message = $"Hit {nearestHit.collider.gameObject.name} at ({inputPosition.x:F1}, {inputPosition.y:F1})."; + response.HitGameObjectName = nearestHit.collider.gameObject.name; + response.HitGameObjectPath = GameObjectPathUtility.GetFullPath(nearestHit.collider.gameObject); + response.HitLayer = nearestHit.collider.gameObject.layer; + response.HitLayerName = LayerMask.LayerToName(nearestHit.collider.gameObject.layer); + response.Distance = nearestHit.distance; + response.HitPointX = nearestHit.point.x; + response.HitPointY = nearestHit.point.y; + response.HitPointZ = nearestHit.point.z; + response.HitNormalX = nearestHit.normal.x; + response.HitNormalY = nearestHit.normal.y; + response.HitNormalZ = nearestHit.normal.z; + return Task.FromResult(response); + } + + private static RaycastResponse CreateBaseResponse(GameViewCoordinateConversion conversion) + { + return new RaycastResponse + { + InputCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_GAME_VIEW, + UnityCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_BOTTOM_LEFT_GAME_VIEW, + GameViewWidth = conversion.GameViewSize.x, + GameViewHeight = conversion.GameViewSize.y, + InputPositionX = conversion.InputPosition.x, + InputPositionY = conversion.InputPosition.y, + InjectedUnityPositionX = conversion.InjectedUnityPosition.x, + InjectedUnityPositionY = conversion.InjectedUnityPosition.y, + CoordinateConversionFormula = UnityCliLoopConstants.COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY + }; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/RaycastTool.cs.meta b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastTool.cs.meta new file mode 100644 index 0000000000..d23ae047f0 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/RaycastTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dc1ff53deb6f4d17a51a94ac4594a12c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/Skill.meta b/Packages/src/Editor/FirstPartyTools/Raycast/Skill.meta new file mode 100644 index 0000000000..6c125b9d3d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/Skill.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d8fd7bb85c954ab4ba1fa745a838222e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Raycast/Skill/SKILL.md new file mode 100644 index 0000000000..e91412fa60 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/Skill/SKILL.md @@ -0,0 +1,60 @@ +--- +name: uloop-raycast +toolName: raycast +description: "Raycast from Camera.main through a Game View coordinate. Use when you need to check what a screenshot coordinate would hit in 3D physics before clicking or long-pressing with simulate-mouse-ui." +--- + +# uloop raycast + +Check what a top-left Game View coordinate hits in 3D physics. + +## Usage + +```bash +uloop raycast --x --y [--layer-mask ] [--max-distance ] +``` + +## Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `--x` | number | `0` | Target X position in Game View pixels (origin: top-left). | +| `--y` | number | `0` | Target Y position in Game View pixels (origin: top-left). | +| `--layer-mask` | number | Unity default raycast layers | Physics layer mask used by the raycast. | +| `--max-distance` | number | `1000` | Maximum raycast distance in world units. | + +## Coordinate System + +- `--x` / `--y` use the same top-left Game View input coordinates as `simulate-mouse-ui`. +- Do not flip Y in the caller. The tool converts internally: + +```text +unity_x = input_x +unity_y = gameViewHeight - input_y +``` + +## Examples + +```bash +# Check what is under a screenshot coordinate +uloop raycast --x 960 --y 540 + +# Check only specific layers +uloop raycast --x 960 --y 540 --layer-mask 1 +``` + +## Output + +Returns JSON with: +- `Success`: Whether the command completed +- `Message`: Status message +- `Hit`: Whether physics hit anything +- `HitGameObjectName` / `HitGameObjectPath`: Hit object identity when `Hit` is true +- `HitLayer` / `HitLayerName`: Hit object layer when `Hit` is true +- `Distance`, `HitPointX/Y/Z`, `HitNormalX/Y/Z`: Hit details when `Hit` is true +- `InputCoordinateSystem`, `UnityCoordinateSystem`, `GameViewWidth/Height`, `InputPositionX/Y`, `InjectedUnityPositionX/Y`, `CoordinateConversionFormula`: Coordinate conversion details + +## Notes + +- Requires an active `Camera.main`. +- Uses Unity Physics raycasts, not UI EventSystem raycasts. diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/Skill/SKILL.md.meta b/Packages/src/Editor/FirstPartyTools/Raycast/Skill/SKILL.md.meta new file mode 100644 index 0000000000..251f3b4df9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/Skill/SKILL.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b4a46559805b47939a5d013772afcc20 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/UnityCLILoop.FirstPartyTools.Raycast.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/Raycast/UnityCLILoop.FirstPartyTools.Raycast.Editor.asmdef new file mode 100644 index 0000000000..0f870d33c1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/UnityCLILoop.FirstPartyTools.Raycast.Editor.asmdef @@ -0,0 +1,20 @@ +{ + "name": "UnityCLILoop.FirstPartyTools.Raycast.Editor", + "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", + "references": [ + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:a2d87883023de4cf59b7d1962d5dd8aa", + "GUID:96b8d4624fb74ea2849fed17e7ad69d3" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Packages/src/Editor/FirstPartyTools/Raycast/UnityCLILoop.FirstPartyTools.Raycast.Editor.asmdef.meta b/Packages/src/Editor/FirstPartyTools/Raycast/UnityCLILoop.FirstPartyTools.Raycast.Editor.asmdef.meta new file mode 100644 index 0000000000..fd5401d6ad --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Raycast/UnityCLILoop.FirstPartyTools.Raycast.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: da253b56f9d34380980fbe7bafe64666 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index ff0eaff2ac..883429a028 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -86,6 +86,13 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string SCREENSHOTS_DIR = "Screenshots"; public const string VIBE_LOGS_DIR = "VibeLogs"; + // Raycast tool constants + public const float RAYCAST_DEFAULT_MAX_DISTANCE = 1000f; + public const string COORDINATE_SYSTEM_TOP_LEFT_GAME_VIEW = "top-left-game-view"; + public const string COORDINATE_SYSTEM_BOTTOM_LEFT_GAME_VIEW = "bottom-left-game-view"; + public const string COORDINATE_CONVERSION_FORMULA_GAME_VIEW_INPUT_TO_UNITY = + "unity_x = input_x; unity_y = gameViewHeight - input_y"; + public const int CORRELATION_ID_LENGTH = 8; public const string GUID_FORMAT_NO_HYPHENS = "N"; diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index ecb1060159..465c71567e 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -605,6 +605,35 @@ } } } + }, + { + "name": "raycast", + "description": "Raycast from Camera.main through a top-left Game View coordinate", + "inputSchema": { + "type": "object", + "properties": { + "X": { + "type": "number", + "description": "Target X position in Game View pixels (origin: top-left)", + "default": 0 + }, + "Y": { + "type": "number", + "description": "Target Y position in Game View pixels (origin: top-left)", + "default": 0 + }, + "LayerMask": { + "type": "integer", + "description": "Physics layer mask used by the raycast", + "default": -5 + }, + "MaxDistance": { + "type": "number", + "description": "Maximum raycast distance in world units", + "default": 1000 + } + } + } } ] }