519 lines
24 KiB
C#
519 lines
24 KiB
C#
#if UNITY_EDITOR
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using UnityEditor;
|
|
using UnityEditor.SceneManagement;
|
|
using UnityEngine;
|
|
using UnityEngine.SceneManagement;
|
|
|
|
[Serializable] public sealed class CharacterReviewVec3 { public float x, y, z; public Vector3 Value => new Vector3(x, y, z); }
|
|
[Serializable]
|
|
public sealed class CharacterReviewSubject
|
|
{
|
|
public string id, kind, prefab, notes;
|
|
public float expectedHeight = 1.8f, heightTolerance = 0.15f;
|
|
public int maxTriangles = 3500;
|
|
public bool requireXpbr = true;
|
|
public bool requireAnimator = false;
|
|
public string[] requiredClips = { "Idle", "Walk", "Attack" };
|
|
}
|
|
[Serializable]
|
|
public sealed class CharacterReviewManifest
|
|
{
|
|
public int schemaVersion;
|
|
public string sceneId, outputScene, reportPath, label;
|
|
public float spacing = 3f, groundSize = 24f, fieldOfView = 40f;
|
|
public CharacterReviewVec3 cameraPosition, cameraTarget;
|
|
public CharacterReviewSubject[] subjects;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a persistent editor scene for reviewing character / unit art before gameplay swaps.
|
|
/// </summary>
|
|
public static class BuildCharacterReview
|
|
{
|
|
const string ManifestPath = "Doc/Design/Character/character_unit_review_manifest.json";
|
|
const string CapturePath = "Assets/Screenshots/character_unit_review.png";
|
|
static string RepoRoot => Directory.GetParent(Application.dataPath).Parent.FullName;
|
|
static string ProjectRoot => Directory.GetParent(Application.dataPath).FullName;
|
|
static string AbsoluteAsset(string assetPath) => Path.GetFullPath(Path.Combine(ProjectRoot, Norm(assetPath)));
|
|
|
|
static CharacterReviewManifest ReadManifest()
|
|
{
|
|
string path = Path.GetFullPath(Path.Combine(RepoRoot, ManifestPath));
|
|
if (!File.Exists(path)) throw new FileNotFoundException("Missing character review manifest", path);
|
|
string text = File.ReadAllText(path);
|
|
var data = new CharacterReviewManifest
|
|
{
|
|
schemaVersion = ExtractInt(text, "schemaVersion"),
|
|
sceneId = ExtractString(text, "sceneId"),
|
|
outputScene = ExtractString(text, "outputScene"),
|
|
reportPath = ExtractString(text, "reportPath"),
|
|
label = ExtractString(text, "label"),
|
|
spacing = ExtractFloat(text, "spacing", 3f),
|
|
groundSize = ExtractFloat(text, "groundSize", 24f),
|
|
fieldOfView = ExtractFloat(text, "fieldOfView", 40f),
|
|
cameraPosition = ReadVec(text, "cameraPosition"),
|
|
cameraTarget = ReadVec(text, "cameraTarget"),
|
|
subjects = ParseSubjects(SliceArray(text, "subjects"))
|
|
};
|
|
if (string.IsNullOrEmpty(data.sceneId) || string.IsNullOrEmpty(data.outputScene))
|
|
throw new Exception("Character review manifest needs sceneId and outputScene.");
|
|
if (data.cameraPosition == null || data.cameraTarget == null)
|
|
throw new Exception("Character review manifest needs cameraPosition and cameraTarget.");
|
|
if (data.subjects == null || data.subjects.Length == 0)
|
|
throw new Exception("Character review manifest needs at least one subject.");
|
|
return data;
|
|
}
|
|
|
|
static string ExtractString(string json, string key)
|
|
{
|
|
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\"([^\"]*)\"");
|
|
return match.Success ? match.Groups[1].Value : "";
|
|
}
|
|
static int ExtractInt(string json, string key)
|
|
{
|
|
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?\\d+)");
|
|
return match.Success ? int.Parse(match.Groups[1].Value) : 0;
|
|
}
|
|
static float ExtractFloat(string json, string key, float fallback)
|
|
{
|
|
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(-?[0-9.]+)");
|
|
return match.Success ? float.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : fallback;
|
|
}
|
|
static bool ExtractBool(string json, string key, bool fallback)
|
|
{
|
|
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*(true|false)");
|
|
return match.Success ? match.Groups[1].Value == "true" : fallback;
|
|
}
|
|
static CharacterReviewVec3 ReadVec(string json, string key)
|
|
{
|
|
var match = Regex.Match(json, "\"" + key + "\"\\s*:\\s*\\{\\s*\"x\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"y\"\\s*:\\s*(-?[0-9.]+)\\s*,\\s*\"z\"\\s*:\\s*(-?[0-9.]+)");
|
|
if (!match.Success) return null;
|
|
return new CharacterReviewVec3
|
|
{
|
|
x = float.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture),
|
|
y = float.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture),
|
|
z = float.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture)
|
|
};
|
|
}
|
|
static string SliceArray(string text, string key)
|
|
{
|
|
int i = text.IndexOf("\"" + key + "\"", StringComparison.Ordinal);
|
|
if (i < 0) return "[]";
|
|
int start = text.IndexOf('[', i);
|
|
int depth = 0;
|
|
for (int c = start; c < text.Length; c++)
|
|
{
|
|
if (text[c] == '[') depth++;
|
|
else if (text[c] == ']') { depth--; if (depth == 0) return text.Substring(start, c - start + 1); }
|
|
}
|
|
return "[]";
|
|
}
|
|
static CharacterReviewSubject[] ParseSubjects(string arrayJson)
|
|
{
|
|
var items = new List<CharacterReviewSubject>();
|
|
int depth = 0, start = -1;
|
|
for (int i = 0; i < arrayJson.Length; i++)
|
|
{
|
|
if (arrayJson[i] == '{') { if (depth == 0) start = i; depth++; }
|
|
else if (arrayJson[i] == '}')
|
|
{
|
|
depth--;
|
|
if (depth == 0 && start >= 0)
|
|
{
|
|
string slice = arrayJson.Substring(start, i - start + 1);
|
|
items.Add(new CharacterReviewSubject
|
|
{
|
|
id = ExtractString(slice, "id"),
|
|
kind = ExtractString(slice, "kind"),
|
|
prefab = ExtractString(slice, "prefab"),
|
|
notes = ExtractString(slice, "notes"),
|
|
expectedHeight = ExtractFloat(slice, "expectedHeight", 1.8f),
|
|
heightTolerance = ExtractFloat(slice, "heightTolerance", 0.15f),
|
|
maxTriangles = ExtractInt(slice, "maxTriangles"),
|
|
requireXpbr = ExtractBool(slice, "requireXpbr", true),
|
|
requireAnimator = ExtractBool(slice, "requireAnimator", false),
|
|
requiredClips = slice.Contains("\"requiredClips\"")
|
|
? Regex.Matches(SliceArray(slice,"requiredClips"), "\"([^\"]+)\"").Cast<Match>().Select(m=>m.Groups[1].Value).ToArray()
|
|
: new[] { "Idle", "Walk", "Attack" }
|
|
});
|
|
if (items[items.Count - 1].maxTriangles <= 0) items[items.Count - 1].maxTriangles = 3500;
|
|
start = -1;
|
|
}
|
|
}
|
|
}
|
|
return items.ToArray();
|
|
}
|
|
|
|
static string Norm(string path) => path.Replace('\\', '/');
|
|
|
|
static void EnsureFolder(string assetPath)
|
|
{
|
|
string absolute = AbsoluteAsset(assetPath);
|
|
string directory = Path.GetDirectoryName(absolute);
|
|
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
|
string unityDir = Path.GetDirectoryName(Norm(assetPath))?.Replace('\\', '/');
|
|
if (string.IsNullOrEmpty(unityDir) || !unityDir.StartsWith("Assets/", StringComparison.Ordinal)) return;
|
|
string[] parts = unityDir.Split('/');
|
|
string current = parts[0];
|
|
for (int i = 1; i < parts.Length; i++)
|
|
{
|
|
string next = current + "/" + parts[i];
|
|
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, parts[i]);
|
|
current = next;
|
|
}
|
|
}
|
|
|
|
public static string ValidateOnly()
|
|
{
|
|
var data = ReadManifest();
|
|
foreach (var subject in data.subjects)
|
|
{
|
|
if (string.IsNullOrEmpty(subject.id) || string.IsNullOrEmpty(subject.prefab))
|
|
throw new Exception("Subject missing id/prefab");
|
|
if (AssetDatabase.LoadAssetAtPath<GameObject>(Norm(subject.prefab)) == null)
|
|
throw new Exception("Missing prefab " + subject.prefab);
|
|
}
|
|
return "PASS character review manifest subjects=" + data.subjects.Length;
|
|
}
|
|
|
|
public static string Build()
|
|
{
|
|
if (EditorApplication.isPlaying) throw new Exception("Build character review in Edit Mode.");
|
|
var data = ReadManifest();
|
|
string scenePath = Norm(data.outputScene);
|
|
EnsureFolder(scenePath);
|
|
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
|
|
|
var root = new GameObject(data.sceneId);
|
|
SceneManager.MoveGameObjectToScene(root, scene);
|
|
|
|
BuildGround(root.transform, data.groundSize);
|
|
BuildHeightMarkers(root.transform);
|
|
var subjects = new GameObject("Subjects");
|
|
subjects.transform.SetParent(root.transform, false);
|
|
for (int i = 0; i < data.subjects.Length; i++)
|
|
{
|
|
var subject = data.subjects[i];
|
|
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(Norm(subject.prefab));
|
|
if (prefab == null) throw new Exception("Missing prefab " + subject.prefab);
|
|
var instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab, subjects.transform);
|
|
instance.name = subject.id;
|
|
instance.transform.localPosition = new Vector3(i * data.spacing, 0f, 0f);
|
|
instance.transform.localRotation = Quaternion.identity;
|
|
AddForwardMarker(instance.transform);
|
|
AddNameLabel(instance.transform, subject.id + " / " + subject.kind);
|
|
EnsureAnimCycle(instance);
|
|
}
|
|
|
|
var lighting = new GameObject("Lighting");
|
|
lighting.transform.SetParent(root.transform, false);
|
|
var sun = new GameObject("Sun");
|
|
sun.transform.SetParent(lighting.transform, false);
|
|
sun.transform.rotation = Quaternion.Euler(48f, -30f, 0f);
|
|
var light = sun.AddComponent<Light>();
|
|
light.type = LightType.Directional;
|
|
light.color = new Color(1f, 0.96f, 0.88f);
|
|
light.intensity = 1.2f;
|
|
light.shadows = LightShadows.Soft;
|
|
|
|
var cameraGo = new GameObject("ReviewCamera");
|
|
cameraGo.tag = "MainCamera";
|
|
var camera = cameraGo.AddComponent<Camera>();
|
|
camera.clearFlags = CameraClearFlags.SolidColor;
|
|
camera.backgroundColor = new Color(0.55f, 0.72f, 0.82f);
|
|
camera.fieldOfView = data.fieldOfView;
|
|
camera.nearClipPlane = 0.05f;
|
|
camera.farClipPlane = 80f;
|
|
cameraGo.AddComponent<AudioListener>();
|
|
cameraGo.transform.position = data.cameraPosition.Value;
|
|
cameraGo.transform.rotation = Quaternion.LookRotation(data.cameraTarget.Value - cameraGo.transform.position, Vector3.up);
|
|
|
|
var title = new GameObject("ReviewLabel");
|
|
var text = title.AddComponent<TextMesh>();
|
|
text.text = string.IsNullOrEmpty(data.label) ? data.sceneId : data.label;
|
|
text.anchor = TextAnchor.LowerCenter;
|
|
text.alignment = TextAlignment.Center;
|
|
text.characterSize = 0.12f;
|
|
text.fontSize = 48;
|
|
text.color = new Color(0.15f, 0.2f, 0.25f);
|
|
title.transform.position = data.cameraTarget.Value + new Vector3(0f, 2.6f, 0f);
|
|
|
|
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Trilight;
|
|
RenderSettings.ambientSkyColor = new Color(0.55f, 0.65f, 0.75f);
|
|
RenderSettings.ambientEquatorColor = new Color(0.4f, 0.42f, 0.4f);
|
|
RenderSettings.ambientGroundColor = new Color(0.25f, 0.24f, 0.22f);
|
|
|
|
EditorSceneManager.SaveScene(scene, scenePath);
|
|
AssetDatabase.SaveAssets();
|
|
AssetDatabase.Refresh();
|
|
WriteReport(data, scenePath, "built");
|
|
return "Built character review scene " + scenePath + " with " + data.subjects.Length + " subjects.";
|
|
}
|
|
|
|
static void BuildGround(Transform parent, float size)
|
|
{
|
|
var ground = GameObject.CreatePrimitive(PrimitiveType.Plane);
|
|
ground.name = "Ground";
|
|
ground.transform.SetParent(parent, false);
|
|
ground.transform.localScale = Vector3.one * (size / 10f);
|
|
var renderer = ground.GetComponent<Renderer>();
|
|
var mat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
|
|
mat.color = new Color(0.78f, 0.82f, 0.74f);
|
|
renderer.sharedMaterial = mat;
|
|
UnityEngine.Object.DestroyImmediate(ground.GetComponent<Collider>());
|
|
}
|
|
|
|
static void BuildHeightMarkers(Transform parent)
|
|
{
|
|
var markers = new GameObject("HeightMarkers");
|
|
markers.transform.SetParent(parent, false);
|
|
markers.transform.localPosition = new Vector3(-2.2f, 0f, 0f);
|
|
foreach (float h in new[] { 0.5f, 1.0f, 1.8f })
|
|
{
|
|
var stick = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
|
|
stick.name = "Stick_" + h.ToString("0.0", CultureInfo.InvariantCulture);
|
|
stick.transform.SetParent(markers.transform, false);
|
|
stick.transform.localPosition = new Vector3(0f, h * 0.5f, 0f);
|
|
stick.transform.localScale = new Vector3(0.06f, h * 0.5f, 0.06f);
|
|
UnityEngine.Object.DestroyImmediate(stick.GetComponent<Collider>());
|
|
var label = new GameObject("Label_" + stick.name);
|
|
label.transform.SetParent(stick.transform, false);
|
|
label.transform.localPosition = new Vector3(0.8f, 0.5f, 0f);
|
|
var text = label.AddComponent<TextMesh>();
|
|
text.text = h.ToString("0.0", CultureInfo.InvariantCulture) + "m";
|
|
text.characterSize = 0.08f;
|
|
text.fontSize = 36;
|
|
text.anchor = TextAnchor.MiddleLeft;
|
|
text.color = new Color(0.2f, 0.25f, 0.3f);
|
|
}
|
|
}
|
|
|
|
static void AddForwardMarker(Transform subject)
|
|
{
|
|
var marker = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
|
marker.name = "ForwardMinusZ";
|
|
marker.transform.SetParent(subject, false);
|
|
marker.transform.localPosition = new Vector3(0f, 0.05f, -0.55f);
|
|
marker.transform.localScale = new Vector3(0.12f, 0.04f, 0.45f);
|
|
UnityEngine.Object.DestroyImmediate(marker.GetComponent<Collider>());
|
|
var renderer = marker.GetComponent<Renderer>();
|
|
var mat = new Material(Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"));
|
|
mat.color = new Color(0.2f, 0.55f, 0.95f);
|
|
renderer.sharedMaterial = mat;
|
|
}
|
|
|
|
static void AddNameLabel(Transform subject, string label)
|
|
{
|
|
var go = new GameObject("NameLabel");
|
|
go.transform.SetParent(subject, false);
|
|
go.transform.localPosition = new Vector3(0f, 2.1f, 0f);
|
|
var text = go.AddComponent<TextMesh>();
|
|
text.text = label;
|
|
text.anchor = TextAnchor.LowerCenter;
|
|
text.alignment = TextAlignment.Center;
|
|
text.characterSize = 0.06f;
|
|
text.fontSize = 40;
|
|
text.color = new Color(0.12f, 0.16f, 0.2f);
|
|
}
|
|
|
|
static void EnsureAnimCycle(GameObject subjectRoot)
|
|
{
|
|
var animator = subjectRoot.GetComponentInChildren<Animator>(true);
|
|
if (animator == null || animator.runtimeAnimatorController == null) return;
|
|
bool hasMotion = false;
|
|
foreach (var p in animator.parameters)
|
|
{
|
|
if (p.name == "Motion" && p.type == AnimatorControllerParameterType.Int)
|
|
{
|
|
hasMotion = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasMotion) return;
|
|
var cycle = subjectRoot.GetComponent<CharacterReviewAnimCycle>();
|
|
if (cycle == null) cycle = subjectRoot.AddComponent<CharacterReviewAnimCycle>();
|
|
var clips = animator.runtimeAnimatorController.animationClips.Select(c=>c.name).Distinct().ToArray();
|
|
var serialized = new SerializedObject(cycle);
|
|
var names = serialized.FindProperty("motionNames");
|
|
names.arraySize = clips.Length;
|
|
for(int i=0;i<clips.Length;i++) names.GetArrayElementAtIndex(i).stringValue=clips[i];
|
|
serialized.ApplyModifiedPropertiesWithoutUndo();
|
|
cycle.enabled = true;
|
|
}
|
|
|
|
static void WriteReport(CharacterReviewManifest data, string scenePath, string status)
|
|
{
|
|
string reportPath = string.IsNullOrEmpty(data.reportPath)
|
|
? "Tools/output/character_review/" + data.sceneId + "/review_report.json"
|
|
: Norm(data.reportPath);
|
|
string absolute = Path.GetFullPath(Path.Combine(RepoRoot, reportPath));
|
|
Directory.CreateDirectory(Path.GetDirectoryName(absolute));
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("{");
|
|
sb.AppendLine(" \"schemaVersion\": 1,");
|
|
sb.AppendLine(" \"sceneId\": \"" + data.sceneId + "\",");
|
|
sb.AppendLine(" \"outputScene\": \"" + scenePath + "\",");
|
|
sb.AppendLine(" \"status\": \"" + status + "\",");
|
|
sb.AppendLine(" \"generatedAtUtc\": \"" + DateTime.UtcNow.ToString("o") + "\",");
|
|
sb.AppendLine(" \"subjectCount\": " + data.subjects.Length + ",");
|
|
sb.AppendLine(" \"doc\": \"Doc/Design/Character/CharacterUnitArtProc.md\"");
|
|
sb.AppendLine("}");
|
|
File.WriteAllText(absolute, sb.ToString());
|
|
}
|
|
|
|
static Bounds ActualBounds(Renderer renderer)
|
|
{
|
|
var skin=renderer as SkinnedMeshRenderer;
|
|
if(skin==null) return renderer.bounds;
|
|
// Renderer.bounds is an animation culling volume, not posed geometry.
|
|
// Evaluate skin matrices directly; BakeMesh at scaled ancestors can
|
|
// include scale a second time in this Unity version.
|
|
var mesh=skin.sharedMesh;
|
|
var poses=mesh.bindposes;
|
|
var matrices=skin.bones.Select((b,i)=>b.localToWorldMatrix*poses[i]).ToArray();
|
|
var weights=mesh.boneWeights;
|
|
var vertices=mesh.vertices;
|
|
Bounds bounds=default;
|
|
for(int i=0;i<vertices.Length;i++)
|
|
{
|
|
var w=weights[i]; var v=vertices[i];
|
|
var p=matrices[w.boneIndex0].MultiplyPoint3x4(v)*w.weight0
|
|
+matrices[w.boneIndex1].MultiplyPoint3x4(v)*w.weight1
|
|
+matrices[w.boneIndex2].MultiplyPoint3x4(v)*w.weight2
|
|
+matrices[w.boneIndex3].MultiplyPoint3x4(v)*w.weight3;
|
|
if(i==0) bounds=new Bounds(p,Vector3.zero); else bounds.Encapsulate(p);
|
|
}
|
|
return bounds;
|
|
}
|
|
|
|
public static string OpenReview()
|
|
{
|
|
var data = ReadManifest();
|
|
string scenePath = Norm(data.outputScene);
|
|
if (!File.Exists(AbsoluteAsset(scenePath)))
|
|
throw new FileNotFoundException("Review scene missing; run Build first", AbsoluteAsset(scenePath));
|
|
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
|
|
var root = GameObject.Find(data.sceneId);
|
|
if (root != null)
|
|
{
|
|
var subjects = root.transform.Find("Subjects");
|
|
if (subjects != null)
|
|
{
|
|
for (int i = 0; i < subjects.childCount; i++)
|
|
EnsureAnimCycle(subjects.GetChild(i).gameObject);
|
|
}
|
|
Selection.activeGameObject = root;
|
|
EditorGUIUtility.PingObject(root);
|
|
EditorSceneManager.MarkSceneDirty(scene);
|
|
EditorSceneManager.SaveScene(scene);
|
|
}
|
|
var view = SceneView.lastActiveSceneView;
|
|
if (view != null)
|
|
{
|
|
view.LookAt(data.cameraTarget.Value, Quaternion.Euler(18f, 20f, 0f), 8f);
|
|
view.orthographic = false;
|
|
view.Repaint();
|
|
}
|
|
return "Opened " + scene.path + " (anim cycle on Motion units)";
|
|
}
|
|
|
|
public static string ValidateReview()
|
|
{
|
|
var data = ReadManifest();
|
|
string scenePath = Norm(data.outputScene);
|
|
EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
|
|
var root = GameObject.Find(data.sceneId);
|
|
if (root == null) throw new Exception("Missing root " + data.sceneId);
|
|
if (GameObject.Find("ReviewCamera") == null) throw new Exception("Missing ReviewCamera");
|
|
// 轮播会改姿态,校验前强制 Idle
|
|
foreach (var cycle in root.GetComponentsInChildren<CharacterReviewAnimCycle>(true))
|
|
cycle.enabled = false;
|
|
foreach (var animator in root.GetComponentsInChildren<Animator>(true))
|
|
{
|
|
if (animator.runtimeAnimatorController == null) continue;
|
|
foreach (var p in animator.parameters)
|
|
{
|
|
if (p.name == "Motion" && p.type == AnimatorControllerParameterType.Int)
|
|
{
|
|
animator.SetInteger("Motion", 0);
|
|
animator.Update(0f);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
var report = new StringBuilder("PASS ");
|
|
foreach (var subject in data.subjects)
|
|
{
|
|
var node = root.transform.Find("Subjects/" + subject.id);
|
|
if (node == null) throw new Exception("Missing subject node " + subject.id);
|
|
var renderers = node.GetComponentsInChildren<Renderer>(true)
|
|
.Where(r => r.name != "ForwardMinusZ" && r.name != "NameLabel" && r.GetComponentInParent<TextMesh>() == null && !r.name.StartsWith("Label_", StringComparison.Ordinal))
|
|
.ToArray();
|
|
if (renderers.Length == 0) throw new Exception(subject.id + " has no mesh renderer");
|
|
var bounds = ActualBounds(renderers[0]);
|
|
for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(ActualBounds(renderers[i]));
|
|
if (bounds.min.y < -0.05f || bounds.min.y > 0.08f)
|
|
throw new Exception(subject.id + " bottom y=" + bounds.min.y.ToString("F3") + " (want near 0)");
|
|
float height = bounds.size.y;
|
|
if (Mathf.Abs(height - subject.expectedHeight) > subject.heightTolerance)
|
|
throw new Exception(subject.id + " height=" + height.ToString("F2") + " expected " + subject.expectedHeight);
|
|
int tris = 0;
|
|
foreach (var filter in node.GetComponentsInChildren<MeshFilter>(true))
|
|
if (filter.sharedMesh != null) tris += filter.sharedMesh.triangles.Length / 3;
|
|
foreach (var skin in node.GetComponentsInChildren<SkinnedMeshRenderer>(true))
|
|
if (skin.sharedMesh != null) tris += skin.sharedMesh.triangles.Length / 3;
|
|
if (tris > subject.maxTriangles)
|
|
throw new Exception(subject.id + " tris=" + tris + " > " + subject.maxTriangles);
|
|
if (subject.requireXpbr)
|
|
{
|
|
foreach (var renderer in renderers)
|
|
{
|
|
if (renderer.sharedMaterial == null || renderer.sharedMaterial.shader == null)
|
|
throw new Exception(subject.id + " missing material");
|
|
if (renderer.sharedMaterial.shader.name != "Test/XPbr")
|
|
throw new Exception(subject.id + " shader=" + renderer.sharedMaterial.shader.name);
|
|
}
|
|
}
|
|
if (node.Find("ForwardMinusZ") == null) throw new Exception(subject.id + " missing -Z marker");
|
|
if (subject.requireAnimator)
|
|
{
|
|
var animator = node.GetComponentInChildren<Animator>(true);
|
|
if (animator == null || animator.runtimeAnimatorController == null)
|
|
throw new Exception(subject.id + " missing Animator/controller");
|
|
var ctrl = animator.runtimeAnimatorController;
|
|
var clipNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var clip in ctrl.animationClips) clipNames.Add(clip.name);
|
|
foreach (var need in subject.requiredClips)
|
|
{
|
|
bool hit = clipNames.Any(n => n.IndexOf(need, StringComparison.OrdinalIgnoreCase) >= 0);
|
|
if (!hit) throw new Exception(subject.id + " missing clip " + need + " in " + string.Join(",", clipNames));
|
|
}
|
|
}
|
|
report.Append(subject.id).Append("(h=").Append(height.ToString("F2")).Append(",tris=").Append(tris).Append(") ");
|
|
}
|
|
WriteReport(data, scenePath, "validated");
|
|
return report.ToString();
|
|
}
|
|
|
|
public static string CaptureReview()
|
|
{
|
|
return OpenReview() + "; then run: unity command capture_scene_view --width 1600 --height 900 --save_path " + CapturePath;
|
|
}
|
|
|
|
public static string BuildOpenValidate()
|
|
{
|
|
string built = Build();
|
|
OpenReview();
|
|
string validated = ValidateReview();
|
|
return built + " | " + validated;
|
|
}
|
|
}
|
|
#endif
|