Files
Emberwild/Assets/GAME/Script/Editor/ScreenshotStudio/ScreenshotStudioWindow.cs
T
Mathew 78bfdf2828 Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
2026-07-25 19:42:26 +02:00

822 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Ashwild.EditorTools
{
/// <summary>
/// The Screenshot Studio — a UI Toolkit window that renders a prefab, scene object or raw mesh in an
/// isolated preview world and bakes polished PNGs from it. The left pane is a live, orbitable 3D
/// preview (drag to orbit, scroll to zoom); the right pane hosts the camera, lighting, background and
/// export controls. All rendering and capture is delegated to ScreenshotStage; this window is the
/// view and the orchestrator that writes the stage config and triggers renders/exports.
/// </summary>
public class ScreenshotStudioWindow : EditorWindow
{
#region Types
/// <summary>
/// The named lighting rigs offered in the presets dropdown; Apply pushes each into the stage.
/// </summary>
private enum LightingPreset { Studio, Soft, Dramatic, Flat }
/// <summary>
/// Which asset kind the Target object picker is filtered to — prefabs/scene objects or raw
/// meshes. Materials, scripts and other assets are intentionally excluded from both.
/// </summary>
private enum TargetKind { Prefab, Mesh }
#endregion
#region Constants
private const string UssPath = "Assets/GAME/Script/Editor/ScreenshotStudio/ScreenshotStudio.uss";
private const string FolderPrefKey = "Ashwild.ScreenshotStudio.Folder";
private const string FilePrefKey = "Ashwild.ScreenshotStudio.FileName";
private const int LivePreviewLongSide = 1024;
#endregion
#region State
private ScreenshotStage stage;
private int exportWidth = 1024;
private int exportHeight = 1024;
private bool supersample = true;
private int turntableFrames = 12;
private string exportFolder;
private string exportFileName = string.Empty;
private bool nameEditedByUser;
private TargetKind targetKind = TargetKind.Prefab;
private bool autoRotate;
private IVisualElementScheduledItem rotateSchedule;
private bool dragging;
private Vector2 lastPointer;
#endregion
#region UI References
private Image previewImage;
private Label emptyOverlay;
private VisualElement controls;
private ColorField backgroundColorField;
#endregion
#region Menu
/// <summary>
/// Opens (or focuses) the Screenshot Studio window.
/// </summary>
[MenuItem("Tools/Screenshot Studio")]
public static void Open()
{
ScreenshotStudioWindow window = GetWindow<ScreenshotStudioWindow>();
window.titleContent = new GUIContent("Screenshot Studio");
window.minSize = new Vector2(880, 520);
}
#endregion
#region Window Lifecycle
/// <summary>
/// Builds the window tree and the render stage. Loads the last export folder from EditorPrefs.
/// </summary>
private void CreateGUI()
{
stage = new ScreenshotStage();
exportFolder = EditorPrefs.GetString(FolderPrefKey, Application.dataPath);
exportFileName = EditorPrefs.GetString(FilePrefKey, string.Empty);
VisualElement root = rootVisualElement;
root.AddToClassList("ss-root");
StyleSheet sheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
if (sheet != null) root.styleSheets.Add(sheet);
VisualElement body = new VisualElement();
body.AddToClassList("ss-body");
body.Add(BuildControlsPane());
body.Add(BuildPreviewPane());
root.Add(body);
RebuildControls();
RenderPreview();
}
/// <summary>
/// Tears the stage down so the preview scene and textures never survive the window.
/// </summary>
private void OnDisable()
{
rotateSchedule?.Pause();
stage?.Dispose();
stage = null;
}
#endregion
#region Preview Pane
/// <summary>
/// Builds the large live-preview surface on the left: an Image fed by the stage's render texture,
/// wired for orbit (drag) and zoom (wheel), with an empty-state overlay shown until a target loads.
/// </summary>
private VisualElement BuildPreviewPane()
{
VisualElement pane = new VisualElement();
pane.AddToClassList("ss-preview");
previewImage = new Image { scaleMode = ScaleMode.ScaleToFit };
previewImage.AddToClassList("ss-preview__image");
previewImage.RegisterCallback<PointerDownEvent>(OnPreviewPointerDown);
previewImage.RegisterCallback<PointerMoveEvent>(OnPreviewPointerMove);
previewImage.RegisterCallback<PointerUpEvent>(OnPreviewPointerUp);
previewImage.RegisterCallback<WheelEvent>(OnPreviewWheel);
pane.Add(previewImage);
emptyOverlay = new Label("Assign a prefab, scene object or mesh to begin.\nDrag to orbit · scroll to zoom.");
emptyOverlay.AddToClassList("ss-preview__empty");
pane.Add(emptyOverlay);
return pane;
}
#endregion
#region Controls Pane
/// <summary>
/// Builds the scrollable right pane container the control cards are rendered into.
/// </summary>
private VisualElement BuildControlsPane()
{
controls = new ScrollView();
controls.AddToClassList("ss-controls");
return controls;
}
/// <summary>
/// Rebuilds every control card from the current stage + window state. Called on open and whenever
/// a preset changes several values at once, so the widgets always mirror the live config.
/// </summary>
private void RebuildControls()
{
controls.Clear();
controls.Add(BuildTargetCard());
controls.Add(BuildCameraCard());
controls.Add(BuildLightingCard());
controls.Add(BuildBackgroundCard());
controls.Add(BuildExportCard());
}
/// <summary>
/// Target card: a Prefab/Mesh kind selector that filters the object picker to only those assets
/// (so materials, scripts and the like never clutter it), the object field itself, per-axis
/// rotation sliders that pose the subject, plus quick Frame and auto-rotate controls.
/// </summary>
private VisualElement BuildTargetCard()
{
VisualElement card = Card("Target");
ObjectField field = new ObjectField("Object")
{
objectType = TargetObjectType(),
allowSceneObjects = targetKind == TargetKind.Prefab
};
field.RegisterValueChangedCallback(evt => LoadTarget(evt.newValue));
EnumField kind = new EnumField("Kind", targetKind);
kind.RegisterValueChangedCallback(evt =>
{
targetKind = (TargetKind)evt.newValue;
field.value = null;
field.objectType = TargetObjectType();
field.allowSceneObjects = targetKind == TargetKind.Prefab;
});
card.Add(kind);
card.Add(field);
card.Add(SliderRow("Rotate X", 0f, 360f, stage.SubjectEuler.x,
v => stage.SetSubjectRotation(new Vector3(v, stage.SubjectEuler.y, stage.SubjectEuler.z))));
card.Add(SliderRow("Rotate Y", 0f, 360f, stage.SubjectEuler.y,
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, v, stage.SubjectEuler.z))));
card.Add(SliderRow("Rotate Z", 0f, 360f, stage.SubjectEuler.z,
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, stage.SubjectEuler.y, v))));
VisualElement buttons = new VisualElement();
buttons.AddToClassList("ss-buttons");
Button frame = new Button(() => { stage.FrameTarget(); RenderPreview(); }) { text = "Frame" };
frame.AddToClassList("ss-btn");
buttons.Add(frame);
Button resetRotation = new Button(() => { stage.SetSubjectRotation(Vector3.zero); RebuildControls(); RenderPreview(); }) { text = "Reset Rotation" };
resetRotation.AddToClassList("ss-btn");
buttons.Add(resetRotation);
Toggle rotate = new Toggle("Auto-rotate") { value = autoRotate };
rotate.RegisterValueChangedCallback(evt => SetAutoRotate(evt.newValue));
buttons.Add(rotate);
card.Add(buttons);
return card;
}
/// <summary>
/// The Unity type the object picker is restricted to for the current kind — GameObject for
/// prefabs/scene objects, Mesh for raw meshes — so nothing else appears in the picker.
/// </summary>
private Type TargetObjectType() => targetKind == TargetKind.Mesh ? typeof(Mesh) : typeof(GameObject);
/// <summary>
/// Camera card: projection, field of view and framing padding.
/// </summary>
private VisualElement BuildCameraCard()
{
VisualElement card = Card("Camera");
Toggle ortho = new Toggle("Orthographic") { value = stage.Orthographic };
ortho.RegisterValueChangedCallback(evt => { stage.Orthographic = evt.newValue; RenderPreview(); });
card.Add(ortho);
card.Add(SliderRow("Field of View", 10f, 80f, stage.FieldOfView, v => stage.FieldOfView = v));
card.Add(SliderRow("Padding", 1f, 3f, stage.Padding, v => stage.Padding = v));
return card;
}
/// <summary>
/// Lighting card: a preset picker plus fine control over the key light (colour, intensity,
/// direction), the fill and rim intensities and the ambient wash.
/// </summary>
private VisualElement BuildLightingCard()
{
VisualElement card = Card("Lighting");
DropdownField presets = new DropdownField("Preset",
new System.Collections.Generic.List<string> { "Studio", "Soft", "Dramatic", "Flat" }, 0);
presets.RegisterValueChangedCallback(evt => ApplyPreset((LightingPreset)presets.index));
card.Add(presets);
ColorField keyColor = new ColorField("Key Colour") { value = stage.KeyColor, showAlpha = false };
keyColor.RegisterValueChangedCallback(evt => { stage.KeyColor = evt.newValue; RenderPreview(); });
card.Add(keyColor);
card.Add(SliderRow("Key Intensity", 0f, 3f, stage.KeyIntensity, v => stage.KeyIntensity = v));
card.Add(SliderRow("Key Yaw", 0f, 360f, stage.KeyYaw, v => stage.KeyYaw = v));
card.Add(SliderRow("Key Pitch", -80f, 80f, stage.KeyPitch, v => stage.KeyPitch = v));
card.Add(SliderRow("Fill Intensity", 0f, 2f, stage.FillIntensity, v => stage.FillIntensity = v));
card.Add(SliderRow("Rim Intensity", 0f, 3f, stage.RimIntensity, v => stage.RimIntensity = v));
ColorField ambient = new ColorField("Ambient Colour") { value = stage.AmbientColor, showAlpha = false };
ambient.RegisterValueChangedCallback(evt => { stage.AmbientColor = evt.newValue; RenderPreview(); });
card.Add(ambient);
card.Add(SliderRow("Ambient Intensity", 0f, 1.5f, stage.AmbientIntensity, v => stage.AmbientIntensity = v));
return card;
}
/// <summary>
/// Background card: transparent vs solid, and the solid fill colour (disabled while transparent).
/// </summary>
private VisualElement BuildBackgroundCard()
{
VisualElement card = Card("Background");
EnumField mode = new EnumField("Mode", stage.Background);
mode.RegisterValueChangedCallback(evt =>
{
stage.Background = (ScreenshotStage.BackgroundMode)evt.newValue;
backgroundColorField.SetEnabled(stage.Background == ScreenshotStage.BackgroundMode.SolidColor);
RenderPreview();
});
card.Add(mode);
backgroundColorField = new ColorField("Colour") { value = stage.BackgroundColor, showAlpha = false };
backgroundColorField.SetEnabled(stage.Background == ScreenshotStage.BackgroundMode.SolidColor);
backgroundColorField.RegisterValueChangedCallback(evt => { stage.BackgroundColor = evt.newValue; RenderPreview(); });
card.Add(backgroundColorField);
return card;
}
/// <summary>
/// Export card: resolution (presets + custom), supersampling, the output folder and file name,
/// and the PNG / turntable export actions.
/// </summary>
private VisualElement BuildExportCard()
{
VisualElement card = Card("Export");
DropdownField resolution = new DropdownField("Resolution",
new System.Collections.Generic.List<string> { "512", "1024", "2048", "4096", "Custom" }, 1);
resolution.RegisterValueChangedCallback(evt => ApplyResolutionPreset(resolution.value));
card.Add(resolution);
VisualElement size = new VisualElement();
size.AddToClassList("ss-row");
IntegerField width = new IntegerField("Width") { value = exportWidth };
width.RegisterValueChangedCallback(evt => exportWidth = Mathf.Clamp(evt.newValue, 16, 8192));
IntegerField height = new IntegerField("Height") { value = exportHeight };
height.RegisterValueChangedCallback(evt => exportHeight = Mathf.Clamp(evt.newValue, 16, 8192));
width.AddToClassList("ss-half");
height.AddToClassList("ss-half");
size.Add(width);
size.Add(height);
card.Add(size);
Toggle ss = new Toggle("Supersample ×2") { value = supersample };
ss.tooltip = "Renders at double resolution and downscales for crisper edges.";
ss.RegisterValueChangedCallback(evt => supersample = evt.newValue);
card.Add(ss);
card.Add(BuildFolderRow());
TextField file = new TextField("File Name") { value = exportFileName };
file.RegisterValueChangedCallback(evt => { exportFileName = evt.newValue; nameEditedByUser = true; });
card.Add(file);
Button export = new Button(ExportPng) { text = "Export PNG" };
export.AddToClassList("ss-btn");
export.AddToClassList("ss-btn--primary");
card.Add(export);
VisualElement turntable = new VisualElement();
turntable.AddToClassList("ss-row");
IntegerField frames = new IntegerField("Turntable") { value = turntableFrames };
frames.RegisterValueChangedCallback(evt => turntableFrames = Mathf.Clamp(evt.newValue, 2, 360));
frames.style.flexGrow = 1;
Button exportTurntable = new Button(ExportTurntable) { text = "Export Sequence" };
exportTurntable.AddToClassList("ss-btn");
turntable.Add(frames);
turntable.Add(exportTurntable);
card.Add(turntable);
return card;
}
/// <summary>
/// Builds the output-folder row: a read-only path label and a Browse button that opens a folder
/// picker and remembers the choice in EditorPrefs. The picker starts from the resolved export
/// folder (normalised to native separators) so it opens where the label points — the native
/// Windows dialog ignores forward-slash paths like Application.dataPath and would otherwise fall
/// back to the shell's last-used location (often another project entirely).
/// </summary>
private VisualElement BuildFolderRow()
{
VisualElement row = new VisualElement();
row.AddToClassList("ss-row");
Label path = new Label(ShortFolder());
path.AddToClassList("ss-path");
path.tooltip = ResolveFolder();
Button browse = new Button(() =>
{
string start = ResolveFolder().Replace('/', Path.DirectorySeparatorChar);
string chosen = EditorUtility.OpenFolderPanel("Export folder", start, string.Empty);
if (string.IsNullOrEmpty(chosen)) return;
exportFolder = chosen;
EditorPrefs.SetString(FolderPrefKey, exportFolder);
path.text = ShortFolder();
path.tooltip = exportFolder;
})
{ text = "Browse" };
browse.AddToClassList("ss-btn");
row.Add(path);
row.Add(browse);
return row;
}
#endregion
#region Control Builders
/// <summary>
/// Builds a titled card container matching the studio styling; callers append their fields.
/// </summary>
private static VisualElement Card(string title)
{
VisualElement card = new VisualElement();
card.AddToClassList("ss-card");
Label heading = new Label(title);
heading.AddToClassList("ss-card__title");
card.Add(heading);
return card;
}
/// <summary>
/// Builds a labelled slider paired with a numeric field; both edit the same value and re-render
/// the preview live. The setter writes straight into the stage config.
/// </summary>
private VisualElement SliderRow(string label, float min, float max, float value, Action<float> setter)
{
VisualElement row = new VisualElement();
row.AddToClassList("ss-row");
Slider slider = new Slider(label, min, max) { value = value };
slider.AddToClassList("ss-slider");
slider.style.flexGrow = 1;
FloatField field = new FloatField { value = value };
field.AddToClassList("ss-num");
slider.RegisterValueChangedCallback(evt =>
{
field.SetValueWithoutNotify(evt.newValue);
setter(evt.newValue);
RenderPreview();
});
field.RegisterValueChangedCallback(evt =>
{
float clamped = Mathf.Clamp(evt.newValue, min, max);
slider.SetValueWithoutNotify(clamped);
setter(clamped);
RenderPreview();
});
row.Add(slider);
row.Add(field);
return row;
}
#endregion
#region Target
/// <summary>
/// Loads a new subject into the stage, defaults the export file name to its name (unless the user
/// already typed one), rebuilds the controls to reflect the reset framing and re-renders.
/// </summary>
private void LoadTarget(UnityEngine.Object source)
{
stage.SetTarget(source);
if (source != null && !nameEditedByUser)
exportFileName = source.name;
emptyOverlay.style.display = stage.HasTarget ? DisplayStyle.None : DisplayStyle.Flex;
RebuildControls();
RenderPreview();
}
#endregion
#region Presets
/// <summary>
/// Applies a named lighting rig to the stage and rebuilds the lighting controls to match.
/// </summary>
private void ApplyPreset(LightingPreset preset)
{
switch (preset)
{
case LightingPreset.Studio:
stage.KeyColor = Color.white; stage.KeyIntensity = 1.1f; stage.KeyYaw = 50f; stage.KeyPitch = 40f;
stage.FillIntensity = 0.45f; stage.RimIntensity = 0.9f;
stage.AmbientColor = new Color(0.5f, 0.54f, 0.62f); stage.AmbientIntensity = 0.35f;
break;
case LightingPreset.Soft:
stage.KeyColor = new Color(1f, 0.97f, 0.92f); stage.KeyIntensity = 0.85f; stage.KeyYaw = 40f; stage.KeyPitch = 55f;
stage.FillIntensity = 0.7f; stage.RimIntensity = 0.4f;
stage.AmbientColor = new Color(0.62f, 0.64f, 0.7f); stage.AmbientIntensity = 0.6f;
break;
case LightingPreset.Dramatic:
stage.KeyColor = new Color(1f, 0.95f, 0.85f); stage.KeyIntensity = 1.7f; stage.KeyYaw = 65f; stage.KeyPitch = 25f;
stage.FillIntensity = 0.1f; stage.RimIntensity = 1.6f;
stage.AmbientColor = new Color(0.3f, 0.34f, 0.45f); stage.AmbientIntensity = 0.12f;
break;
case LightingPreset.Flat:
stage.KeyColor = Color.white; stage.KeyIntensity = 0.9f; stage.KeyYaw = 0f; stage.KeyPitch = 30f;
stage.FillIntensity = 0.9f; stage.RimIntensity = 0f;
stage.AmbientColor = Color.white; stage.AmbientIntensity = 0.8f;
break;
}
RebuildControls();
RenderPreview();
}
/// <summary>
/// Sets the export resolution from the preset dropdown; "Custom" leaves the current width/height
/// untouched so the fields stay editable.
/// </summary>
private void ApplyResolutionPreset(string choice)
{
if (choice == "Custom") return;
if (int.TryParse(choice, out int size))
{
exportWidth = size;
exportHeight = size;
RebuildControls();
RenderPreview();
}
}
#endregion
#region Preview Rendering
/// <summary>
/// Renders the subject into the live texture at the preview aspect and pushes it into the Image.
/// A no-op (blank surface) until a target is loaded.
/// </summary>
private void RenderPreview()
{
if (stage == null) return;
if (!stage.HasTarget)
{
previewImage.image = null;
previewImage.MarkDirtyRepaint();
return;
}
Vector2Int size = LiveSize();
RenderTexture rt = stage.RenderLive(size.x, size.y);
previewImage.image = rt;
previewImage.MarkDirtyRepaint();
}
/// <summary>
/// The live render resolution: the export aspect ratio scaled so its long side is a fixed size,
/// keeping the preview crisp without paying full export cost every frame.
/// </summary>
private Vector2Int LiveSize()
{
float aspect = (float)Mathf.Max(1, exportWidth) / Mathf.Max(1, exportHeight);
int w = aspect >= 1f ? LivePreviewLongSide : Mathf.RoundToInt(LivePreviewLongSide * aspect);
int h = aspect >= 1f ? Mathf.RoundToInt(LivePreviewLongSide / aspect) : LivePreviewLongSide;
return new Vector2Int(Mathf.Max(16, w), Mathf.Max(16, h));
}
#endregion
#region Preview Interaction
/// <summary>
/// Begins an orbit drag, capturing the pointer so the drag continues even when it leaves the image.
/// </summary>
private void OnPreviewPointerDown(PointerDownEvent evt)
{
if (!stage.HasTarget) return;
dragging = true;
lastPointer = evt.position;
previewImage.CapturePointer(evt.pointerId);
}
/// <summary>
/// Orbits the camera by the pointer delta while dragging (horizontal → yaw, vertical → pitch).
/// </summary>
private void OnPreviewPointerMove(PointerMoveEvent evt)
{
if (!dragging) return;
Vector2 delta = (Vector2)evt.position - lastPointer;
lastPointer = evt.position;
stage.Yaw += delta.x * 0.5f;
stage.Pitch = Mathf.Clamp(stage.Pitch - delta.y * 0.5f, -89f, 89f);
RenderPreview();
}
/// <summary>
/// Ends the orbit drag and releases the pointer capture.
/// </summary>
private void OnPreviewPointerUp(PointerUpEvent evt)
{
dragging = false;
previewImage.ReleasePointer(evt.pointerId);
}
/// <summary>
/// Zooms the framing on scroll, clamped so the subject can neither invert nor fly off.
/// </summary>
private void OnPreviewWheel(WheelEvent evt)
{
if (!stage.HasTarget) return;
stage.Zoom = Mathf.Clamp(stage.Zoom * (1f - evt.delta.y * 0.05f), 0.2f, 5f);
RenderPreview();
evt.StopPropagation();
}
#endregion
#region Auto-Rotate
/// <summary>
/// Toggles the turntable spin, driving a steady yaw increment through the window scheduler so the
/// preview animates without a per-frame editor update hook.
/// </summary>
private void SetAutoRotate(bool enabled)
{
autoRotate = enabled;
if (enabled)
{
rotateSchedule = rootVisualElement.schedule.Execute(() =>
{
stage.Yaw += 1.2f;
RenderPreview();
}).Every(33);
}
else
{
rotateSchedule?.Pause();
}
}
#endregion
#region Export
/// <summary>
/// Captures the current view at the export resolution and writes it as a PNG to the chosen folder,
/// avoiding overwrites by auto-numbering, importing it as a single-mode Sprite when the path is
/// in-project, and revealing the file. Guards a missing target and failed writes with clear logs.
/// </summary>
private void ExportPng()
{
if (!stage.HasTarget)
{
Debug.LogError("[ScreenshotStudio] No target assigned — nothing to export.");
return;
}
Texture2D shot = stage.Capture(exportWidth, exportHeight, supersample);
if (shot == null) return;
byte[] png = shot.EncodeToPNG();
DestroyImmediate(shot);
string path = UniquePath(ResolveFolder(), ResolveFileName());
try
{
File.WriteAllBytes(path, png);
}
catch (Exception e)
{
Debug.LogError($"[ScreenshotStudio] Failed to write '{path}': {e.Message}");
return;
}
EditorPrefs.SetString(FilePrefKey, exportFileName);
ImportAsSprite(path);
Debug.Log($"[ScreenshotStudio] Exported {exportWidth}×{exportHeight} PNG → {path}");
ShowNotification(new GUIContent($"Exported {Path.GetFileName(path)}"));
}
/// <summary>
/// Bakes a turntable: N evenly-spaced frames around the Y axis, each written as a numbered PNG.
/// Restores the original yaw afterwards and shows a cancellable progress bar.
/// </summary>
private void ExportTurntable()
{
if (!stage.HasTarget)
{
Debug.LogError("[ScreenshotStudio] No target assigned — nothing to export.");
return;
}
string folder = ResolveFolder();
string baseName = Path.GetFileNameWithoutExtension(ResolveFileName());
float startYaw = stage.Yaw;
System.Collections.Generic.List<string> written = new System.Collections.Generic.List<string>();
try
{
for (int i = 0; i < turntableFrames; i++)
{
if (EditorUtility.DisplayCancelableProgressBar("Screenshot Studio",
$"Rendering frame {i + 1}/{turntableFrames}", (float)i / turntableFrames))
break;
stage.Yaw = startYaw + 360f * i / turntableFrames;
Texture2D frame = stage.Capture(exportWidth, exportHeight, supersample);
if (frame == null) break;
byte[] png = frame.EncodeToPNG();
DestroyImmediate(frame);
string framePath = Path.Combine(folder, $"{baseName}_{i:000}.png");
File.WriteAllBytes(framePath, png);
written.Add(framePath);
}
}
catch (Exception e)
{
Debug.LogError($"[ScreenshotStudio] Turntable export failed: {e.Message}");
}
finally
{
EditorUtility.ClearProgressBar();
stage.Yaw = startYaw;
foreach (string framePath in written) ImportAsSprite(framePath);
RenderPreview();
Debug.Log($"[ScreenshotStudio] Exported {written.Count}-frame turntable → {folder}");
ShowNotification(new GUIContent($"Exported {written.Count} frames"));
}
}
#endregion
#region Export Helpers
/// <summary>
/// The resolved export folder, falling back to the project Assets folder when unset or missing.
/// </summary>
private string ResolveFolder()
{
if (string.IsNullOrEmpty(exportFolder) || !Directory.Exists(exportFolder))
return Application.dataPath;
return exportFolder;
}
/// <summary>
/// The export file name, defaulting to the target's name and always ending in ".png".
/// </summary>
private string ResolveFileName()
{
string name = string.IsNullOrWhiteSpace(exportFileName) ? "Screenshot" : exportFileName.Trim();
if (!name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) name += ".png";
return name;
}
/// <summary>
/// Returns a non-colliding path in the folder, appending _001, _002, … when the name is taken.
/// </summary>
private static string UniquePath(string folder, string fileName)
{
string full = Path.Combine(folder, fileName);
if (!File.Exists(full)) return full;
string stem = Path.GetFileNameWithoutExtension(fileName);
string ext = Path.GetExtension(fileName);
for (int i = 1; i < 10000; i++)
{
string candidate = Path.Combine(folder, $"{stem}_{i:000}{ext}");
if (!File.Exists(candidate)) return candidate;
}
return full;
}
/// <summary>
/// The project-relative "Assets/…" path for a written file, or null when it lives outside the
/// project — so external-folder exports skip the in-project import step entirely.
/// </summary>
private static string ToAssetPath(string path)
{
string full = Path.GetFullPath(path).Replace('\\', '/');
string root = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return null;
return "Assets" + full.Substring(root.Length);
}
/// <summary>
/// Imports the written file when it lives under Assets and retypes it as a single-mode Sprite, so
/// studio exports drop straight into UI/inventory work without a manual texture-type change in the
/// inspector. Files exported to an external folder are left untouched.
/// </summary>
private static void ImportAsSprite(string path)
{
string assetPath = ToAssetPath(path);
if (assetPath == null) return;
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (importer == null)
{
Debug.LogError($"[ScreenshotStudio] No TextureImporter for '{assetPath}' — cannot set Sprite type.");
return;
}
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Single;
importer.SaveAndReimport();
}
/// <summary>
/// A compact, right-aligned form of the export folder for the path label.
/// </summary>
private string ShortFolder()
{
string folder = ResolveFolder();
const int max = 34;
return folder.Length <= max ? folder : "…" + folder.Substring(folder.Length - max);
}
#endregion
}
}