diff --git a/.agents/skills/uloop-screenshot/SKILL.md b/.agents/skills/uloop-screenshot/SKILL.md index 23d3068c7..db17bef88 100644 --- a/.agents/skills/uloop-screenshot/SKILL.md +++ b/.agents/skills/uloop-screenshot/SKILL.md @@ -11,7 +11,7 @@ Take a screenshot of any Unity EditorWindow by name and save as PNG. ## Usage ```bash -uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] +uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] [--prefab-path ] [--prefab-background-color ] ``` ## Parameters @@ -27,14 +27,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--prefab-path` | string | `""` | Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched. | +| `--prefab-background-color` | string | `""` | Background for `--prefab-path` as `#RRGGBB` or `#RRGGBBAA`. Empty uses `#808080`. | ## Match Modes -| Mode | Description | Example | -|------|-------------|---------| -| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | -| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | -| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | +See [references/match-modes.md](references/match-modes.md) for exact / prefix / contains examples. ## Window Name @@ -51,14 +49,14 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured -- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` +- `ResolvedCaptureMode`: `"window"`, `"rendering"`, or `"prefab"` — the mode actually used after resolving `auto` - `Warning`: Appears for window captures taken while Play Mode is running with at least one image, and for any image captured while Play Mode is paused (UGUI/canvas content may show the frame rendered before the pause; run `uloop control-play-mode --action Step` once and capture again to refresh it). - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty (`""`) when `--elements-only` is used because no image file is written; `FileSizeBytes`, `Width`, and `Height` are then `0`, while `GameViewWidth`/`GameViewHeight` and `AnnotatedElements` still carry real values. When it is non-empty, always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. - `FileSizeBytes`: Size of the saved file in bytes - `Width`: Captured image width in pixels - `Height`: Captured image height in pixels - - `ImageCoordinateSystem`: `"top-left-game-view"` or `"top-left-window"` + - `ImageCoordinateSystem`: `"top-left-game-view"`, `"top-left-window"`, or `"top-left-prefab-image"` - `ResolutionScale`: Resolution scale used for capture - `ImageToInputOffsetY`: Y offset used for top-left-game-view coordinate conversion - `GameViewWidth`, `GameViewHeight`: Game View size in input coordinates at capture time — the basis for `SimX`/`SimY` and for the Y flip inside `simulate-mouse-input`. Populated for rendering captures, including `--elements-only` @@ -75,3 +73,4 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con - Use `uloop focus-window` first if needed - Target window must be open in Unity Editor - Window name matching is always case-insensitive +- Before using --prefab-path, read [references/prefab-capture.md](references/prefab-capture.md). diff --git a/.agents/skills/uloop-screenshot/references/match-modes.md b/.agents/skills/uloop-screenshot/references/match-modes.md new file mode 100644 index 000000000..f2bf44f88 --- /dev/null +++ b/.agents/skills/uloop-screenshot/references/match-modes.md @@ -0,0 +1,7 @@ +# Window Name Match Modes + +| Mode | Description | Example | +|------|-------------|---------| +| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | +| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | +| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md new file mode 100644 index 000000000..11f348b14 --- /dev/null +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -0,0 +1,42 @@ +# Prefab Capture (`--prefab-path`) + +`uloop screenshot --prefab-path ` instantiates the prefab or model asset in a temporary preview scene, renders it with a temporary camera, and returns one PNG. No window opens, and open scenes, Prefab Mode, and the asset itself are left untouched. It works in Edit Mode and Play Mode. + +```bash +uloop screenshot --prefab-path Assets/UI/Panel.prefab +uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color "#00000000" +``` + +## How the prefab is framed + +The first matching rule decides the image (the response `Message` says which one was used): + +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. The temporary Canvas that hosts the part sits on the built-in UI layer. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. The prefab's own Canvases keep their layers, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. A lone world-space Canvas is drawn on its own layer, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. + +Prefabs with visible renderers (rules 3 and 4) are drawn with a preview camera, the kind Inspector previews use, so they appear even in pipelines whose game cameras draw only certain layers. Preview cameras do not draw uGUI, so in a prefab that mixes renderers and a world-space Canvas, the Canvas does not appear. + +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. A render in which nothing appears (every pixel transparent or the background color) is rejected too; for a prefab that is a single flat color, pass a different `--prefab-background-color`. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. + +The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. + +## Options + +- `--window-name` and `--match-mode` are ignored. +- `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. +- `--resolution-scale` shrinks the image after it is rendered. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. When the render pipeline clears the capture camera to the background color, the drawn colors are kept and only made opaque. When it does not, the background is composited after rendering under unpainted and translucent pixels. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. +- `--prefab-background-color` without `--prefab-path` is rejected. + +## What happens to the instance + +- If the prefab has no enabled Light, one directional light is added to the preview scene. +- A root saved inactive is activated so it can be captured; children keep their saved state. +- Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. +- The prefab is rendered right after it is instantiated, so content that scripts assign later (asynchronous loads, Start, or Update) is missing, and post-processing such as bloom is not applied. + +## Output + +`ResolvedCaptureMode` is `"prefab"` and `ImageCoordinateSystem` is `"top-left-prefab-image"`. Prefab images are not Game View captures, so `ScreenshotToInputFormula` reports that mouse-input coordinates are unavailable. diff --git a/.claude/skills/uloop-screenshot/SKILL.md b/.claude/skills/uloop-screenshot/SKILL.md index 23d3068c7..db17bef88 100644 --- a/.claude/skills/uloop-screenshot/SKILL.md +++ b/.claude/skills/uloop-screenshot/SKILL.md @@ -11,7 +11,7 @@ Take a screenshot of any Unity EditorWindow by name and save as PNG. ## Usage ```bash -uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] +uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] [--prefab-path ] [--prefab-background-color ] ``` ## Parameters @@ -27,14 +27,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--prefab-path` | string | `""` | Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched. | +| `--prefab-background-color` | string | `""` | Background for `--prefab-path` as `#RRGGBB` or `#RRGGBBAA`. Empty uses `#808080`. | ## Match Modes -| Mode | Description | Example | -|------|-------------|---------| -| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | -| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | -| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | +See [references/match-modes.md](references/match-modes.md) for exact / prefix / contains examples. ## Window Name @@ -51,14 +49,14 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured -- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` +- `ResolvedCaptureMode`: `"window"`, `"rendering"`, or `"prefab"` — the mode actually used after resolving `auto` - `Warning`: Appears for window captures taken while Play Mode is running with at least one image, and for any image captured while Play Mode is paused (UGUI/canvas content may show the frame rendered before the pause; run `uloop control-play-mode --action Step` once and capture again to refresh it). - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty (`""`) when `--elements-only` is used because no image file is written; `FileSizeBytes`, `Width`, and `Height` are then `0`, while `GameViewWidth`/`GameViewHeight` and `AnnotatedElements` still carry real values. When it is non-empty, always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. - `FileSizeBytes`: Size of the saved file in bytes - `Width`: Captured image width in pixels - `Height`: Captured image height in pixels - - `ImageCoordinateSystem`: `"top-left-game-view"` or `"top-left-window"` + - `ImageCoordinateSystem`: `"top-left-game-view"`, `"top-left-window"`, or `"top-left-prefab-image"` - `ResolutionScale`: Resolution scale used for capture - `ImageToInputOffsetY`: Y offset used for top-left-game-view coordinate conversion - `GameViewWidth`, `GameViewHeight`: Game View size in input coordinates at capture time — the basis for `SimX`/`SimY` and for the Y flip inside `simulate-mouse-input`. Populated for rendering captures, including `--elements-only` @@ -75,3 +73,4 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con - Use `uloop focus-window` first if needed - Target window must be open in Unity Editor - Window name matching is always case-insensitive +- Before using --prefab-path, read [references/prefab-capture.md](references/prefab-capture.md). diff --git a/.claude/skills/uloop-screenshot/references/match-modes.md b/.claude/skills/uloop-screenshot/references/match-modes.md new file mode 100644 index 000000000..f2bf44f88 --- /dev/null +++ b/.claude/skills/uloop-screenshot/references/match-modes.md @@ -0,0 +1,7 @@ +# Window Name Match Modes + +| Mode | Description | Example | +|------|-------------|---------| +| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | +| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | +| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md new file mode 100644 index 000000000..11f348b14 --- /dev/null +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -0,0 +1,42 @@ +# Prefab Capture (`--prefab-path`) + +`uloop screenshot --prefab-path ` instantiates the prefab or model asset in a temporary preview scene, renders it with a temporary camera, and returns one PNG. No window opens, and open scenes, Prefab Mode, and the asset itself are left untouched. It works in Edit Mode and Play Mode. + +```bash +uloop screenshot --prefab-path Assets/UI/Panel.prefab +uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color "#00000000" +``` + +## How the prefab is framed + +The first matching rule decides the image (the response `Message` says which one was used): + +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. The temporary Canvas that hosts the part sits on the built-in UI layer. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. The prefab's own Canvases keep their layers, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. A lone world-space Canvas is drawn on its own layer, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. + +Prefabs with visible renderers (rules 3 and 4) are drawn with a preview camera, the kind Inspector previews use, so they appear even in pipelines whose game cameras draw only certain layers. Preview cameras do not draw uGUI, so in a prefab that mixes renderers and a world-space Canvas, the Canvas does not appear. + +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. A render in which nothing appears (every pixel transparent or the background color) is rejected too; for a prefab that is a single flat color, pass a different `--prefab-background-color`. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. + +The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. + +## Options + +- `--window-name` and `--match-mode` are ignored. +- `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. +- `--resolution-scale` shrinks the image after it is rendered. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. When the render pipeline clears the capture camera to the background color, the drawn colors are kept and only made opaque. When it does not, the background is composited after rendering under unpainted and translucent pixels. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. +- `--prefab-background-color` without `--prefab-path` is rejected. + +## What happens to the instance + +- If the prefab has no enabled Light, one directional light is added to the preview scene. +- A root saved inactive is activated so it can be captured; children keep their saved state. +- Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. +- The prefab is rendered right after it is instantiated, so content that scripts assign later (asynchronous loads, Start, or Update) is missing, and post-processing such as bloom is not applied. + +## Output + +`ResolvedCaptureMode` is `"prefab"` and `ImageCoordinateSystem` is `"top-left-prefab-image"`. Prefab images are not Game View captures, so `ScreenshotToInputFormula` reports that mouse-input coordinates are unavailable. diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs new file mode 100644 index 000000000..18565466e --- /dev/null +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -0,0 +1,576 @@ +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies how a prefab instance in a preview scene is classified, sized, and framed before rendering. + /// + public sealed class PrefabCaptureFramingTests + { + private static readonly Vector2Int GameViewSize = new(800, 600); + private const float Tolerance = 0.001f; + + private Scene _scene; + private Camera _camera; + + [SetUp] + public void SetUp() + { + _scene = EditorSceneManager.NewPreviewScene(); + GameObject cameraObject = CreateInScene("Camera"); + _camera = cameraObject.AddComponent(); + _camera.scene = _scene; + } + + [TearDown] + public void TearDown() + { + EditorSceneManager.ClosePreviewScene(_scene); + } + + /// + /// Verifies a UI part is sized to the union of all RectTransforms, including a child that overflows the root. + /// + [Test] + public void Arrange_WithAnOverflowingChild_SizesToTheUnionOfRectTransforms() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject _); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(140, 100))); + Assert.That(_camera.orthographic, Is.True); + Assert.That(_camera.orthographicSize, Is.EqualTo(50f).Within(Tolerance)); + Assert.That(_camera.transform.position.x, Is.EqualTo(-20f).Within(Tolerance)); + Assert.That(_camera.transform.position.y, Is.EqualTo(0f).Within(Tolerance)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + Assert.That(root.transform.parent.gameObject.layer, Is.EqualTo(LayerMask.NameToLayer("UI"))); + } + + /// + /// Verifies an inactive child does not widen the UI part. + /// + [Test] + public void Arrange_WithAnInactiveOverflowingChild_IgnoresTheChild() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject child); + child.SetActive(false); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + } + + /// + /// Verifies a stretched UI root fills the Game View size. + /// + [Test] + public void Arrange_WithAStretchedUiRoot_UsesTheGameViewSize() + { + GameObject root = CreateRectInScene("Root"); + RectTransform rect = (RectTransform)root.transform; + rect.anchorMin = Vector2.zero; + rect.anchorMax = Vector2.one; + rect.sizeDelta = Vector2.zero; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + } + + /// + /// Verifies a UI root with no area is rejected as having nothing to capture. + /// + [Test] + public void Arrange_WithAZeroSizedUiRoot_Throws() + { + GameObject root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = Vector2.zero; + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a root screen-space Canvas with a nested Canvas is captured as screen UI through the capture camera. + /// + [Test] + public void Arrange_WithAnOverlayCanvasRoot_SwitchesTheRootCanvasToTheCaptureCamera() + { + GameObject root = CreateInScene("Root"); + Canvas rootCanvas = root.AddComponent(); + rootCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + CreateChild(root, "Nested").AddComponent().overrideSorting = true; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + Assert.That(rootCanvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera)); + Assert.That(rootCanvas.worldCamera, Is.SameAs(_camera)); + } + + /// + /// Verifies a screen-space-camera Canvas under a plain Transform root renders through the capture camera. + /// + [Test] + public void Arrange_WithAScreenSpaceCameraCanvasChild_AssignsTheCaptureCamera() + { + GameObject root = CreateInScene("Root"); + Canvas canvas = CreateChild(root, "Canvas").AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceCamera; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + Assert.That(canvas.worldCamera, Is.SameAs(_camera)); + } + + /// + /// Verifies a RectTransform root with a world-space Canvas is treated as a UI part, not screen UI. + /// + [Test] + public void Arrange_WithAWorldSpaceCanvasRoot_TreatsItAsAUiPart() + { + GameObject root = CreateRectInScene("Root"); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + } + + /// + /// Verifies a 3D object is framed by a perspective camera that sees every corner of its bounds. + /// + [Test] + public void Arrange_WithACube_FramesAllBoundsCornersInPerspective() + { + GameObject root = CreateInScene("Root"); + Renderer cube = CreatePrimitiveChild(root, PrimitiveType.Cube); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); + Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + Assert.That(_camera.orthographic, Is.False); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Preview)); + AssertCornersInView(BoundsCorners(cube.bounds)); + } + + /// + /// Verifies a directional light is added when the 3D prefab has no light of its own. + /// + [Test] + public void Arrange_WithACubeAndNoLight_AddsADirectionalLight() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + int lightsBefore = CountLights(); + + PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(CountLights(), Is.EqualTo(lightsBefore + 1)); + Assert.That(CountDirectionalLightsOutside(root), Is.EqualTo(1)); + } + + /// + /// Verifies no light is added when the 3D prefab carries an enabled light. + /// + [Test] + public void Arrange_WithACubeAndAnEnabledLight_AddsNoLight() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + CreateChild(root, "Light").AddComponent(); + int lightsBefore = CountLights(); + + PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(CountLights(), Is.EqualTo(lightsBefore)); + } + + /// + /// Verifies a flat object is framed straight on by an unrotated orthographic camera. + /// + [Test] + public void Arrange_WithAQuad_FramesItStraightOn() + { + GameObject root = CreateInScene("Root"); + Renderer quad = CreatePrimitiveChild(root, PrimitiveType.Quad); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Assert.That(_camera.orthographic, Is.True); + Assert.That(_camera.transform.rotation, Is.EqualTo(Quaternion.identity)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Preview)); + AssertCornersInView(BoundsCorners(quad.bounds)); + } + + /// + /// Verifies a prefab with neither a Renderer nor a Canvas is rejected. + /// + [Test] + public void Arrange_WithNothingToRender_Throws() + { + GameObject root = CreateInScene("Root"); + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a prefab saved with an inactive root is activated so its content can be captured. + /// + [Test] + public void Arrange_WithAnInactiveRoot_ActivatesTheRoot() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + root.SetActive(false); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(root.activeSelf, Is.True); + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); + } + + /// + /// Verifies screen UI takes precedence over 3D content in the same prefab. + /// + [Test] + public void Arrange_WithAnOverlayCanvasAndACube_PrefersScreenUi() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Canvas").AddComponent().renderMode = RenderMode.ScreenSpaceOverlay; + CreatePrimitiveChild(root, PrimitiveType.Cube); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + } + + /// + /// Verifies a world-space Canvas under a plain Transform root is framed straight on with all four corners visible. + /// + [Test] + public void Arrange_WithOnlyAWorldSpaceCanvasChild_FramesItAsFlat() + { + GameObject root = CreateInScene("Root"); + GameObject canvasObject = CreateChild(root, "Canvas"); + Canvas canvas = canvasObject.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + RectTransform canvasRect = (RectTransform)canvasObject.transform; + canvasRect.sizeDelta = new Vector2(100f, 100f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + Vector3[] corners = new Vector3[4]; + canvasRect.GetWorldCorners(corners); + AssertCornersInView(corners); + } + + /// + /// Verifies a scaled UI root is sized in UI units rather than world units. + /// + [Test] + public void Arrange_WithAScaledWorldSpaceCanvasRoot_SizesInUiUnits() + { + GameObject root = CreateRectInScene("Root"); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + RectTransform rect = (RectTransform)root.transform; + rect.sizeDelta = new Vector2(100f, 100f); + rect.localScale = new Vector3(0.01f, 0.01f, 1f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + Assert.That(root.transform.parent.gameObject.layer, Is.EqualTo(LayerMask.NameToLayer("UI"))); + } + + /// + /// Verifies float noise from dividing by the root scale does not add a pixel to a scaled UI part. + /// + [Test] + public void Arrange_WithAScaledRootOfAnAwkwardWidth_DoesNotAddAPixel() + { + GameObject root = CreateRectInScene("Root"); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + RectTransform rect = (RectTransform)root.transform; + rect.sizeDelta = new Vector2(109f, 100f); + rect.localScale = new Vector3(0.01f, 0.01f, 1f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(109, 100))); + } + + /// + /// Verifies disabled screen-space Canvases do not make a prefab screen UI. + /// + [Test] + public void Arrange_WithDisabledOverlayCanvases_TreatsTheRectRootAsAUiPart() + { + GameObject root = CreateInScene("Root"); + Canvas rootCanvas = root.AddComponent(); + rootCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + Canvas nestedCanvas = CreateChild(root, "Nested").AddComponent(); + nestedCanvas.overrideSorting = true; + rootCanvas.enabled = false; + nestedCanvas.enabled = false; + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + } + + /// + /// Verifies a disabled Renderer is not counted, so a prefab with nothing else is rejected. + /// + [Test] + public void Arrange_WithOnlyADisabledRenderer_Throws() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube).enabled = false; + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies an overlay Canvas nested in a Canvas-less UI part does not turn the part into screen UI. + /// + [Test] + public void Arrange_WithAnOverlayCanvasInsideACanvaslessUiRoot_TreatsItAsAUiPart() + { + GameObject root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + GameObject child = new GameObject("Child", typeof(RectTransform)); + child.transform.SetParent(root.transform, false); + Canvas childCanvas = child.AddComponent(); + childCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + childCanvas.overrideSorting = true; + RectTransform childRect = (RectTransform)child.transform; + childRect.anchorMin = new Vector2(0.5f, 0.5f); + childRect.anchorMax = new Vector2(0.5f, 0.5f); + childRect.sizeDelta = new Vector2(40f, 20f); + childRect.anchoredPosition = new Vector2(-70f, 0f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(140, 100))); + } + + /// + /// Verifies a prefab with renderers and a world-space Canvas uses a preview camera and frames both. + /// + [Test] + public void Arrange_WithACubeAndAWorldSpaceCanvas_UsesAPreviewCameraAndFramesBoth() + { + GameObject root = CreateInScene("Root"); + Renderer cube = CreatePrimitiveChild(root, PrimitiveType.Cube); + RectTransform canvasRect = CreateWorldSpaceCanvasChild(root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Preview)); + AssertCornersInView(BoundsCorners(cube.bounds)); + Vector3[] corners = new Vector3[4]; + canvasRect.GetWorldCorners(corners); + AssertCornersInView(corners); + } + + /// + /// Verifies a renderer with empty bounds does not switch a world-space Canvas prefab to a preview camera, which would hide the Canvas. + /// + [Test] + public void Arrange_WithAnEmptyParticleSystemAndAWorldSpaceCanvas_KeepsAGameCamera() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Particles").AddComponent(); + CreateWorldSpaceCanvasChild(root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + } + + /// + /// Verifies a MeshRenderer without a mesh has empty bounds and is rejected instead of rendering a blank image. + /// + [Test] + public void Arrange_WithOnlyAMeshRendererWithoutAMesh_Throws() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Renderer").AddComponent(); + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a particle system that has not emitted has empty bounds and is rejected instead of rendering a blank image. + /// + [Test] + public void Arrange_WithOnlyAParticleSystem_Throws() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Particles").AddComponent(); + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a disabled light in the prefab does not count, so a directional light is still added. + /// + [Test] + public void Arrange_WithACubeAndADisabledLight_AddsADirectionalLight() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + CreateChild(root, "Light").AddComponent().enabled = false; + int lightsBefore = CountLights(); + + PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(CountLights(), Is.EqualTo(lightsBefore + 1)); + Assert.That(CountDirectionalLightsOutside(root), Is.EqualTo(1)); + } + + private GameObject CreateUiPartWithOverflowingChild(out GameObject child) + { + GameObject root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + child = new GameObject("Child", typeof(RectTransform)); + child.transform.SetParent(root.transform, false); + RectTransform childRect = (RectTransform)child.transform; + childRect.sizeDelta = new Vector2(40f, 20f); + childRect.anchoredPosition = new Vector2(-70f, 0f); + return root; + } + + private static RectTransform CreateWorldSpaceCanvasChild(GameObject parent) + { + GameObject canvasObject = CreateChild(parent, "Canvas"); + canvasObject.AddComponent().renderMode = RenderMode.WorldSpace; + RectTransform canvasRect = (RectTransform)canvasObject.transform; + canvasRect.sizeDelta = new Vector2(100f, 100f); + return canvasRect; + } + + private GameObject CreateInScene(string name) + { + GameObject gameObject = new GameObject(name); + SceneManager.MoveGameObjectToScene(gameObject, _scene); + return gameObject; + } + + private GameObject CreateRectInScene(string name) + { + GameObject gameObject = new GameObject(name, typeof(RectTransform)); + SceneManager.MoveGameObjectToScene(gameObject, _scene); + return gameObject; + } + + private static GameObject CreateChild(GameObject parent, string name) + { + GameObject child = new GameObject(name); + child.transform.SetParent(parent.transform, false); + return child; + } + + private Renderer CreatePrimitiveChild(GameObject parent, PrimitiveType type) + { + GameObject primitive = GameObject.CreatePrimitive(type); + SceneManager.MoveGameObjectToScene(primitive, _scene); + primitive.transform.SetParent(parent.transform, false); + return primitive.GetComponent(); + } + + private int CountLights() + { + int count = 0; + foreach (GameObject sceneRoot in _scene.GetRootGameObjects()) + { + count += sceneRoot.GetComponentsInChildren(true).Length; + } + + return count; + } + + private int CountDirectionalLightsOutside(GameObject prefabRoot) + { + int count = 0; + foreach (GameObject sceneRoot in _scene.GetRootGameObjects()) + { + if (sceneRoot == prefabRoot) + { + continue; + } + + foreach (Light light in sceneRoot.GetComponentsInChildren(true)) + { + if (light.type == LightType.Directional) + { + count++; + } + } + } + + return count; + } + + private static Vector3[] BoundsCorners(Bounds bounds) + { + Vector3[] corners = new Vector3[8]; + int index = 0; + for (int x = -1; x <= 1; x += 2) + { + for (int y = -1; y <= 1; y += 2) + { + for (int z = -1; z <= 1; z += 2) + { + corners[index++] = bounds.center + Vector3.Scale(bounds.extents, new Vector3(x, y, z)); + } + } + } + + return corners; + } + + private void AssertCornersInView(Vector3[] corners) + { + _camera.aspect = GameViewSize.x / (float)GameViewSize.y; + foreach (Vector3 corner in corners) + { + Vector3 viewport = _camera.WorldToViewportPoint(corner); + Assert.That(viewport.x, Is.InRange(0f, 1f), $"corner {corner} x"); + Assert.That(viewport.y, Is.InRange(0f, 1f), $"corner {corner} y"); + Assert.That(viewport.z, Is.GreaterThan(0f), $"corner {corner} z"); + } + } + } +} diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta new file mode 100644 index 000000000..7117ae84f --- /dev/null +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 591a88dba19594a27b95ccda0cdbc090 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabImagePixelsTests.cs b/Assets/Tests/Editor/PrefabImagePixelsTests.cs new file mode 100644 index 000000000..0d0fb2265 --- /dev/null +++ b/Assets/Tests/Editor/PrefabImagePixelsTests.cs @@ -0,0 +1,164 @@ +using System; +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies how a rendered prefab image is judged empty and how an opaque background is composited under it. + /// + public sealed class PrefabImagePixelsTests + { + private static readonly Color32 Gray = new(128, 128, 128, 255); + private static readonly Color32 Transparent = new(0, 0, 0, 0); + + /// + /// Verifies an image whose pixels are all fully transparent counts as nothing drawn. + /// + [Test] + public void HasDrawnPixel_WithOnlyTransparentPixels_ReturnsFalse() + { + Color32[] pixels = Filled(Transparent, 4); + + Assert.That(PrefabImagePixels.HasDrawnPixel(pixels, Gray), Is.False); + } + + /// + /// Verifies pixels within one step of the clear color count as background, not as drawn content. + /// + [Test] + public void HasDrawnPixel_WithOnlyTheClearColorWithinOneStep_ReturnsFalse() + { + Color32[] pixels = Filled(Gray, 4); + pixels[1] = new Color32(129, 128, 128, 255); + + Assert.That(PrefabImagePixels.HasDrawnPixel(pixels, Gray), Is.False); + } + + /// + /// Verifies one opaque pixel that differs from the clear color counts as drawn. + /// + [Test] + public void HasDrawnPixel_WithOneDrawnPixel_ReturnsTrue() + { + Color32[] pixels = Filled(Transparent, 4); + pixels[2] = new Color32(255, 0, 0, 255); + + Assert.That(PrefabImagePixels.HasDrawnPixel(pixels, Gray), Is.True); + } + + /// + /// Verifies a fully transparent pixel becomes the background color in gamma blending. + /// + [Test] + public void FillBackground_WithATransparentPixel_UsesTheBackground() + { + Color32[] pixels = { Transparent }; + + PrefabImagePixels.FillBackground(pixels, Gray, false); + + AssertColor32Near(pixels[0], Gray, 0); + } + + /// + /// Verifies an opaque pixel is left unchanged. + /// + [Test] + public void FillBackground_WithAnOpaquePixel_LeavesItUnchanged() + { + Color32 red = new(255, 0, 0, 255); + Color32[] pixels = { red }; + + PrefabImagePixels.FillBackground(pixels, Gray, false); + + AssertColor32Near(pixels[0], red, 0); + } + + /// + /// Verifies a premultiplied translucent pixel is composited over the background in gamma space. + /// + [Test] + public void FillBackground_WithATranslucentPixelInGamma_CompositesPremultipliedOver() + { + Color32[] pixels = { new Color32(100, 50, 25, 128) }; + + PrefabImagePixels.FillBackground(pixels, Gray, false); + + AssertColor32Near(pixels[0], new Color32(164, 114, 89, 255), 0); + } + + /// + /// Verifies linear blending reproduces what the GPU draws for translucent white over opaque gray in a Linear project. + /// + [Test] + public void FillBackground_WithATranslucentPixelInLinear_MatchesTheGpuComposite() + { + Color32[] pixels = { new Color32(188, 188, 188, 128) }; + + PrefabImagePixels.FillBackground(pixels, Gray, true); + + AssertColor32Near(pixels[0], new Color32(205, 205, 205, 255), 1); + } + + /// + /// Verifies a fully transparent pixel becomes the background color in linear blending. + /// + [Test] + public void FillBackground_WithATransparentPixelInLinear_UsesTheBackground() + { + Color32[] pixels = { Transparent }; + + PrefabImagePixels.FillBackground(pixels, Gray, true); + + AssertColor32Near(pixels[0], Gray, 1); + } + + /// + /// Verifies a background that is not fully opaque is refused, since compositing it would double a pipeline-drawn background. + /// + [Test] + public void FillBackground_WithATranslucentBackground_Throws() + { + Color32[] pixels = { Transparent }; + + Assert.Throws( + () => PrefabImagePixels.FillBackground(pixels, new Color32(128, 128, 128, 254), false)); + } + + /// + /// Verifies MakeOpaque raises alpha to 255 without changing color, and leaves an opaque pixel unchanged. + /// + [Test] + public void MakeOpaque_KeepsColorAndSetsAlphaTo255() + { + Color32 red = new(255, 0, 0, 255); + Color32[] pixels = { new Color32(192, 192, 192, 191), red }; + + PrefabImagePixels.MakeOpaque(pixels); + + AssertColor32Near(pixels[0], new Color32(192, 192, 192, 255), 0); + AssertColor32Near(pixels[1], red, 0); + } + + private static Color32[] Filled(Color32 color, int count) + { + Color32[] pixels = new Color32[count]; + for (int i = 0; i < count; i++) + { + pixels[i] = color; + } + + return pixels; + } + + private static void AssertColor32Near(Color32 actual, Color32 expected, int tolerance) + { + Assert.That(actual.r, Is.InRange(expected.r - tolerance, expected.r + tolerance), "r"); + Assert.That(actual.g, Is.InRange(expected.g - tolerance, expected.g + tolerance), "g"); + Assert.That(actual.b, Is.InRange(expected.b - tolerance, expected.b + tolerance), "b"); + Assert.That(actual.a, Is.InRange(expected.a - tolerance, expected.a + tolerance), "a"); + } + } +} diff --git a/Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta b/Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta new file mode 100644 index 000000000..42591f765 --- /dev/null +++ b/Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51b3e8d42821b4ac0ad7f4d6bcde2545 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabPreviewRendererTests.cs b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs new file mode 100644 index 000000000..71e64a5aa --- /dev/null +++ b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs @@ -0,0 +1,124 @@ +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the off-screen prefab renderer returns a texture of the requested size and restores the render state it borrowed. + /// + public sealed class PrefabPreviewRendererTests + { + /// + /// Verifies Render returns a texture of the requested size and restores the active RenderTexture and the camera target. + /// + [Test] + public void Render_ReturnsTheRequestedSizeAndRestoresRenderState() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Assert.Ignore("Rendering needs a graphics device; skipped on the Null device."); + } + + Scene scene = EditorSceneManager.NewPreviewScene(); + RenderTexture callerTarget = RenderTexture.GetTemporary(8, 8); + RenderTexture previousActive = RenderTexture.active; + Texture2D texture = null; + try + { + GameObject cameraObject = new("Camera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.scene = scene; + RenderTexture.active = callerTarget; + + texture = new PrefabPreviewRenderer().Render(camera, new Vector2Int(64, 32)); + + Assert.That(texture.width, Is.EqualTo(64)); + Assert.That(texture.height, Is.EqualTo(32)); + Assert.That(RenderTexture.active, Is.SameAs(callerTarget)); + Assert.That(camera.targetTexture, Is.Null); + } + finally + { + RenderTexture.active = previousActive; + RenderTexture.ReleaseTemporary(callerTarget); + if (texture != null) + { + Object.DestroyImmediate(texture); + } + + EditorSceneManager.ClosePreviewScene(scene); + } + } + + /// + /// Verifies a camera cleared to an opaque solid color is reported as cleared by the pipeline. + /// + [Test] + public void ClearsBackground_WithACameraClearedToAnOpaqueColor_ReturnsTrue() + { + IgnoreWithoutARealGpu(); + Scene scene = EditorSceneManager.NewPreviewScene(); + try + { + Camera camera = CreateSolidColorCamera(scene); + + bool clears = new PrefabPreviewRenderer().ClearsBackground(camera, new Vector2Int(16, 16)); + + Assert.That(clears, Is.True); + } + finally + { + EditorSceneManager.ClosePreviewScene(scene); + } + } + + /// + /// Verifies the probe render leaves the camera's culling mask as it found it, so the real render still draws the prefab. + /// + [Test] + public void ClearsBackground_RestoresTheCullingMask() + { + IgnoreWithoutARealGpu(); + Scene scene = EditorSceneManager.NewPreviewScene(); + try + { + Camera camera = CreateSolidColorCamera(scene); + camera.cullingMask = 1 << 5; + + new PrefabPreviewRenderer().ClearsBackground(camera, new Vector2Int(16, 16)); + + Assert.That(camera.cullingMask, Is.EqualTo(1 << 5)); + } + finally + { + EditorSceneManager.ClosePreviewScene(scene); + } + } + + private static void IgnoreWithoutARealGpu() + { + GraphicsDeviceType device = SystemInfo.graphicsDeviceType; + if (device == GraphicsDeviceType.Null || device == GraphicsDeviceType.OpenGLCore) + { + Assert.Ignore("Reading rendered pixels needs a real GPU; skipped on the Null and OpenGLCore devices."); + } + } + + private static Camera CreateSolidColorCamera(Scene scene) + { + GameObject cameraObject = new("Camera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.scene = scene; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.5f, 0.5f, 0.5f, 1f); + return camera; + } + } +} diff --git a/Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta new file mode 100644 index 000000000..39de4d965 --- /dev/null +++ b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb1dc409a59c24c22af9f08c86332c8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures.meta b/Assets/Tests/Editor/PrefabScreenshotFixtures.meta new file mode 100644 index 000000000..cc151a017 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8c07776f5510d409d8d251d29287c770 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab new file mode 100644 index 000000000..ee699c3ca --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab @@ -0,0 +1,33 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1189744403743977954 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 9152590177948676354} + m_Layer: 0 + m_Name: EmptyRoot + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &9152590177948676354 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1189744403743977954} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta new file mode 100644 index 000000000..2a457f5ab --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c761a60ffb70d467a93f08687034d0b3 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab new file mode 100644 index 000000000..d9a094b86 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab @@ -0,0 +1,153 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &488878777344455649 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8046216622164244746} + - component: {fileID: 6718508094473520953} + - component: {fileID: 2818173992894152336} + m_Layer: 0 + m_Name: UiPartWithOverflowingChild + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &8046216622164244746 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 488878777344455649} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 4081199270652062503} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 100, y: 100} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &6718508094473520953 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 488878777344455649} + m_CullTransparentMesh: 1 +--- !u!114 &2818173992894152336 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 488878777344455649} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &849394645349412386 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4081199270652062503} + - component: {fileID: 6268043233289662047} + - component: {fileID: 6281631284369086990} + m_Layer: 0 + m_Name: Badge + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &4081199270652062503 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 849394645349412386} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8046216622164244746} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: -70, y: 0} + m_SizeDelta: {x: 40, y: 20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &6268043233289662047 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 849394645349412386} + m_CullTransparentMesh: 1 +--- !u!114 &6281631284369086990 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 849394645349412386} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 0, b: 0, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta new file mode 100644 index 000000000..e605d8110 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1e33132b3f9c8449ca2dbfbf839cca4a +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs new file mode 100644 index 000000000..272edc3b1 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs @@ -0,0 +1,526 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the prefab screenshot use case: its response, its output file, and that it leaves no preview scene or texture behind. + /// + public sealed class PrefabScreenshotUseCaseTests + { + private const string FixtureFolder = "Assets/Tests/Editor/PrefabScreenshotFixtures"; + private const string UiPartPath = FixtureFolder + "/UiPartWithOverflowingChild.prefab"; + private const string EmptyRootPath = FixtureFolder + "/EmptyRoot.prefab"; + private const string MissingPath = FixtureFolder + "/Missing.prefab"; + private const string NonGameObjectPath = "Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs"; + + private string _outputDirectory; + + [SetUp] + public void SetUp() + { + _outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_outputDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_outputDirectory)) + { + Directory.Delete(_outputDirectory, true); + } + } + + /// + /// Verifies a UI part prefab produces one PNG with prefab metadata and cleans up its scene and texture. + /// + [Test] + public void CaptureAsync_WithAUiPartPrefab_ReturnsOnePrefabScreenshotAndCleansUp() + { + RecordingRenderer renderer = new(); + int previewScenesBefore = EditorSceneManager.previewSceneCount; + + ScreenshotResponse response = Capture(renderer, CreateRequest(UiPartPath)); + + Assert.That(response.Success, Is.True); + Assert.That(response.ResolvedCaptureMode, Is.EqualTo("prefab")); + Assert.That(response.Message, Does.Contain("UI part")); + Assert.That(response.Screenshots.Count, Is.EqualTo(1)); + ScreenshotInfo info = response.Screenshots[0]; + Assert.That(info.Width, Is.EqualTo(140)); + Assert.That(info.Height, Is.EqualTo(100)); + Assert.That(File.Exists(info.ImagePath), Is.True); + Assert.That(Path.GetFileName(info.ImagePath), Does.StartWith("Prefab_UiPartWithOverflowingChild_")); + Assert.That(info.ImageCoordinateSystem, Is.EqualTo("top-left-prefab-image")); + Assert.That(info.ScreenshotToInputFormula, Is.EqualTo(UnityCliLoopConstants.SCREENSHOT_PREFAB_TO_INPUT_FORMULA_UNAVAILABLE)); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + Assert.That(renderer.LastReturnedTexture == null, Is.True, "the rendered texture must be destroyed"); + Assert.That(renderer.CameraSceneWasPreview, Is.True); + } + + /// + /// Verifies ResolutionScale shrinks the rendered image after rendering. + /// + [Test] + public void CaptureAsync_WithHalfResolutionScale_HalvesTheImage() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Assert.Ignore("Resolution scaling blits on the GPU; skipped on the Null device."); + } + + ScreenshotSchema request = CreateRequest(UiPartPath); + request.ResolutionScale = 0.5f; + + ScreenshotResponse response = Capture(new RecordingRenderer(), request); + + Assert.That(response.Screenshots[0].Width, Is.EqualTo(70)); + Assert.That(response.Screenshots[0].Height, Is.EqualTo(50)); + } + + /// + /// Verifies WindowName is ignored for a prefab capture. + /// + [Test] + public void CaptureAsync_WithAWindowName_IgnoresIt() + { + ScreenshotSchema request = CreateRequest(UiPartPath); + request.WindowName = "Scene"; + + ScreenshotResponse response = Capture(new RecordingRenderer(), request); + + Assert.That(response.Success, Is.True); + } + + /// + /// Verifies the camera clears to the default #808080 background when no color is given. + /// + [Test] + public void CaptureAsync_WithoutABackgroundColor_ClearsToTheDefaultGray() + { + RecordingRenderer renderer = new(); + + Capture(renderer, CreateRequest(UiPartPath)); + + Assert.That(renderer.CameraClearFlags, Is.EqualTo(CameraClearFlags.SolidColor)); + AssertColor(new Color(0x80 / 255f, 0x80 / 255f, 0x80 / 255f, 1f), renderer.CameraBackgroundColor); + } + + /// + /// Verifies the camera clears to the requested background color including alpha. + /// + [Test] + public void CaptureAsync_WithABackgroundColor_ClearsToThatColor() + { + RecordingRenderer renderer = new(); + ScreenshotSchema request = CreateRequest(UiPartPath); + request.PrefabBackgroundColor = "#33669980"; + + Capture(renderer, request); + + Assert.That(renderer.CameraClearFlags, Is.EqualTo(CameraClearFlags.SolidColor)); + AssertColor(new Color(0x33 / 255f, 0x66 / 255f, 0x99 / 255f, 0x80 / 255f), renderer.CameraBackgroundColor); + } + + /// + /// Verifies a malformed background color is rejected before any scene is created or file written. + /// + [Test] + public void CaptureAsync_WithAMalformedBackgroundColor_RejectsWithoutSideEffects() + { + ScreenshotSchema request = CreateRequest(UiPartPath); + request.PrefabBackgroundColor = "#FFF"; + + AssertRejectedWithoutSideEffects(request, CancellationToken.None); + } + + /// + /// Verifies a cancelled token stops the capture before any scene is created or file written. + /// + [Test] + public void CaptureAsync_WithACancelledToken_ThrowsWithoutSideEffects() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + AssertRejectedWithoutSideEffects(CreateRequest(UiPartPath), cancellation.Token); + } + + /// + /// Verifies a path with no asset is rejected without rendering or writing a file. + /// + [Test] + public void CaptureAsync_WithAMissingAsset_RejectsWithoutSideEffects() + { + AssertRejectedWithoutSideEffects(CreateRequest(MissingPath), CancellationToken.None); + } + + /// + /// Verifies a path to an asset that is not a GameObject is rejected without rendering or writing a file. + /// + [Test] + public void CaptureAsync_WithANonGameObjectAsset_RejectsWithoutSideEffects() + { + AssertRejectedWithoutSideEffects(CreateRequest(NonGameObjectPath), CancellationToken.None); + } + + /// + /// Verifies a prefab with nothing to render is rejected and its preview scene is closed. + /// + [Test] + public void CaptureAsync_WithAnEmptyPrefab_RejectsAndClosesThePreviewScene() + { + AssertRejectedWithoutSideEffects(CreateRequest(EmptyRootPath), CancellationToken.None); + } + + /// + /// Verifies a rendering failure propagates unchanged and leaves no file or preview scene behind. + /// + [Test] + public void CaptureAsync_WhenRenderingFails_PropagatesAndCleansUp() + { + RecordingRenderer renderer = new() { ThrowOnRender = true }; + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws(() => useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None)); + + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + } + + /// + /// Verifies an image with nothing drawn is rejected, and no file, texture, or preview scene is left behind. + /// + [Test] + public void CaptureAsync_WhenNothingIsDrawn_RejectsWithoutWritingAFile() + { + RecordingRenderer renderer = new() { ReturnBlank = true }; + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws( + () => useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None)); + + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + Assert.That(renderer.LastReturnedTexture == null, Is.True, "the rendered texture must be destroyed"); + } + + /// + /// Verifies an image showing only the camera's background color is rejected as nothing drawn. + /// + [Test] + public void CaptureAsync_WhenOnlyTheBackgroundIsDrawn_RejectsWithoutWritingAFile() + { + RecordingRenderer renderer = new() { FillWithCameraBackground = true }; + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws( + () => useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None)); + + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + } + + /// + /// Verifies pixels the pipeline left transparent are filled with the default opaque gray while drawn pixels stay. + /// + [Test] + public void CaptureAsync_WithTheDefaultBackground_FillsUndrawnPixelsWithGray() + { + ScreenshotResponse response = Capture(new RecordingRenderer(), CreateRequest(UiPartPath)); + + Texture2D image = LoadSavedImage(response); + try + { + AssertColor32Near(image.GetPixel(0, 0), new Color32(128, 128, 128, 255), 1); + AssertColor32Near(image.GetPixel(image.width / 2, image.height / 2), new Color32(255, 0, 0, 255), 0); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies a transparent background is not composited, so undrawn pixels stay transparent. + /// + [Test] + public void CaptureAsync_WithATransparentBackground_KeepsUndrawnPixelsTransparent() + { + ScreenshotSchema request = CreateRequest(UiPartPath); + request.PrefabBackgroundColor = "#00000000"; + + RecordingRenderer renderer = new(); + + ScreenshotResponse response = Capture(renderer, request); + + Texture2D image = LoadSavedImage(response); + try + { + Assert.That(((Color32)image.GetPixel(0, 0)).a, Is.EqualTo(0)); + Assert.That(renderer.ClearsBackgroundCount, Is.EqualTo(0)); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies that when the pipeline does not clear, a translucent pixel is composited over the opaque background in linear space. + /// + [Test] + public void CaptureAsync_WhenThePipelineDoesNotClear_CompositesInLinearSpace() + { + Assume.That(QualitySettings.activeColorSpace, Is.EqualTo(ColorSpace.Linear)); + RecordingRenderer renderer = new() { ExtraPixel = new Color32(188, 188, 188, 128) }; + + ScreenshotResponse response = Capture(renderer, CreateRequest(UiPartPath)); + + Texture2D image = LoadSavedImage(response); + try + { + AssertColor32Near(image.GetPixel(1, 1), new Color32(205, 205, 205, 255), 1); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies that when the pipeline clears to the background, drawn colors are kept as they are and only made opaque. + /// + [Test] + public void CaptureAsync_WhenThePipelineClears_KeepsTheDrawnColorAndMakesItOpaque() + { + RecordingRenderer renderer = new() + { + ClearsBackgroundResult = true, + ExtraPixel = new Color32(192, 192, 192, 191) + }; + + ScreenshotResponse response = Capture(renderer, CreateRequest(UiPartPath)); + + Texture2D image = LoadSavedImage(response); + try + { + AssertColor32Near(image.GetPixel(1, 1), new Color32(192, 192, 192, 255), 0); + AssertColor32Near(image.GetPixel(image.width / 2, image.height / 2), new Color32(255, 0, 0, 255), 0); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies capturing does not modify the prefab asset. + /// + [Test] + public void CaptureAsync_WithAUiPartPrefab_LeavesTheAssetUnchanged() + { + Capture(new RecordingRenderer(), CreateRequest(UiPartPath)); + + GameObject asset = AssetDatabase.LoadAssetAtPath(UiPartPath); + Assert.That(EditorUtility.IsDirty(asset), Is.False); + Assert.That(asset.GetComponent().rect.size, Is.EqualTo(new Vector2(100f, 100f))); + } + + /// + /// Verifies the real renderer produces a PNG sized to the whole UI part on any device with a GPU path. + /// + [Test] + public void CaptureAsync_WithTheRealRenderer_WritesAnImageSizedToTheUiPart() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Assert.Ignore("Rendering needs a graphics device; skipped on the Null device."); + } + + Texture2D image = CaptureWithTheRealRenderer(); + try + { + Assert.That(image.width, Is.EqualTo(140)); + Assert.That(image.height, Is.EqualTo(100)); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies the real renderer draws the panel, the overflowing badge, and the gray background into the PNG. + /// + [Test] + public void CaptureAsync_WithTheRealRenderer_DrawsTheWholeUiPart() + { + GraphicsDeviceType deviceType = SystemInfo.graphicsDeviceType; + if (deviceType == GraphicsDeviceType.Null || deviceType == GraphicsDeviceType.OpenGLCore) + { + Assert.Ignore($"Pixel values need a real GPU with matching sRGB writes; skipped on {deviceType}."); + } + + Texture2D image = CaptureWithTheRealRenderer(); + try + { + AssertColor32Near(image.GetPixel(2, 2), new Color32(128, 128, 128, 255), 1); + AssertColor32Near(image.GetPixel(90, 50), new Color32(255, 255, 255, 255), 1); + AssertColor32Near(image.GetPixel(20, 50), new Color32(255, 0, 0, 255), 1); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + private Texture2D CaptureWithTheRealRenderer() + { + PrefabScreenshotUseCase useCase = new(null, () => new Vector2(800f, 600f)); + ScreenshotResponse response = useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None).Result; + return LoadSavedImage(response); + } + + private static Texture2D LoadSavedImage(ScreenshotResponse response) + { + Texture2D image = new(2, 2); + if (!image.LoadImage(File.ReadAllBytes(response.Screenshots[0].ImagePath))) + { + UnityEngine.Object.DestroyImmediate(image); + Assert.Fail("The saved PNG could not be decoded."); + } + + return image; + } + + private void AssertRejectedWithoutSideEffects(ScreenshotSchema request, CancellationToken ct) + where TException : Exception + { + RecordingRenderer renderer = new(); + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws(() => useCase.CaptureAsync(request, ct)); + + Assert.That(renderer.RenderCount, Is.EqualTo(0)); + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + } + + private ScreenshotResponse Capture(RecordingRenderer renderer, ScreenshotSchema request) + { + Task task = CreateUseCase(renderer).CaptureAsync(request, CancellationToken.None); + Assert.That(task.IsCompleted, Is.True); + return task.Result; + } + + private static PrefabScreenshotUseCase CreateUseCase(RecordingRenderer renderer) + { + return new PrefabScreenshotUseCase(renderer, () => new Vector2(800f, 600f)); + } + + private ScreenshotSchema CreateRequest(string prefabPath) + { + return new ScreenshotSchema { PrefabPath = prefabPath, OutputDirectory = _outputDirectory }; + } + + private static void AssertColor(Color expected, Color actual) + { + const float tolerance = 0.5f / 255f; + Assert.That(actual.r, Is.EqualTo(expected.r).Within(tolerance)); + Assert.That(actual.g, Is.EqualTo(expected.g).Within(tolerance)); + Assert.That(actual.b, Is.EqualTo(expected.b).Within(tolerance)); + Assert.That(actual.a, Is.EqualTo(expected.a).Within(tolerance)); + } + + private static void AssertColor32Near(Color32 actual, Color32 expected, int tolerance) + { + Assert.That(actual.r, Is.InRange(expected.r - tolerance, expected.r + tolerance), "r"); + Assert.That(actual.g, Is.InRange(expected.g - tolerance, expected.g + tolerance), "g"); + Assert.That(actual.b, Is.InRange(expected.b - tolerance, expected.b + tolerance), "b"); + Assert.That(actual.a, Is.InRange(expected.a - tolerance, expected.a + tolerance), "a"); + } + + /// + /// Stands in for the GPU renderer and copies the camera settings it saw, because the camera is destroyed with the preview scene. + /// + private sealed class RecordingRenderer : IPrefabPreviewRenderer + { + public bool ThrowOnRender { get; set; } + public bool ReturnBlank { get; set; } + public bool FillWithCameraBackground { get; set; } + public bool ClearsBackgroundResult { get; set; } + public Color32? ExtraPixel { get; set; } + public int RenderCount { get; private set; } + public int ClearsBackgroundCount { get; private set; } + public Texture2D LastReturnedTexture { get; private set; } + public Color CameraBackgroundColor { get; private set; } + public CameraClearFlags CameraClearFlags { get; private set; } + public bool CameraSceneWasPreview { get; private set; } + + public Texture2D Render(Camera camera, Vector2Int size) + { + RenderCount++; + CameraBackgroundColor = camera.backgroundColor; + CameraClearFlags = camera.clearFlags; + Scene cameraScene = camera.scene; + CameraSceneWasPreview = EditorSceneManager.IsPreviewScene(cameraScene); + if (ThrowOnRender) + { + throw new InvalidOperationException("Simulated rendering failure"); + } + + LastReturnedTexture = new Texture2D(size.x, size.y, TextureFormat.RGBA32, false); + LastReturnedTexture.SetPixels32(CreatePixels(camera, size)); + LastReturnedTexture.Apply(); + return LastReturnedTexture; + } + + // A renderer that paints the camera background stands for a pipeline that clears, so it always reports true. + public bool ClearsBackground(Camera camera, Vector2Int size) + { + ClearsBackgroundCount++; + return ClearsBackgroundResult || FillWithCameraBackground; + } + + // Transparent everywhere except one opaque red pixel in the middle, unless a blank or background-only image is asked for. + private Color32[] CreatePixels(Camera camera, Vector2Int size) + { + Color32 fill = FillWithCameraBackground ? (Color32)camera.backgroundColor : new Color32(0, 0, 0, 0); + Color32[] pixels = new Color32[size.x * size.y]; + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = fill; + } + + if (!ReturnBlank && !FillWithCameraBackground) + { + pixels[size.y / 2 * size.x + size.x / 2] = new Color32(255, 0, 0, 255); + } + + if (ExtraPixel.HasValue) + { + pixels[1 * size.x + 1] = ExtraPixel.Value; + } + + return pixels; + } + } + } +} diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta new file mode 100644 index 000000000..42254257b --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 01fce0e9f9cd84db6b3c8622c69c686c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs new file mode 100644 index 000000000..3d792acd3 --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs @@ -0,0 +1,209 @@ +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies which screenshot parameter combinations are rejected for prefab captures before any asset is loaded. + /// + public sealed class ScreenshotParameterValidatorPrefabTests + { + private const string AnyPrefabPath = "Assets/Any.prefab"; + + /// + /// Verifies an explicit capture mode cannot be combined with a prefab path. + /// + [TestCase(CaptureMode.window, TestName = "ValidatePrefab_WithWindowCaptureMode_Throws")] + [TestCase(CaptureMode.rendering, TestName = "ValidatePrefab_WithRenderingCaptureMode_Throws")] + [TestCase(CaptureMode.GameView, TestName = "ValidatePrefab_WithGameViewCaptureMode_Throws")] + public void ValidatePrefab_WithAnExplicitCaptureMode_Throws(CaptureMode captureMode) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.CaptureMode = captureMode; + + AssertPrefabRejected(request, "CaptureMode"); + } + + /// + /// Verifies the default auto capture mode is accepted for a prefab path. + /// + [Test] + public void ValidatePrefab_WithAutoCaptureMode_Accepts() + { + ScreenshotSchema request = CreatePrefabRequest(); + + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + } + + /// + /// Verifies AnnotateElements cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithAnnotateElements_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.AnnotateElements = true; + + AssertPrefabRejected(request, "AnnotateElements"); + } + + /// + /// Verifies ElementsOnly cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithElementsOnly_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.ElementsOnly = true; + + AssertPrefabRejected(request, "ElementsOnly"); + } + + /// + /// Verifies AnnotateRaycastGrid cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithAnnotateRaycastGrid_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.AnnotateRaycastGrid = true; + + AssertPrefabRejected(request, "AnnotateRaycastGrid"); + } + + /// + /// Verifies RaycastLayerMask cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithRaycastLayerMask_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.RaycastLayerMask = "Default"; + + AssertPrefabRejected(request, "RaycastLayerMask"); + } + + /// + /// Verifies prefab resolution scales outside 0.1 to 1.0 are rejected and the bounds themselves are accepted. + /// + [TestCase(0.09f, false, TestName = "ValidatePrefab_WithAScaleBelowTheMinimum_Throws")] + [TestCase(0.1f, true, TestName = "ValidatePrefab_WithTheMinimumScale_Accepts")] + [TestCase(1.0f, true, TestName = "ValidatePrefab_WithTheMaximumScale_Accepts")] + [TestCase(1.01f, false, TestName = "ValidatePrefab_WithAScaleAboveTheMaximum_Throws")] + public void ValidatePrefab_ChecksTheResolutionScaleRange(float scale, bool accepted) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.ResolutionScale = scale; + + if (accepted) + { + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + return; + } + + AssertPrefabRejected(request, "ResolutionScale must be between 0.1 and 1.0"); + } + + /// + /// Verifies empty, #RRGGBB, and #RRGGBBAA background colors are accepted. + /// + [TestCase("", TestName = "ValidatePrefab_WithAnEmptyBackgroundColor_Accepts")] + [TestCase("#336699", TestName = "ValidatePrefab_WithAnRgbBackgroundColor_Accepts")] + [TestCase("#33669980", TestName = "ValidatePrefab_WithAnRgbaBackgroundColor_Accepts")] + [TestCase("#00000000", TestName = "ValidatePrefab_WithATransparentBackgroundColor_Accepts")] + public void ValidatePrefab_WithAWellFormedBackgroundColor_Accepts(string color) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.PrefabBackgroundColor = color; + + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + } + + /// + /// Verifies background colors outside the #RRGGBB / #RRGGBBAA forms are rejected, including color names. + /// + [TestCase("#FFF", TestName = "ValidatePrefab_WithAShortHexBackgroundColor_Throws")] + [TestCase("red", TestName = "ValidatePrefab_WithAColorNameBackgroundColor_Throws")] + [TestCase("#GG0000", TestName = "ValidatePrefab_WithANonHexBackgroundColor_Throws")] + [TestCase("336699", TestName = "ValidatePrefab_WithABackgroundColorMissingTheHash_Throws")] + public void ValidatePrefab_WithAMalformedBackgroundColor_Throws(string color) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.PrefabBackgroundColor = color; + + AssertPrefabRejected(request, "PrefabBackgroundColor"); + } + + /// + /// Verifies WindowName and MatchMode are ignored for a prefab capture. + /// + [Test] + public void ValidatePrefab_WithAWindowNameAndMatchMode_Accepts() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.WindowName = "Scene"; + request.MatchMode = WindowMatchMode.contains; + + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + } + + /// + /// Verifies a background color without a prefab path is rejected on the non-prefab path. + /// + [Test] + public void Validate_WithABackgroundColorAndNoPrefabPath_Throws() + { + ScreenshotSchema request = new ScreenshotSchema { PrefabBackgroundColor = "#336699" }; + + UnityCliLoopToolParameterValidationException exception = + Assert.Throws(() => ScreenshotParameterValidator.Validate(request)); + Assert.That(exception.Message, Does.Contain("PrefabBackgroundColor requires PrefabPath")); + } + + /// + /// Verifies an empty background color resolves to the documented #808080 default. + /// + [Test] + public void Resolve_WithAnEmptyValue_ReturnsTheDefaultGray() + { + Color color = PrefabBackgroundColorParser.Resolve(""); + + AssertColor(new Color(0x80 / 255f, 0x80 / 255f, 0x80 / 255f, 1f), color); + } + + /// + /// Verifies a #RRGGBBAA value resolves to its channels including alpha. + /// + [Test] + public void Resolve_WithAnRgbaValue_ReturnsItsChannels() + { + Color color = PrefabBackgroundColorParser.Resolve("#33669980"); + + AssertColor(new Color(0x33 / 255f, 0x66 / 255f, 0x99 / 255f, 0x80 / 255f), color); + } + + private static ScreenshotSchema CreatePrefabRequest() + { + return new ScreenshotSchema { PrefabPath = AnyPrefabPath }; + } + + private static void AssertPrefabRejected(ScreenshotSchema request, string expectedMessagePart) + { + UnityCliLoopToolParameterValidationException exception = + Assert.Throws(() => ScreenshotParameterValidator.ValidatePrefab(request)); + Assert.That(exception.Message, Does.Contain(expectedMessagePart)); + } + + private static void AssertColor(Color expected, Color actual) + { + const float tolerance = 0.5f / 255f; + Assert.That(actual.r, Is.EqualTo(expected.r).Within(tolerance)); + Assert.That(actual.g, Is.EqualTo(expected.g).Within(tolerance)); + Assert.That(actual.b, Is.EqualTo(expected.b).Within(tolerance)); + Assert.That(actual.a, Is.EqualTo(expected.a).Within(tolerance)); + } + } +} diff --git a/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta new file mode 100644 index 000000000..7210292cc --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9cdd3aa8dffb44b20a50b53805e066be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs new file mode 100644 index 000000000..c112e975d --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the screenshot tool sends requests with a prefab path to the prefab capture. + /// + public sealed class ScreenshotToolPrefabRoutingTests + { + /// + /// Verifies a request with PrefabPath reaches the prefab capture, which rejects a missing asset before rendering. + /// + [Test] + public void ExecuteAsync_WithAPrefabPath_RoutesToThePrefabCapture() + { + ScreenshotTool tool = new(); + + Task task = tool.ExecuteAsync( + JObject.Parse("{\"PrefabPath\":\"Assets/Nope.prefab\"}"), + CancellationToken.None); + + Assert.That(task.IsFaulted, Is.True); + Assert.That(task.Exception.InnerException, Is.TypeOf()); + Assert.That(task.Exception.InnerException.Message, Does.Contain("PrefabPath does not point to a prefab or model asset")); + } + } +} diff --git a/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta new file mode 100644 index 000000000..4f08970bf --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8be7934a411fb472c8c7956a57b6c64b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs index a903bc132..0988d51c1 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs @@ -358,7 +358,7 @@ internal static int CalculateImageToInputOffsetY(Vector2 gameViewSize, int rende return Mathf.RoundToInt(gameViewSize.y) - renderTextureHeight; } - private static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float scale) + internal static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float scale) { int newWidth = Mathf.RoundToInt(originalTexture.width * scale); int newHeight = Mathf.RoundToInt(originalTexture.height * scale); diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs new file mode 100644 index 000000000..504f0e435 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.RegularExpressions; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Checks and converts the background color accepted by prefab screenshots. + /// + internal static class PrefabBackgroundColorParser + { + // Accepts only #RRGGBB or #RRGGBBAA so the documented form is the whole contract; + // ColorUtility alone would also accept color names such as "red". + private static readonly Regex HexColor = new("^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$"); + + internal static bool IsValid(string value) + { + return string.IsNullOrEmpty(value) || HexColor.IsMatch(value); + } + + /// + /// Returns the color for a value that already passed IsValid; empty means the default gray. + /// + internal static Color Resolve(string value) + { + string source = string.IsNullOrEmpty(value) + ? UnityCliLoopConstants.SCREENSHOT_PREFAB_DEFAULT_BACKGROUND_COLOR + : value; + if (!HexColor.IsMatch(source)) + { + throw new ArgumentException($"Background color must be validated before it is resolved, got: {value}", nameof(value)); + } + + ColorUtility.TryParseHtmlString(source, out Color color); + return color; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta new file mode 100644 index 000000000..42e8f15b9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36cd95f58c93843a8b96140e2742c0f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs new file mode 100644 index 000000000..b731d5e11 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// What a prefab screenshot is rendered as, which decides its image size and camera. + /// + internal enum PrefabCaptureKind + { + UiPart, + ScreenUi, + Object3D, + Flat2D + } + + /// + /// The classification and image size decided for one prefab capture. + /// + internal readonly struct PrefabCaptureFrame + { + public PrefabCaptureKind Kind { get; } + public Vector2Int ImageSize { get; } + + public PrefabCaptureFrame(PrefabCaptureKind kind, Vector2Int imageSize) + { + if (imageSize.x < 1 || imageSize.y < 1) + { + throw new ArgumentException($"Image size must be at least 1x1, got: {imageSize}", nameof(imageSize)); + } + + Kind = kind; + ImageSize = imageSize; + } + } + + /// + /// Classifies a prefab instance in a preview scene and points the capture camera at it. + /// + internal static class PrefabCaptureFraming + { + private const float WorldUiCameraDistance = 100f; + private const float ObjectFieldOfView = 30f; + private static readonly Quaternion ObjectViewRotation = Quaternion.Euler(20f, -30f, 0f); + private static readonly Quaternion AddedLightRotation = Quaternion.Euler(50f, -30f, 0f); + private const float ObjectFitMargin = 1.1f; + private const float FlatFitMargin = 1.05f; + private const float FlatDepthRatio = 0.001f; + private const float FlatCameraDistance = 10f; + private const float MinimumVisibleExtent = 1e-4f; + private const float PixelSnapTolerance = 0.01f; + + /// + /// Rearranges the instance and camera for rendering and returns what was decided; rejects prefabs with nothing to capture. + /// + internal static PrefabCaptureFrame Arrange(GameObject instanceRoot, Camera camera, Vector2Int gameViewSize) + { + if (instanceRoot == null) + { + throw new ArgumentNullException(nameof(instanceRoot)); + } + + if (camera == null) + { + throw new ArgumentNullException(nameof(camera)); + } + + if (gameViewSize.x < 1 || gameViewSize.y < 1) + { + throw new ArgumentException($"Game View size must be at least 1x1, got: {gameViewSize}", nameof(gameViewSize)); + } + + // A camera bound to another scene would render the user's scene instead of the instance. + if (camera.scene != instanceRoot.scene) + { + throw new ArgumentException("Camera must render the scene that holds the prefab instance", nameof(camera)); + } + + // A prefab saved inactive still has content worth capturing; children keep their saved state. + if (!instanceRoot.activeSelf) + { + instanceRoot.SetActive(true); + } + + // A UI part is placed under a Canvas in the game, so its own nested canvases must not be treated as screen UI. + if (instanceRoot.transform is RectTransform && !HasEnabledCanvas(instanceRoot)) + { + return ArrangeUiPart(instanceRoot, camera, gameViewSize); + } + + // Screen UI is checked before the remaining UI part case so a root with its own screen-space Canvas keeps its in-game layout. + List screenCanvases = CollectTopCanvases(instanceRoot, false); + if (screenCanvases.Count > 0) + { + return ArrangeScreenUi(screenCanvases, camera, gameViewSize); + } + + if (instanceRoot.transform is RectTransform) + { + return ArrangeUiPart(instanceRoot, camera, gameViewSize); + } + + return ArrangeObject(instanceRoot, camera, gameViewSize); + } + + private static bool HasEnabledCanvas(GameObject gameObject) + { + return gameObject.TryGetComponent(out Canvas canvas) && canvas.enabled; + } + + private static PrefabCaptureFrame ArrangeScreenUi(List screenCanvases, Camera camera, Vector2Int gameViewSize) + { + foreach (Canvas canvas in screenCanvases) + { + canvas.renderMode = RenderMode.ScreenSpaceCamera; + canvas.worldCamera = camera; + canvas.planeDistance = 1f; + } + + camera.orthographic = true; + camera.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + camera.nearClipPlane = 0.1f; + camera.farClipPlane = 10f; + return new PrefabCaptureFrame(PrefabCaptureKind.ScreenUi, gameViewSize); + } + + // Returns only outermost Canvases. Nested canvases follow their root canvas and Unity ignores + // renderMode/worldCamera assignments on them; skipping them keeps only the canvases that decide how the UI renders. + private static List CollectTopCanvases(GameObject root, bool includeWorldSpace) + { + List canvases = new(); + foreach (Canvas canvas in root.GetComponentsInChildren(false)) + { + if (!canvas.enabled) + { + continue; + } + + if (HasCanvasAncestorWithin(canvas.transform, root.transform)) + { + continue; + } + + bool isWorld = canvas.renderMode == RenderMode.WorldSpace; + if (isWorld != includeWorldSpace) + { + continue; + } + + canvases.Add(canvas); + } + + return canvases; + } + + private static bool HasCanvasAncestorWithin(Transform transform, Transform root) + { + if (transform == root) + { + return false; + } + + for (Transform parent = transform.parent; parent != null; parent = parent.parent) + { + if (parent.GetComponent() != null) + { + return true; + } + + if (parent == root) + { + break; + } + } + + return false; + } + + private static PrefabCaptureFrame ArrangeUiPart(GameObject root, Camera camera, Vector2Int gameViewSize) + { + GameObject wrapper = new("PrefabCaptureCanvas", typeof(RectTransform), typeof(Canvas)); + // Some pipelines draw game cameras only on certain layers; uGUI follows the outermost Canvas's layer. + wrapper.layer = UiLayer(); + SceneManager.MoveGameObjectToScene(wrapper, root.scene); + Canvas wrapperCanvas = wrapper.GetComponent(); + wrapperCanvas.renderMode = RenderMode.WorldSpace; + wrapperCanvas.worldCamera = camera; + RectTransform wrapperRect = (RectTransform)wrapper.transform; + // A stretched UI root spreads to the Game View size, as it would under a full-screen Canvas. + wrapperRect.sizeDelta = gameViewSize; + wrapperRect.position = Vector3.zero; + root.transform.SetParent(wrapper.transform, false); + // LayoutGroup and ContentSizeFitter sizes must settle before the rects are measured. + Canvas.ForceUpdateCanvases(); + + Rect union = UnionOfWorldRects(root); + Vector3 rootScale = root.transform.lossyScale; + float unitsPerPixelX = Mathf.Abs(rootScale.x); + float unitsPerPixelY = Mathf.Abs(rootScale.y); + // World corners include the root's scale, so pixels are counted in UI units (the size at scale 1). + // A 100x100 world-space Canvas saved at localScale 0.01 is 1x1 in the world but captured at 100x100 px. + if (unitsPerPixelX <= 0f || unitsPerPixelY <= 0f) + { + throw new UnityCliLoopToolParameterValidationException("Prefab has no visible UI area to capture"); + } + + Vector2 pixelSize = new(union.width / unitsPerPixelX, union.height / unitsPerPixelY); + if (pixelSize.x < 1f || pixelSize.y < 1f) + { + throw new UnityCliLoopToolParameterValidationException("Prefab has no visible UI area to capture"); + } + + camera.orthographic = true; + camera.orthographicSize = union.height / 2f; + camera.transform.SetPositionAndRotation( + new Vector3(union.center.x, union.center.y, -WorldUiCameraDistance), + Quaternion.identity); + camera.nearClipPlane = 0.1f; + camera.farClipPlane = WorldUiCameraDistance * 2f; + return new PrefabCaptureFrame( + PrefabCaptureKind.UiPart, + new Vector2Int(ToPixelCount(pixelSize.x), ToPixelCount(pixelSize.y))); + } + + // Dividing world corners by the root scale leaves float noise such as 109.0000076, which must not add a pixel. + private static int ToPixelCount(float uiUnits) + { + return Mathf.CeilToInt(uiUnits - PixelSnapTolerance); + } + + private static int UiLayer() + { + int layer = LayerMask.NameToLayer("UI"); + if (layer < 0) + { + // Continuing would return an image the pipeline never drew into. + throw new InvalidOperationException("The built-in UI layer is missing"); + } + + return layer; + } + + private static Rect UnionOfWorldRects(GameObject root) + { + Vector3[] corners = new Vector3[4]; + float minX = float.PositiveInfinity; + float minY = float.PositiveInfinity; + float maxX = float.NegativeInfinity; + float maxY = float.NegativeInfinity; + foreach (RectTransform rect in root.GetComponentsInChildren(false)) + { + rect.GetWorldCorners(corners); + foreach (Vector3 corner in corners) + { + minX = Mathf.Min(minX, corner.x); + minY = Mathf.Min(minY, corner.y); + maxX = Mathf.Max(maxX, corner.x); + maxY = Mathf.Max(maxY, corner.y); + } + } + + return Rect.MinMaxRect(minX, minY, maxX, maxY); + } + + private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, Vector2Int gameViewSize) + { + Bounds? rendererBounds = CollectRendererBounds(root); + Bounds? bounds = Union(rendererBounds, CollectWorldCanvasBounds(root)); + if (bounds == null) + { + throw new UnityCliLoopToolParameterValidationException("Prefab has no Renderer or UI to capture"); + } + + // Empty bounds would give a zero-sized orthographic camera and a blank image reported as success. + if (MaxExtent(bounds.Value) <= MinimumVisibleExtent) + { + throw new UnityCliLoopToolParameterValidationException( + "Prefab has nothing with a visible size to capture; renderer bounds are empty (for example, a particle system that has not emitted)"); + } + + // uGUI is not drawn by preview cameras, so only prefabs with visible renderers switch to one; + // every pipeline draws preview cameras plainly because Inspector previews depend on them. + if (rendererBounds != null && MaxExtent(rendererBounds.Value) > MinimumVisibleExtent) + { + camera.cameraType = CameraType.Preview; + } + + if (!HasEnabledLight(root)) + { + AddDirectionalLight(root.scene); + } + + float aspect = gameViewSize.x / (float)gameViewSize.y; + Bounds b = bounds.Value; + // Things without depth, such as sprites, are framed straight on so they are not flattened by an angled view. + if (b.size.z <= FlatDepthRatio * Mathf.Max(b.size.x, b.size.y)) + { + camera.orthographic = true; + camera.orthographicSize = Mathf.Max(b.size.y / 2f, b.size.x / (2f * aspect)) * FlatFitMargin; + camera.transform.SetPositionAndRotation(b.center - Vector3.forward * FlatCameraDistance, Quaternion.identity); + camera.nearClipPlane = 0.01f; + camera.farClipPlane = FlatCameraDistance * 2f; + return new PrefabCaptureFrame(PrefabCaptureKind.Flat2D, gameViewSize); + } + + float radius = b.extents.magnitude; + float vertical = ObjectFieldOfView * Mathf.Deg2Rad; + float horizontal = 2f * Mathf.Atan(Mathf.Tan(vertical / 2f) * aspect); + float distance = radius / Mathf.Sin(Mathf.Min(vertical, horizontal) / 2f) * ObjectFitMargin; + Vector3 direction = ObjectViewRotation * Vector3.forward; + camera.orthographic = false; + camera.fieldOfView = ObjectFieldOfView; + camera.transform.SetPositionAndRotation(b.center - direction * distance, Quaternion.LookRotation(direction)); + camera.nearClipPlane = Mathf.Max(0.01f, distance - radius * 2f); + camera.farClipPlane = distance + radius * 2f; + return new PrefabCaptureFrame(PrefabCaptureKind.Object3D, gameViewSize); + } + + private static Bounds? CollectRendererBounds(GameObject root) + { + Bounds? bounds = null; + foreach (Renderer renderer in root.GetComponentsInChildren(false)) + { + if (!renderer.enabled) + { + continue; + } + + bounds = Encapsulate(bounds, renderer.bounds); + } + + return bounds; + } + + private static Bounds? CollectWorldCanvasBounds(GameObject root) + { + Bounds? bounds = null; + Vector3[] corners = new Vector3[4]; + foreach (Canvas canvas in CollectTopCanvases(root, true)) + { + ((RectTransform)canvas.transform).GetWorldCorners(corners); + foreach (Vector3 corner in corners) + { + bounds = Encapsulate(bounds, new Bounds(corner, Vector3.zero)); + } + } + + return bounds; + } + + private static Bounds? Union(Bounds? first, Bounds? second) + { + if (second == null) + { + return first; + } + + return Encapsulate(first, second.Value); + } + + private static float MaxExtent(Bounds bounds) + { + Vector3 size = bounds.size; + return Mathf.Max(size.x, size.y, size.z); + } + + private static Bounds Encapsulate(Bounds? current, Bounds addition) + { + if (current == null) + { + return addition; + } + + Bounds result = current.Value; + result.Encapsulate(addition); + return result; + } + + private static bool HasEnabledLight(GameObject root) + { + foreach (Light light in root.GetComponentsInChildren(false)) + { + if (light.enabled) + { + return true; + } + } + + return false; + } + + private static void AddDirectionalLight(Scene scene) + { + GameObject lightObject = new("PrefabCaptureLight"); + SceneManager.MoveGameObjectToScene(lightObject, scene); + lightObject.transform.rotation = AddedLightRotation; + Light light = lightObject.AddComponent(); + light.type = LightType.Directional; + light.intensity = 1f; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta new file mode 100644 index 000000000..d7d1bb421 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c7af59dd5ac64babb7ae42f682ca096 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs new file mode 100644 index 000000000..a68a9fcf8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs @@ -0,0 +1,100 @@ +using System; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Pixel checks and background compositing applied to a rendered prefab image before it is saved. + /// + internal static class PrefabImagePixels + { + private const int ClearColorTolerance = 1; + + /// + /// Returns true when at least one visible pixel differs from the clear color by more than one step. + /// + internal static bool HasDrawnPixel(Color32[] pixels, Color32 clearColor) + { + foreach (Color32 pixel in pixels) + { + if (pixel.a == 0) + { + continue; + } + + if (DiffersFrom(pixel, clearColor)) + { + return true; + } + } + + return false; + } + + /// + /// Composites an opaque background under every pixel that is not fully opaque. + /// Pixels drawn over a transparent clear hold premultiplied color, so this is a premultiplied over; + /// linearBlending blends in linear space, matching how the GPU blends in a Linear project. + /// + internal static void FillBackground(Color32[] pixels, Color32 background, bool linearBlending) + { + if (background.a != 255) + { + throw new ArgumentException($"Background must be opaque, got alpha {background.a}", nameof(background)); + } + + for (int i = 0; i < pixels.Length; i++) + { + Color32 pixel = pixels[i]; + if (pixel.a == 255) + { + continue; + } + + float coverage = pixel.a / 255f; + pixels[i] = new Color32( + Over(pixel.r, background.r, coverage, linearBlending), + Over(pixel.g, background.g, coverage, linearBlending), + Over(pixel.b, background.b, coverage, linearBlending), + 255); + } + } + + /// + /// Sets every pixel fully opaque without changing its color; used when the pipeline already blended everything over the background. + /// + internal static void MakeOpaque(Color32[] pixels) + { + for (int i = 0; i < pixels.Length; i++) + { + pixels[i].a = 255; + } + } + + private static byte Over(byte source, byte background, float coverage, bool linearBlending) + { + float s = ToBlend(source / 255f, linearBlending); + float b = ToBlend(background / 255f, linearBlending); + float blended = Mathf.Min(1f, s + b * (1f - coverage)); + return (byte)Mathf.RoundToInt(FromBlend(blended, linearBlending) * 255f); + } + + private static float ToBlend(float value, bool linearBlending) + { + return linearBlending ? Mathf.GammaToLinearSpace(value) : value; + } + + private static float FromBlend(float value, bool linearBlending) + { + return linearBlending ? Mathf.LinearToGammaSpace(value) : value; + } + + private static bool DiffersFrom(Color32 pixel, Color32 clearColor) + { + return Mathf.Abs(pixel.r - clearColor.r) > ClearColorTolerance + || Mathf.Abs(pixel.g - clearColor.g) > ClearColorTolerance + || Mathf.Abs(pixel.b - clearColor.b) > ClearColorTolerance + || Mathf.Abs(pixel.a - clearColor.a) > ClearColorTolerance; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta new file mode 100644 index 000000000..191cb8d37 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76efeaf3ac4224927a760025838c9c57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs new file mode 100644 index 000000000..dd029e002 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs @@ -0,0 +1,90 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Renders a camera into a new texture of the given size; the caller owns and destroys the texture. + /// + internal interface IPrefabPreviewRenderer + { + Texture2D Render(Camera camera, Vector2Int size); + + /// + /// Renders the camera with nothing visible and returns true when the pipeline painted every pixel opaque, + /// which means it clears this camera to its background color. + /// + bool ClearsBackground(Camera camera, Vector2Int size); + } + + /// + /// Renders a preview-scene camera off screen through a temporary RenderTexture. + /// + internal sealed class PrefabPreviewRenderer : IPrefabPreviewRenderer + { + public Texture2D Render(Camera camera, Vector2Int size) + { + return RenderToTexture(camera, size); + } + + public bool ClearsBackground(Camera camera, Vector2Int size) + { + int savedMask = camera.cullingMask; + // A culling mask of 0 hides the prefab, so the probe shows only what the pipeline itself paints. + camera.cullingMask = 0; + Texture2D probe = null; + try + { + probe = RenderToTexture(camera, size); + foreach (Color32 pixel in probe.GetPixels32()) + { + if (pixel.a != 255) + { + return false; + } + } + + return true; + } + finally + { + // Restored so the real render that follows draws the prefab. + camera.cullingMask = savedMask; + if (probe != null) + { + Object.DestroyImmediate(probe); + } + } + } + + private static Texture2D RenderToTexture(Camera camera, Vector2Int size) + { + RenderTextureDescriptor descriptor = new(size.x, size.y, RenderTextureFormat.ARGB32, 24); + // Keeps PNG values equal to the requested sRGB colors in Linear projects; Gamma projects ignore it. + descriptor.sRGB = true; + RenderTexture target = new(descriptor); + RenderTexture previousActive = RenderTexture.active; + try + { + // A new RenderTexture holds undefined contents, so pixels the pipeline does not paint are made alpha 0 first. + RenderTexture.active = target; + GL.Clear(true, true, Color.clear); + camera.targetTexture = target; + // Screen-space-camera Canvases lay out against the camera's pixel size, so they update after the target is set. + Canvas.ForceUpdateCanvases(); + camera.Render(); + RenderTexture.active = target; + Texture2D texture = new(size.x, size.y, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, size.x, size.y), 0, 0); + texture.Apply(); + return texture; + } + finally + { + RenderTexture.active = previousActive; + camera.targetTexture = null; + target.Release(); + Object.DestroyImmediate(target); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta new file mode 100644 index 000000000..48a38472e --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7187fce72ca45421aa1b3671a06984e8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs new file mode 100644 index 000000000..a7013789d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Renders a prefab asset alone in a temporary preview scene and saves it as a PNG, + /// leaving open scenes, Prefab Mode, and the asset untouched. + /// + internal sealed class PrefabScreenshotUseCase + { + private readonly IPrefabPreviewRenderer _renderer; + private readonly Func _gameViewSizeReader; + + internal PrefabScreenshotUseCase(IPrefabPreviewRenderer renderer = null, Func gameViewSizeReader = null) + { + _renderer = renderer ?? new PrefabPreviewRenderer(); + _gameViewSizeReader = gameViewSizeReader ?? Handles.GetMainGameViewSize; + } + + /// + /// Completes synchronously; invalid input is rejected before a preview scene is created. + /// + internal Task CaptureAsync(ScreenshotSchema request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + ScreenshotParameterValidator.ValidatePrefab(request); + GameObject asset = AssetDatabase.LoadAssetAtPath(request.PrefabPath); + if (asset == null) + { + throw new UnityCliLoopToolParameterValidationException( + $"PrefabPath does not point to a prefab or model asset: {request.PrefabPath}"); + } + + Color background = PrefabBackgroundColorParser.Resolve(request.PrefabBackgroundColor); + Vector2 rawSize = _gameViewSizeReader(); + Vector2Int gameViewSize = new( + Mathf.Max(1, Mathf.RoundToInt(rawSize.x)), + Mathf.Max(1, Mathf.RoundToInt(rawSize.y))); + string correlationId = UnityCliLoopConstants.GenerateCorrelationId(); + VibeLogger.LogInfo( + "screenshot_prefab_start", + "Prefab screenshot started", + new { request.PrefabPath, GameViewWidth = gameViewSize.x, GameViewHeight = gameViewSize.y }, + correlationId: correlationId); + + Scene scene = EditorSceneManager.NewPreviewScene(); + Texture2D texture = null; + try + { + Camera camera = CreateCamera(scene, background); + GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(asset, scene); + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(instance, camera, gameViewSize); + // Only an opaque background is composited, so only then is the extra probe render worth it. + // The probe runs before the real render so the decision rests on the pipeline, never on the image content. + bool pipelineClears = background.a >= 1f && _renderer.ClearsBackground(camera, frame.ImageSize); + texture = _renderer.Render(camera, frame.ImageSize); + CheckAndFillBackground(texture, background, request.PrefabPath, pipelineClears); + if (!Mathf.Approximately(request.ResolutionScale, 1f)) + { + // ApplyResolutionScaling destroys the original only after it succeeds, so finally destroys exactly one texture. + texture = EditorWindowCaptureUtility.ApplyResolutionScaling(texture, request.ResolutionScale); + } + + ScreenshotInfo info = SavePng(texture, asset.name, request); + VibeLogger.LogInfo( + "screenshot_prefab_complete", + $"Rendered prefab ({info.Width}x{info.Height})", + new { Kind = frame.Kind.ToString(), info.Width, info.Height }, + correlationId: correlationId); + return Task.FromResult(new ScreenshotResponse + { + Success = true, + ResolvedCaptureMode = UnityCliLoopConstants.SCREENSHOT_RESOLVED_CAPTURE_MODE_PREFAB, + Message = DescribeFrame(request.PrefabPath, frame), + Screenshots = new List { info } + }); + } + finally + { + if (texture != null) + { + UnityEngine.Object.DestroyImmediate(texture); + } + + EditorSceneManager.ClosePreviewScene(scene); + } + } + + private static void CheckAndFillBackground(Texture2D texture, Color background, string prefabPath, bool pipelineClears) + { + Color32[] pixels = texture.GetPixels32(); + if (!PrefabImagePixels.HasDrawnPixel(pixels, background)) + { + throw new UnityCliLoopToolParameterValidationException( + $"Nothing was drawn for {prefabPath}; the project's render pipeline did not draw this content. If the prefab is a single flat color, pass a different --prefab-background-color."); + } + + // Some pipelines do not clear game cameras, so an opaque background is filled in afterwards; + // a translucent background is left to the pipeline because compositing it again would double it. + if (background.a < 1f) + { + return; + } + + if (pipelineClears) + { + // The GPU already blended every pixel over the background; only alpha can be off, + // because shaders without a separate alpha blend leave it below 255. + PrefabImagePixels.MakeOpaque(pixels); + } + else + { + PrefabImagePixels.FillBackground(pixels, background, QualitySettings.activeColorSpace == ColorSpace.Linear); + } + + texture.SetPixels32(pixels); + texture.Apply(); + } + + private static Camera CreateCamera(Scene scene, Color background) + { + GameObject cameraObject = new("PrefabCaptureCamera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.scene = scene; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = background; + return camera; + } + + private static ScreenshotInfo SavePng(Texture2D texture, string assetName, ScreenshotSchema request) + { + string outputDirectory = ScreenshotFileWriter.EnsureOutputDirectoryExists(request.OutputDirectory); + string fileName = $"Prefab_{ScreenshotFileWriter.SanitizeFileName(assetName)}_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; + string path = Path.Combine(outputDirectory, fileName); + ScreenshotFileWriter.SaveTextureAsPng(texture, path); + // Only the package default folder is pruned; files in a user-specified OutputDirectory are never deleted. + if (string.IsNullOrEmpty(request.OutputDirectory)) + { + OutputFileRetention.DeleteOldestBeyondLimit(outputDirectory, "*.png"); + } + + return new ScreenshotInfo + { + ImagePath = path, + FileSizeBytes = new FileInfo(path).Length, + Width = texture.width, + Height = texture.height, + ResolutionScale = request.ResolutionScale, + ImageCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_PREFAB_IMAGE, + ScreenshotToInputFormula = UnityCliLoopConstants.SCREENSHOT_PREFAB_TO_INPUT_FORMULA_UNAVAILABLE + }; + } + + private static string DescribeFrame(string path, PrefabCaptureFrame frame) + { + return frame.Kind switch + { + PrefabCaptureKind.UiPart => $"Rendered {path} as a UI part, sized to all of its RectTransforms.", + PrefabCaptureKind.ScreenUi => $"Rendered {path} as screen UI at the Game View size.", + PrefabCaptureKind.Object3D => $"Rendered {path} as a 3D object framed from the front-left at the Game View size.", + PrefabCaptureKind.Flat2D => $"Rendered {path} as a flat object framed straight on at the Game View size.", + _ => throw new ArgumentOutOfRangeException(nameof(frame), frame.Kind, "Unknown prefab capture kind") + }; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta new file mode 100644 index 000000000..eb56c2fe8 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 056eafe231da34534bd6e9d29169a8e6 +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 index 4191d755e..24018d8f6 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using UnityEngine; @@ -12,17 +13,18 @@ internal static class ScreenshotParameterValidator { internal static void Validate(ScreenshotSchema request) { + if (!string.IsNullOrEmpty(request.PrefabBackgroundColor)) + { + throw new UnityCliLoopToolParameterValidationException("PrefabBackgroundColor requires PrefabPath"); + } + 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}"); - } + ValidateResolutionScale(request); // AnnotateElements, ElementsOnly, and AnnotateRaycastGrid rely on PlayMode rendering pipeline if (request.CaptureMode != CaptureMode.rendering) @@ -65,6 +67,60 @@ internal static void Validate(ScreenshotSchema request) } } + /// + /// Validates a prefab capture request; callers route here only when PrefabPath is set. + /// + internal static void ValidatePrefab(ScreenshotSchema request) + { + if (string.IsNullOrEmpty(request.PrefabPath)) + { + throw new ArgumentException("ValidatePrefab requires PrefabPath", nameof(request)); + } + + if (request.CaptureMode != CaptureMode.auto) + { + throw new UnityCliLoopToolParameterValidationException( + "CaptureMode cannot be combined with PrefabPath; prefab screenshots always render the asset in a preview scene"); + } + + if (request.AnnotateElements) + { + throw new UnityCliLoopToolParameterValidationException("AnnotateElements cannot be combined with PrefabPath"); + } + + if (request.ElementsOnly) + { + throw new UnityCliLoopToolParameterValidationException("ElementsOnly cannot be combined with PrefabPath"); + } + + if (request.AnnotateRaycastGrid) + { + throw new UnityCliLoopToolParameterValidationException("AnnotateRaycastGrid cannot be combined with PrefabPath"); + } + + if (!string.IsNullOrEmpty(request.RaycastLayerMask)) + { + throw new UnityCliLoopToolParameterValidationException("RaycastLayerMask cannot be combined with PrefabPath"); + } + + ValidateResolutionScale(request); + + if (!PrefabBackgroundColorParser.IsValid(request.PrefabBackgroundColor)) + { + throw new UnityCliLoopToolParameterValidationException( + $"PrefabBackgroundColor must be #RRGGBB or #RRGGBBAA, got: {request.PrefabBackgroundColor}"); + } + } + + private static void ValidateResolutionScale(ScreenshotSchema request) + { + if (request.ResolutionScale < 0.1f || request.ResolutionScale > 1.0f) + { + throw new UnityCliLoopToolParameterValidationException( + $"ResolutionScale must be between 0.1 and 1.0, got: {request.ResolutionScale}"); + } + } + internal static RaycastLayerMaskResolution ResolveRaycastLayerMask(ScreenshotSchema request) { string raycastLayerMask = request.RaycastLayerMask ?? ""; diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs index 6d983f9b3..1cd657a4c 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs @@ -15,6 +15,13 @@ public class ScreenshotTool : UnityCliLoopTool ExecuteAsync(ScreenshotSchema parameters, CancellationToken ct) { + // Prefab captures bypass ScreenshotUseCase, which resolves auto to rendering in Play Mode before validating. + if (!string.IsNullOrEmpty(parameters.PrefabPath)) + { + PrefabScreenshotUseCase prefabUseCase = new(); + return await prefabUseCase.CaptureAsync(parameters, ct).ConfigureAwait(false); + } + ScreenshotUseCase useCase = new(); return await useCase.CaptureAsync(parameters, ct).ConfigureAwait(false); } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md index 23d3068c7..db17bef88 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md @@ -11,7 +11,7 @@ Take a screenshot of any Unity EditorWindow by name and save as PNG. ## Usage ```bash -uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] +uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] [--prefab-path ] [--prefab-background-color ] ``` ## Parameters @@ -27,14 +27,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--prefab-path` | string | `""` | Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched. | +| `--prefab-background-color` | string | `""` | Background for `--prefab-path` as `#RRGGBB` or `#RRGGBBAA`. Empty uses `#808080`. | ## Match Modes -| Mode | Description | Example | -|------|-------------|---------| -| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | -| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | -| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | +See [references/match-modes.md](references/match-modes.md) for exact / prefix / contains examples. ## Window Name @@ -51,14 +49,14 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured -- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` +- `ResolvedCaptureMode`: `"window"`, `"rendering"`, or `"prefab"` — the mode actually used after resolving `auto` - `Warning`: Appears for window captures taken while Play Mode is running with at least one image, and for any image captured while Play Mode is paused (UGUI/canvas content may show the frame rendered before the pause; run `uloop control-play-mode --action Step` once and capture again to refresh it). - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty (`""`) when `--elements-only` is used because no image file is written; `FileSizeBytes`, `Width`, and `Height` are then `0`, while `GameViewWidth`/`GameViewHeight` and `AnnotatedElements` still carry real values. When it is non-empty, always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. - `FileSizeBytes`: Size of the saved file in bytes - `Width`: Captured image width in pixels - `Height`: Captured image height in pixels - - `ImageCoordinateSystem`: `"top-left-game-view"` or `"top-left-window"` + - `ImageCoordinateSystem`: `"top-left-game-view"`, `"top-left-window"`, or `"top-left-prefab-image"` - `ResolutionScale`: Resolution scale used for capture - `ImageToInputOffsetY`: Y offset used for top-left-game-view coordinate conversion - `GameViewWidth`, `GameViewHeight`: Game View size in input coordinates at capture time — the basis for `SimX`/`SimY` and for the Y flip inside `simulate-mouse-input`. Populated for rendering captures, including `--elements-only` @@ -75,3 +73,4 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con - Use `uloop focus-window` first if needed - Target window must be open in Unity Editor - Window name matching is always case-insensitive +- Before using --prefab-path, read [references/prefab-capture.md](references/prefab-capture.md). diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md new file mode 100644 index 000000000..f2bf44f88 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md @@ -0,0 +1,7 @@ +# Window Name Match Modes + +| Mode | Description | Example | +|------|-------------|---------| +| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | +| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | +| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta new file mode 100644 index 000000000..d6d47bc05 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 24551ed296561408f9fa0cdce1334adf +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md new file mode 100644 index 000000000..11f348b14 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -0,0 +1,42 @@ +# Prefab Capture (`--prefab-path`) + +`uloop screenshot --prefab-path ` instantiates the prefab or model asset in a temporary preview scene, renders it with a temporary camera, and returns one PNG. No window opens, and open scenes, Prefab Mode, and the asset itself are left untouched. It works in Edit Mode and Play Mode. + +```bash +uloop screenshot --prefab-path Assets/UI/Panel.prefab +uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color "#00000000" +``` + +## How the prefab is framed + +The first matching rule decides the image (the response `Message` says which one was used): + +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. The temporary Canvas that hosts the part sits on the built-in UI layer. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. The prefab's own Canvases keep their layers, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. A lone world-space Canvas is drawn on its own layer, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. + +Prefabs with visible renderers (rules 3 and 4) are drawn with a preview camera, the kind Inspector previews use, so they appear even in pipelines whose game cameras draw only certain layers. Preview cameras do not draw uGUI, so in a prefab that mixes renderers and a world-space Canvas, the Canvas does not appear. + +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. A render in which nothing appears (every pixel transparent or the background color) is rejected too; for a prefab that is a single flat color, pass a different `--prefab-background-color`. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. + +The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. + +## Options + +- `--window-name` and `--match-mode` are ignored. +- `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. +- `--resolution-scale` shrinks the image after it is rendered. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. When the render pipeline clears the capture camera to the background color, the drawn colors are kept and only made opaque. When it does not, the background is composited after rendering under unpainted and translucent pixels. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. +- `--prefab-background-color` without `--prefab-path` is rejected. + +## What happens to the instance + +- If the prefab has no enabled Light, one directional light is added to the preview scene. +- A root saved inactive is activated so it can be captured; children keep their saved state. +- Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. +- The prefab is rendered right after it is instantiated, so content that scripts assign later (asynchronous loads, Start, or Update) is missing, and post-processing such as bloom is not applied. + +## Output + +`ResolvedCaptureMode` is `"prefab"` and `ImageCoordinateSystem` is `"top-left-prefab-image"`. Prefab images are not Game View captures, so `ScreenshotToInputFormula` reports that mouse-input coordinates are unavailable. diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta new file mode 100644 index 000000000..f991bc168 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e31c96b2fb14b41c7a033b4ac4402fea +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs b/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs index 0ef41565f..09969817d 100644 --- a/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs +++ b/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs @@ -14,5 +14,7 @@ public class ScreenshotSchema : UnityCliLoopToolSchema public bool ElementsOnly { get; set; } = false; public bool AnnotateRaycastGrid { get; set; } = false; public string RaycastLayerMask { get; set; } = ""; + public string PrefabPath { get; set; } = ""; + public string PrefabBackgroundColor { get; set; } = ""; } } diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 3cf254040..a5db7b48d 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -123,6 +123,11 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string SCREENSHOT_SIMULATOR_WINDOW_NAME = "Simulator"; public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_WINDOW = "window"; public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_RENDERING = "rendering"; + public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_PREFAB = "prefab"; + public const string COORDINATE_SYSTEM_TOP_LEFT_PREFAB_IMAGE = "top-left-prefab-image"; + public const string SCREENSHOT_PREFAB_TO_INPUT_FORMULA_UNAVAILABLE = + "unavailable: prefab screenshots render the asset in an isolated preview scene, not the Game View"; + public const string SCREENSHOT_PREFAB_DEFAULT_BACKGROUND_COLOR = "#808080"; 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 13646d60a..840e31f31 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -306,6 +306,16 @@ "type": "string", "description": "Comma-separated physics layer names to narrow which layers --annotate-raycast-grid clusters. Hits are limited to layers also visible to Camera.main.cullingMask. When omitted, clusters against Physics.DefaultRaycastLayers.", "default": "" + }, + "PrefabPath": { + "type": "string", + "description": "Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched.", + "default": "" + }, + "PrefabBackgroundColor": { + "type": "string", + "description": "Background for --prefab-path as #RRGGBB or #RRGGBBAA. Empty uses #808080.", + "default": "" } } } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index ee93f86a1..40ae1d750 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "84de69207e7c4aacf17afe371cfbb2df358d6074" + "sharedInputsHash": "b453636d0586a882f075ef2d0e77f2da79354454" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index a85a3e472..44a5818d1 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "afb925d8e21cf6a9d5b57fc047cb882adc6bab7c" + "sharedInputsHash": "7b1a205dce89310e512997eef8defeb7ecc4e533" }