Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,474 changes: 2,474 additions & 0 deletions Assets/Scenes/RaycastAnnotationDemoScene.unity

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Assets/Scenes/RaycastAnnotationDemoScene.unity.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

158 changes: 144 additions & 14 deletions Assets/Tests/Editor/RaycastGridAnnotatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,37 @@ public void SelectReachableRepresentativeSample_WhenAllSamplesAreOccluded_Should
}

[Test]
public void CreatePhysicsColliderElement_ShouldKeepBoundsInTopLeftInputSpace()
public void CreateReachableCluster_WhenSamplesAreOccluded_ShouldExcludeOccludedSamples()
{
RaycastClusterSample reachableLeftSample = CreateSample(1, 0f, 0f);
RaycastClusterSample reachableRightSample = CreateSample(1, 10f, 0f);
RaycastClusterSample occludedSample = CreateSample(1, 100f, 100f);
List<RaycastClusterSample> samples = new List<RaycastClusterSample>
{
reachableLeftSample,
reachableRightSample,
occludedSample
};
HashSet<RaycastClusterSample> occludedSamples = new HashSet<RaycastClusterSample>
{
occludedSample
};

RaycastClusterInfo reachableCluster = RaycastHitClusterer.CreateReachableCluster(
samples,
(RaycastClusterSample sample) => occludedSamples.Contains(sample));

Assert.That(reachableCluster, Is.Not.Null);
Assert.That(reachableCluster.SampleCount, Is.EqualTo(2));
Assert.That(reachableCluster.Samples, Is.EqualTo(new List<RaycastClusterSample>
{
reachableLeftSample,
reachableRightSample
}));
}

[Test]
public void CreatePhysicsColliderElement_ShouldUseSampleCellBoundsInTopLeftInputSpace()
{
RaycastClusterInfo cluster = new RaycastClusterInfo
{
Expand All @@ -229,30 +259,108 @@ public void CreatePhysicsColliderElement_ShouldKeepBoundsInTopLeftInputSpace()
InputX = 100f,
InputY = 200f
},
SampleCount = 3
};
RaycastColliderMetadata metadata = new RaycastColliderMetadata
{
Name = "Cube",
Path = "Cube",
Layer = "Default",
Components = new List<string> { "BoxCollider" }
SampleCount = 3,
Samples = new List<RaycastClusterSample>
{
CreateSample(1, 80f, 180f),
CreateSample(1, 100f, 200f),
CreateSample(1, 130f, 220f)
}
};
RaycastColliderMetadata metadata = CreateMetadata();
RaycastSampleCoverage coverage = CreateCoverage(5f, 10f, 0f, 0f, 200f, 300f);

UIElementInfo element = RaycastGridAnnotator.CreatePhysicsColliderElement("R1", cluster, metadata);
UIElementInfo element =
RaycastGridAnnotator.CreatePhysicsColliderElement("R1", cluster, metadata, coverage);

Assert.That(element.Type, Is.EqualTo("PhysicsCollider"));
Assert.That(element.Interaction, Is.EqualTo("Raycast"));
Assert.That(element.SimX, Is.EqualTo(100f));
Assert.That(element.SimY, Is.EqualTo(200f));
Assert.That(element.BoundsMinX, Is.EqualTo(91f));
Assert.That(element.BoundsMinY, Is.EqualTo(191f));
Assert.That(element.BoundsMaxX, Is.EqualTo(109f));
Assert.That(element.BoundsMaxY, Is.EqualTo(209f));
Assert.That(element.BoundsMinX, Is.EqualTo(75f));
Assert.That(element.BoundsMinY, Is.EqualTo(170f));
Assert.That(element.BoundsMaxX, Is.EqualTo(135f));
Assert.That(element.BoundsMaxY, Is.EqualTo(230f));
Assert.That(element.SimX, Is.InRange(element.BoundsMinX, element.BoundsMaxX));
Assert.That(element.SimY, Is.InRange(element.BoundsMinY, element.BoundsMaxY));
}

[Test]
public void CreatePhysicsColliderElement_WhenSamplesTouchViewportEdge_ShouldClampCellBounds()
{
RaycastClusterInfo cluster = new RaycastClusterInfo
{
Representative = CreateSample(1, 3f, 4f),
SampleCount = 1,
Samples = new List<RaycastClusterSample>
{
CreateSample(1, 3f, 4f)
}
};
RaycastSampleCoverage coverage = CreateCoverage(5f, 10f, 0f, 0f, 200f, 300f);

UIElementInfo element = RaycastGridAnnotator.CreatePhysicsColliderElement(
"R1",
cluster,
CreateMetadata(),
coverage);

Assert.That(element.BoundsMinX, Is.EqualTo(0f));
Assert.That(element.BoundsMinY, Is.EqualTo(0f));
Assert.That(element.BoundsMaxX, Is.EqualTo(8f));
Assert.That(element.BoundsMaxY, Is.EqualTo(14f));
}

[Test]
public void CreatePhysicsColliderElement_WhenSamplesFormLShape_ShouldUseAxisAlignedCellBoundingBox()
{
RaycastClusterInfo cluster = new RaycastClusterInfo
{
Representative = CreateSample(1, 0f, 0f),
SampleCount = 3,
Samples = new List<RaycastClusterSample>
{
CreateSample(1, 0f, 0f),
CreateSample(1, 10f, 0f),
CreateSample(1, 0f, 10f)
}
};
RaycastColliderMetadata metadata = CreateMetadata();
RaycastSampleCoverage coverage = CreateCoverage(5f, 5f, 0f, 0f, 100f, 100f);

UIElementInfo element =
RaycastGridAnnotator.CreatePhysicsColliderElement("R1", cluster, metadata, coverage);

Assert.That(element.BoundsMinX, Is.EqualTo(0f));
Assert.That(element.BoundsMinY, Is.EqualTo(0f));
Assert.That(element.BoundsMaxX, Is.EqualTo(15f));
Assert.That(element.BoundsMaxY, Is.EqualTo(15f));
Assert.That(element.SimX, Is.EqualTo(0f));
Assert.That(element.SimY, Is.EqualTo(0f));
}

[Test]
public void CreatePhysicsColliderElement_WhenClusterHasSingleSample_ShouldUseOneSampleCellBounds()
{
RaycastClusterSample sample = CreateSample(1, 100f, 200f);
RaycastClusterInfo cluster = new RaycastClusterInfo
{
Representative = sample,
SampleCount = 1,
Samples = new List<RaycastClusterSample> { sample }
};
RaycastColliderMetadata metadata = CreateMetadata();
RaycastSampleCoverage coverage = CreateCoverage(5f, 10f, 0f, 0f, 200f, 300f);

UIElementInfo element =
RaycastGridAnnotator.CreatePhysicsColliderElement("R1", cluster, metadata, coverage);

Assert.That(element.BoundsMinX, Is.EqualTo(95f));
Assert.That(element.BoundsMinY, Is.EqualTo(190f));
Assert.That(element.BoundsMaxX, Is.EqualTo(105f));
Assert.That(element.BoundsMaxY, Is.EqualTo(210f));
}

[Test]
public void IsUiOcclusionRaycastResult_WhenGraphicRaycasterHit_ShouldReturnTrue()
{
Expand Down Expand Up @@ -407,5 +515,27 @@ private static RaycastGridPointInfo CreateLayerHitPoint(
HitGameObjectPath = objectPath
};
}

private static RaycastColliderMetadata CreateMetadata()
{
return new RaycastColliderMetadata
{
Name = "Cube",
Path = "Cube",
Layer = "Default",
Components = new List<string> { "BoxCollider" }
};
}

private static RaycastSampleCoverage CreateCoverage(
float halfStepX,
float halfStepY,
float minX,
float minY,
float maxX,
float maxY)
{
return new RaycastSampleCoverage(halfStepX, halfStepY, minX, minY, maxX, maxY);
}
}
}
1 change: 0 additions & 1 deletion Assets/Tests/Editor/UIElementAnnotatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,6 @@ public void CreateAnnotationOverlay_WhenElementIsAnnotated_ShouldKeepLabelOutlin
}
}

[Test]
public void CreateAnnotationOverlay_WhenMultipleElementsAreAnnotated_ShouldDrawLabelsAboveAllBorders()
{
List<UIElementInfo> elements = new List<UIElementInfo>
Expand Down
12 changes: 11 additions & 1 deletion Packages/src/Editor/Api/McpTools/Screenshot/Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,15 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con
- Use `ScreenshotToInputFormula` before passing raw image pixels to mouse tools. `AnnotatedElements[].SimX/SimY` and `RaycastGridPoints[].InputX/InputY` are already mouse-input coordinates.
- To discover a useful physics layer, first run `--annotate-raycast-grid` without `--raycast-layer-mask`, inspect `RaycastLayerSummaries`, then rerun with `--raycast-layer-mask <Layer>`.
- Use `--raycast-layer-mask` with layer names from `find-game-objects` or project code when the game input code raycasts only specific layers. Layers hidden by Camera.main.cullingMask are not reported because they are not visible to the screenshot camera.
- Clustered `PhysicsCollider` entries avoid points where the frontmost EventSystem hit comes from a `GraphicRaycaster` UI element, including world-space Canvas UI. PhysicsRaycaster and other non-uGUI hits are not treated as UI occlusion. If the centroid-nearest sample is covered, the nearest uncovered sampled hit is used; if every sampled hit in the cluster is covered, that collider is omitted.
- Clustered `PhysicsCollider` entries avoid points where the frontmost EventSystem hit comes from a `GraphicRaycaster` UI element, including world-space Canvas UI. PhysicsRaycaster and other non-uGUI hits are not treated as UI occlusion. `BoundsMinX/Y` and `BoundsMaxX/Y` show the axis-aligned sampled-cell coverage bbox from reachable hits; use `SimX/SimY` for the actual click point. If every sampled hit in the cluster is covered, that collider is omitted.

### Raycast Annotation Demo Scene

Use `Assets/Scenes/RaycastAnnotationDemoScene.unity` as the maintained visual fixture for raycast annotation checks. Enter PlayMode, then run:

```bash
uloop screenshot --capture-mode rendering --annotate-raycast-grid true --raycast-layer-mask Default --resolution-scale 1
```

The scene contains a deterministic 4x4 set of `BoxCollider` tiles and a center `GraphicRaycaster` UI blocker. Verify relative behavior rather than fixed pixel coordinates: center tile frames should shrink around the blocker, frames should wrap the reachable sampled-cell bbox, and `SimX/SimY` should stay inside each frame.
- Do not use `window` captures as mouse-input coordinates because they include Unity Editor chrome.
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ Read this when using `uloop screenshot --capture-mode rendering --annotate-eleme
- `Interaction`: Derived interaction category (`Click`, `Drag`, `Drop`, `Text`) or `Raycast` for clustered physics collider entries. Use this to choose between `simulate-mouse-ui --action Click`, drag actions, or `simulate-mouse-input`/`raycast`.
- `Layer`: Physics layer name for `PhysicsCollider` entries. Empty for UI entries.
- `Components`: Collider and MonoBehaviour component type names from the hit GameObject for `PhysicsCollider` entries. Empty for UI entries.
- `SimX`, `SimY`: Center position in top-left Game View coordinates. Use these directly with `simulate-mouse-ui --x/--y`, `simulate-mouse-input --x/--y`, or `raycast --x/--y`.
- `BoundsMinX`, `BoundsMinY`, `BoundsMaxX`, `BoundsMaxY`: Bounding box in simulate-mouse coordinates
- `SimX`, `SimY`: Target click position in top-left Game View coordinates. For UI entries this is the element center; for `PhysicsCollider` entries this is a representative sampled hit. Use these directly with `simulate-mouse-ui --x/--y`, `simulate-mouse-input --x/--y`, or `raycast --x/--y`.
- `BoundsMinX`, `BoundsMinY`, `BoundsMaxX`, `BoundsMaxY`: Bounding box in simulate-mouse coordinates. For `PhysicsCollider` entries, this is the axis-aligned sampled-cell coverage box from reachable raycast hits, not a guarantee that every interior point is clickable.
- `SortingOrder`: Canvas sorting order. Higher values are in front.
- `SiblingIndex`: Transform sibling index under the element's direct parent. Do not use it as a reliable z-order signal across nested UI hierarchies.

Expand All @@ -40,11 +40,13 @@ input_y = image_y / resolutionScale + imageToInputOffsetY

When `ResolutionScale` is `1.0` and `ImageToInputOffsetY` is `0`, raw image pixel coordinates already match mouse-input coordinates. `AnnotatedElements[].SimX/SimY` and `RaycastGridPoints[].InputX/InputY` are always returned as mouse-input coordinates, so pass those values directly.

For `PhysicsCollider` entries, `SimX/SimY` is a real sampled raycast hit nearest to the cluster centroid. This avoids synthetic center points that may fall into empty space for L-shaped or ring-shaped collider coverage.
For `PhysicsCollider` entries, `SimX/SimY` is a real sampled raycast hit nearest to the reachable cluster centroid. This avoids synthetic center points that may fall into empty space for L-shaped or ring-shaped collider coverage. Always use `SimX/SimY` for clicking; use `BoundsMinX/Y` and `BoundsMaxX/Y` only as a sampled coverage guide.

`--raycast-layer-mask` filters by the requested physics layers and Camera.main.cullingMask. A layer that is requested but hidden from the active camera is treated as not visible and will not produce `PhysicsCollider` entries.

For clustered `PhysicsCollider` entries, points where the frontmost EventSystem hit comes from a `GraphicRaycaster` UI element are treated as covered by UI. This includes world-space Canvas UI. PhysicsRaycaster and other non-uGUI hits are not treated as UI occlusion. When the centroid-nearest sample is covered, the annotator promotes the nearest uncovered sampled hit; if every sampled hit in that collider cluster is covered, the collider is omitted from `AnnotatedElements`.
For clustered `PhysicsCollider` entries, points where the frontmost EventSystem hit comes from a `GraphicRaycaster` UI element are treated as covered by UI. This includes world-space Canvas UI. PhysicsRaycaster and other non-uGUI hits are not treated as UI occlusion. Bounds and `SimX/SimY` are both derived from the remaining reachable samples; if every sampled hit in that collider cluster is covered, the collider is omitted from `AnnotatedElements`.

`PhysicsCollider` bounds expand each reachable sample by half the dense raycast sampling step in X and Y, then clamp the result to the captured Game View area. This makes the frame approximate the covered raycast cells instead of shrinking to the sample centers. It may extend up to half a sample step past the visible collider edge, and it still does not guarantee that every interior point is clickable.

The mouse input tools convert internally to Unity Input System coordinates:

Expand Down
Loading
Loading