using System; using System.Collections.Generic; using System.Reflection; using UnityEditor; using UnityEngine; namespace Streamingle.Gaze.Editor { [CustomEditor(typeof(BlendshapeGazeDriver))] public sealed class BlendshapeGazeDriverEditor : UnityEditor.Editor { private static readonly string[] StandardEyeLookChannels = { "eyeLookUpLeft", "eyeLookUpRight", "eyeLookDownLeft", "eyeLookDownRight", "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight" }; private static readonly GazeCalibrationPoint[] GridSamples = { GazeCalibrationPoint.UpLeft, GazeCalibrationPoint.UpCenter, GazeCalibrationPoint.UpRight, GazeCalibrationPoint.CenterLeft, GazeCalibrationPoint.Center, GazeCalibrationPoint.CenterRight, GazeCalibrationPoint.DownLeft, GazeCalibrationPoint.DownCenter, GazeCalibrationPoint.DownRight }; private static readonly string[] GridLabels = { "Up Left", "Up", "Up Right", "Left", "Center", "Right", "Down Left", "Down", "Down Right" }; private static readonly string[] RendererPropertyNames = { "targetRenderer", "_targetRenderer", "m_TargetRenderer", "renderer", "faceRenderer" }; private static readonly string[] ProfilePropertyNames = { "profile", "_profile", "m_Profile", "calibrationProfile" }; private static readonly string[] OriginPropertyNames = { "gazeOrigin", "_gazeOrigin", "m_GazeOrigin", "origin", "gazeBasis" }; private static readonly string[] HeadPropertyNames = { "headReference", "_headReference", "m_HeadReference", "head", "headTransform" }; private readonly Dictionary previewRestoreWeights = new Dictionary(); private readonly HashSet capturedThisSession = new HashSet(); private SkinnedMeshRenderer previewRenderer; private SerializedProperty targetRendererProperty; private SerializedProperty profileProperty; private SerializedProperty gazeOriginProperty; private SerializedProperty headReferenceProperty; private int selectedSampleIndex = 4; private bool editOriginInScene; private string feedbackMessage; private MessageType feedbackType = MessageType.Info; private GazeOriginEstimate lastOriginEstimate; private bool hasOriginEstimate; private BlendshapeGazeDriver Driver => (BlendshapeGazeDriver)target; private string SessionKey => $"Streamingle.Gaze.SelectedSample.{target.GetInstanceID()}"; private void OnEnable() { CacheDriverProperties(); selectedSampleIndex = Mathf.Clamp(SessionState.GetInt(SessionKey, 4), 0, GridSamples.Length - 1); } private void OnDisable() { // Applying a sample is a non-destructive preview. Do not leave it stuck on the face when // the inspector is closed or the user selects another object. RestorePreview(false); } public override void OnInspectorGUI() { serializedObject.Update(); EditorGUI.BeginChangeCheck(); DrawPropertiesExcluding(serializedObject, "m_Script"); bool defaultInspectorChanged = EditorGUI.EndChangeCheck(); serializedObject.ApplyModifiedProperties(); if (defaultInspectorChanged) { RestorePreview(false); CacheDriverProperties(); RefreshDriverCache(); } EditorGUILayout.Space(8f); DrawCalibrationTools(); } private void DrawCalibrationTools() { EditorGUILayout.LabelField("Gaze Calibration", EditorStyles.boldLabel); SkinnedMeshRenderer renderer = Driver.TargetRenderer; BlendshapeGazeProfile profile = Driver.Profile; using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) { if (GUILayout.Button("Open Guided Calibration", GUILayout.MinHeight(36f))) { GazeCalibrationWindow.Open(Driver); } EditorGUILayout.LabelField( "Recommended: edit five poses directly, generate corners, and test interpolation in one window.", EditorStyles.wordWrappedMiniLabel); } DrawValidation(renderer, profile); if (!string.IsNullOrWhiteSpace(feedbackMessage)) { EditorGUILayout.HelpBox(feedbackMessage, feedbackType); } } private void DrawProfileTools(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(profile != null)) { if (GUILayout.Button("Create Profile Asset")) { CreateProfileAsset(); GUIUtility.ExitGUI(); } } using (new EditorGUI.DisabledScope(profile == null || renderer == null || renderer.sharedMesh == null)) { if (GUILayout.Button("Sync ARKit EyeLook 8")) { SyncStandardChannels(renderer, profile); } } } if (renderer == null) { EditorGUILayout.HelpBox("Assign Target Renderer before calibrating.", MessageType.Warning); } else if (renderer.sharedMesh == null) { EditorGUILayout.HelpBox("Target Renderer has no shared mesh.", MessageType.Warning); } } private void DrawOriginTools(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { EditorGUILayout.LabelField("Gaze Origin", EditorStyles.boldLabel); using (new EditorGUILayout.HorizontalScope()) { using (new EditorGUI.DisabledScope(renderer == null)) { string originLabel = Driver.GazeOrigin == null ? "Auto Create Gaze Origin" : "Recalculate Gaze Origin"; if (GUILayout.Button(originLabel)) { AutoCreateOrRecalculateOrigin(renderer, profile); } } using (new EditorGUI.DisabledScope(Driver.GazeOrigin == null && renderer == null)) { if (GUILayout.Button("Set Forward From Scene Camera")) { SetNeutralForwardFromSceneCamera(renderer, profile); } } } using (new EditorGUI.DisabledScope(Driver.GazeOrigin == null)) { bool newEditState = EditorGUILayout.ToggleLeft("Edit origin position in Scene view", editOriginInScene); if (newEditState != editOriginInScene) { editOriginInScene = newEditState; SceneView.RepaintAll(); } } } private void DrawSampleGrid(BlendshapeGazeProfile profile) { for (int row = 0; row < 3; row++) { using (new EditorGUILayout.HorizontalScope()) { for (int column = 0; column < 3; column++) { int index = row * 3 + column; bool isSelected = selectedSampleIndex == index; bool isCaptured = IsSampleCaptured(profile, index); Color previousColor = GUI.backgroundColor; if (isSelected) { GUI.backgroundColor = new Color(0.35f, 0.72f, 1f); } else if (isCaptured) { GUI.backgroundColor = new Color(0.45f, 0.8f, 0.48f); } string state = isCaptured ? " [Captured]" : string.Empty; if (GUILayout.Button(GridLabels[index] + state, GUILayout.MinHeight(30f))) { selectedSampleIndex = index; SessionState.SetInt(SessionKey, selectedSampleIndex); SceneView.RepaintAll(); } GUI.backgroundColor = previousColor; } } } EditorGUILayout.LabelField( $"Selected: {GridLabels[selectedSampleIndex]}", EditorStyles.miniLabel); } private void DrawSampleActions(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { bool canUseSamples = renderer != null && renderer.sharedMesh != null && profile != null; using (new EditorGUI.DisabledScope(!canUseSamples)) using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Capture Current Weights")) { CaptureSelectedSample(renderer, profile); } if (GUILayout.Button("Apply Selected Sample")) { ApplySelectedSample(renderer, profile); } using (new EditorGUI.DisabledScope(previewRestoreWeights.Count == 0)) { if (GUILayout.Button("Restore")) { RestorePreview(true); } } } } private void DrawValidation(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { if (renderer == null || profile == null) { return; } Mesh mesh = renderer.sharedMesh; if (mesh == null) { EditorGUILayout.HelpBox("Target Renderer has no shared mesh.", MessageType.Warning); return; } if (profile.Channels.Count == 0) { EditorGUILayout.HelpBox("Sync the eight eyeLook channels before capturing samples.", MessageType.Warning); return; } var missingChannels = new List(); if (!profile.IsCompatibleWith(mesh)) { EditorGUILayout.HelpBox( $"This profile was calibrated for '{profile.SourceMesh.name}', not '{mesh.name}'.", MessageType.Warning); return; } for (int index = 0; index < profile.Channels.Count; index++) { GazeBlendShapeChannel channel = profile.Channels[index]; if (channel == null || BlendshapeGazeProfile.FindBlendShapeIndex(mesh, channel.BlendShapeName) < 0) { missingChannels.Add(channel != null ? channel.BlendShapeName : ""); } } if (missingChannels.Count > 0) { EditorGUILayout.HelpBox( "Missing blendshapes on Target Renderer: " + string.Join(", ", missingChannels), MessageType.Warning); return; } int capturedCount = 0; for (int index = 0; index < profile.Samples.Count; index++) { if (profile.Samples[index] != null && profile.Samples[index].Captured) { capturedCount++; } } EditorGUILayout.HelpBox( profile.IsFullyCalibrated ? "All 9 gaze samples are calibrated." : $"Calibration progress: {capturedCount} / 9 samples.", profile.IsFullyCalibrated ? MessageType.Info : MessageType.None); } private void CreateProfileAsset() { string driverName = string.IsNullOrWhiteSpace(Driver.gameObject.name) ? "Character" : Driver.gameObject.name; string path = EditorUtility.SaveFilePanelInProject( "Create Gaze Calibration Profile", driverName + "_GazeCalibration", "asset", "Choose where to save the character-specific gaze calibration profile."); if (string.IsNullOrWhiteSpace(path)) { return; } var profile = ScriptableObject.CreateInstance(); profile.name = System.IO.Path.GetFileNameWithoutExtension(path); AssetDatabase.CreateAsset(profile, path); AssetDatabase.SaveAssets(); Undo.RecordObject(Driver, "Assign Gaze Calibration Profile"); if (!SetDriverObjectReference(profileProperty, ProfilePropertyNames, profile)) { AssetDatabase.DeleteAsset(path); SetFeedback("Could not find the driver's serialized Profile field.", MessageType.Error); return; } EditorUtility.SetDirty(Driver); EditorUtility.SetDirty(profile); PrefabUtility.RecordPrefabInstancePropertyModifications(Driver); RefreshDriverCache(); SetFeedback("Created and assigned a gaze calibration profile.", MessageType.Info); EditorGUIUtility.PingObject(profile); } private void SyncStandardChannels(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { if (!TryFindStandardChannels(renderer.sharedMesh, out List matches, out string matchIssue)) { SetFeedback(matchIssue, MessageType.Error); return; } var preservedSamples = new Dictionary>(); IReadOnlyList existingChannels = profile.Channels; IReadOnlyList existingSamples = profile.Samples; for (int pointIndex = 0; pointIndex < 9 && pointIndex < existingSamples.Count; pointIndex++) { GazeCalibrationSample sample = existingSamples[pointIndex]; if (sample == null || !sample.Captured) { continue; } var weightsByChannel = new Dictionary(StringComparer.Ordinal); for (int channelIndex = 0; channelIndex < existingChannels.Count; channelIndex++) { GazeBlendShapeChannel channel = existingChannels[channelIndex]; if (channel == null || channelIndex >= sample.Weights.Count) { continue; } weightsByChannel[GetStableChannelKey(channel.BlendShapeName)] = sample.Weights[channelIndex]; } preservedSamples[(GazeCalibrationPoint)pointIndex] = weightsByChannel; } var replacements = new List(matches.Count + existingChannels.Count); for (int index = 0; index < matches.Count; index++) { replacements.Add(new GazeBlendShapeChannel(matches[index].MeshName, GazeChannelUsage.EyeLook)); } // Channel sync owns the primary eight, but must not destroy optional eyelid or other // corrective channels that were explicitly authored later. for (int index = 0; index < existingChannels.Count; index++) { GazeBlendShapeChannel channel = existingChannels[index]; if (channel == null || channel.Usage != GazeChannelUsage.Corrective) { continue; } bool duplicate = false; for (int replacementIndex = 0; replacementIndex < replacements.Count; replacementIndex++) { if (string.Equals( replacements[replacementIndex].BlendShapeName, channel.BlendShapeName, StringComparison.OrdinalIgnoreCase)) { duplicate = true; break; } } if (!duplicate) { replacements.Add(new GazeBlendShapeChannel( channel.BlendShapeName, GazeChannelUsage.Corrective, channel.CorrectiveBlendMode)); } } Undo.RecordObject(profile, "Sync ARKit EyeLook Channels"); profile.ReplaceChannels(replacements); profile.SetSourceMesh(renderer.sharedMesh); foreach (KeyValuePair> pair in preservedSamples) { var restoredWeights = new float[replacements.Count]; for (int channelIndex = 0; channelIndex < replacements.Count; channelIndex++) { string key = GetStableChannelKey(replacements[channelIndex].BlendShapeName); if (pair.Value.TryGetValue(key, out float value)) { restoredWeights[channelIndex] = value; } } profile.SetSample(pair.Key, restoredWeights); } EditorUtility.SetDirty(profile); AssetDatabase.SaveAssetIfDirty(profile); RefreshDriverCache(); SetFeedback( $"Synced all {StandardEyeLookChannels.Length} ARKit eyeLook channels from {renderer.sharedMesh.name}.", MessageType.Info); } private void CaptureSelectedSample(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { Undo.RecordObject(profile, "Capture Gaze Calibration Sample"); if (!profile.CaptureSample(GridSamples[selectedSampleIndex], renderer)) { SetFeedback( "Capture failed. Sync channels and make sure every configured blendshape exists on Target Renderer.", MessageType.Error); return; } capturedThisSession.Add(selectedSampleIndex); EditorUtility.SetDirty(profile); AssetDatabase.SaveAssetIfDirty(profile); RefreshDriverCache(); SetFeedback($"Captured {GridLabels[selectedSampleIndex]} from the current blendshape weights.", MessageType.Info); Repaint(); SceneView.RepaintAll(); } private void ApplySelectedSample(SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { GazeCalibrationPoint point = GridSamples[selectedSampleIndex]; int sampleIndex = (int)point; if (sampleIndex < 0 || sampleIndex >= profile.Samples.Count || profile.Samples[sampleIndex] == null || !profile.Samples[sampleIndex].Captured) { SetFeedback("The selected sample has not been captured yet.", MessageType.Warning); return; } CachePreviewWeights(renderer); Undo.RecordObject(renderer, "Preview Gaze Calibration Sample"); GazeCalibrationSample sample = profile.Samples[sampleIndex]; Mesh mesh = renderer.sharedMesh; for (int channelIndex = 0; channelIndex < profile.Channels.Count; channelIndex++) { int blendShapeIndex = profile.FindBlendShapeIndex(mesh, channelIndex); if (blendShapeIndex < 0 || channelIndex >= sample.Weights.Count) { continue; } renderer.SetBlendShapeWeight(blendShapeIndex, sample.Weights[channelIndex]); } EditorUtility.SetDirty(renderer); PrefabUtility.RecordPrefabInstancePropertyModifications(renderer); SetFeedback($"Previewing {GridLabels[selectedSampleIndex]}. Restore returns to the previous face pose.", MessageType.Info); SceneView.RepaintAll(); } private void CachePreviewWeights(SkinnedMeshRenderer renderer) { if (previewRestoreWeights.Count > 0 && previewRenderer == renderer) { return; } previewRestoreWeights.Clear(); previewRenderer = renderer; Mesh mesh = renderer != null ? renderer.sharedMesh : null; if (mesh == null) { return; } for (int index = 0; index < mesh.blendShapeCount; index++) { previewRestoreWeights[index] = renderer.GetBlendShapeWeight(index); } } private void RestorePreview(bool recordUndo) { if (previewRenderer == null || previewRestoreWeights.Count == 0) { previewRestoreWeights.Clear(); previewRenderer = null; return; } if (recordUndo) { Undo.RecordObject(previewRenderer, "Restore Face Before Gaze Preview"); } Mesh mesh = previewRenderer.sharedMesh; if (mesh != null) { foreach (KeyValuePair pair in previewRestoreWeights) { if (pair.Key >= 0 && pair.Key < mesh.blendShapeCount) { previewRenderer.SetBlendShapeWeight(pair.Key, pair.Value); } } EditorUtility.SetDirty(previewRenderer); PrefabUtility.RecordPrefabInstancePropertyModifications(previewRenderer); } previewRestoreWeights.Clear(); previewRenderer = null; SceneView.RepaintAll(); } private void AutoCreateOrRecalculateOrigin( SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { IReadOnlyList channelNames = GetConfiguredChannelNames(profile); GazeOriginEstimate estimate = GazeOriginEstimator.Estimate(renderer, channelNames); lastOriginEstimate = estimate; hasOriginEstimate = true; Transform origin = Driver.GazeOrigin; if (origin == null) { Transform parent = ResolveOriginParent(); var marker = new GameObject(Driver.gameObject.name + "_GazeOrigin"); Undo.RegisterCreatedObjectUndo(marker, "Create Gaze Origin"); origin = marker.transform; Undo.SetTransformParent(origin, parent, "Parent Gaze Origin"); origin.rotation = parent != null ? parent.rotation : Driver.transform.rotation; Undo.RecordObject(Driver, "Assign Gaze Origin"); if (!SetDriverObjectReference(gazeOriginProperty, OriginPropertyNames, origin)) { Undo.DestroyObjectImmediate(marker); SetFeedback("Could not find the driver's serialized Gaze Origin field.", MessageType.Error); return; } } Undo.RecordObject(origin, "Recalculate Gaze Origin"); origin.position = estimate.WorldPosition; EditorUtility.SetDirty(origin); EditorUtility.SetDirty(Driver); PrefabUtility.RecordPrefabInstancePropertyModifications(origin); PrefabUtility.RecordPrefabInstancePropertyModifications(Driver); RefreshDriverCache(); SetFeedback( estimate.Message, estimate.UsedEyeBlendShapes ? MessageType.Info : MessageType.Warning); EditorGUIUtility.PingObject(origin.gameObject); SceneView.RepaintAll(); } private void SetNeutralForwardFromSceneCamera( SkinnedMeshRenderer renderer, BlendshapeGazeProfile profile) { if (Driver.GazeOrigin == null) { if (renderer == null) { SetFeedback("Assign Target Renderer before creating Gaze Origin.", MessageType.Error); return; } AutoCreateOrRecalculateOrigin(renderer, profile); } Transform origin = Driver.GazeOrigin; Camera sceneCamera = SceneView.lastActiveSceneView != null ? SceneView.lastActiveSceneView.camera : null; if (origin == null || sceneCamera == null) { SetFeedback("No active Scene view camera was found.", MessageType.Error); return; } Vector3 direction = sceneCamera.transform.position - origin.position; if (direction.sqrMagnitude < 1e-8f) { direction = -sceneCamera.transform.forward; } Vector3 up = origin.parent != null ? origin.parent.up : Vector3.up; if (Mathf.Abs(Vector3.Dot(direction.normalized, up.normalized)) > 0.999f) { up = sceneCamera.transform.up; } Undo.RecordObject(origin, "Set Neutral Gaze Forward"); origin.rotation = Quaternion.LookRotation(direction.normalized, up); EditorUtility.SetDirty(origin); PrefabUtility.RecordPrefabInstancePropertyModifications(origin); RefreshDriverCache(); SetFeedback("Neutral forward now points from Gaze Origin to the Scene view camera.", MessageType.Info); SceneView.RepaintAll(); } private Transform ResolveOriginParent() { Transform explicitHead = GetObjectReference(headReferenceProperty, Driver, HeadPropertyNames); if (explicitHead != null) { return explicitHead; } Animator animator = Driver.GetComponentInParent(); if (animator != null && animator.isHuman) { Transform humanoidHead = animator.GetBoneTransform(HumanBodyBones.Head); if (humanoidHead != null) { return humanoidHead; } } return Driver.transform; } private void OnSceneGUI() { Transform origin = Driver.GazeOrigin; if (origin == null) { return; } float handleSize = HandleUtility.GetHandleSize(origin.position); Handles.color = new Color(0.1f, 0.9f, 1f, 0.95f); Handles.ArrowHandleCap( 0, origin.position, origin.rotation, handleSize * 0.65f, EventType.Repaint); Handles.Label( origin.position + origin.up * handleSize * 0.16f, $"Gaze Origin\n{GridLabels[selectedSampleIndex]}"); if (hasOriginEstimate && lastOriginEstimate.UsedEyeBlendShapes) { Handles.color = new Color(0.3f, 1f, 0.4f, 0.8f); float eyeHandleSize = handleSize * 0.04f; Handles.SphereHandleCap(0, lastOriginEstimate.LeftEyeWorldPosition, Quaternion.identity, eyeHandleSize, EventType.Repaint); Handles.SphereHandleCap(0, lastOriginEstimate.RightEyeWorldPosition, Quaternion.identity, eyeHandleSize, EventType.Repaint); Handles.DrawDottedLine( lastOriginEstimate.LeftEyeWorldPosition, lastOriginEstimate.RightEyeWorldPosition, 3f); } bool hasDebugTarget = Driver.HasActiveRequest; Vector3 debugTargetPosition = Driver.LastTargetPosition; if (!hasDebugTarget) { Camera mainCamera = Camera.main; if (mainCamera != null) { hasDebugTarget = true; debugTargetPosition = mainCamera.transform.position; } } if (hasDebugTarget) { Vector3 targetDirection = debugTargetPosition - origin.position; if (targetDirection.sqrMagnitude > 1e-8f) { Handles.color = new Color(1f, 0.78f, 0.1f, 0.8f); Handles.DrawDottedLine(origin.position, debugTargetPosition, 4f); Vector3 localDirection = origin.InverseTransformDirection(targetDirection.normalized); float yaw = Mathf.Atan2(localDirection.x, localDirection.z) * Mathf.Rad2Deg; float pitch = Mathf.Atan2( localDirection.y, Mathf.Sqrt(localDirection.x * localDirection.x + localDirection.z * localDirection.z)) * Mathf.Rad2Deg; Handles.Label( origin.position + targetDirection.normalized * handleSize * 0.8f, $"Camera yaw {yaw:F1} deg pitch {pitch:F1} deg"); } } if (!editOriginInScene) { return; } EditorGUI.BeginChangeCheck(); Vector3 newPosition = Handles.PositionHandle(origin.position, origin.rotation); if (EditorGUI.EndChangeCheck()) { Undo.RecordObject(origin, "Move Gaze Origin"); origin.position = newPosition; EditorUtility.SetDirty(origin); PrefabUtility.RecordPrefabInstancePropertyModifications(origin); RefreshDriverCache(); } } private void CacheDriverProperties() { serializedObject.UpdateIfRequiredOrScript(); targetRendererProperty = FindProperty(serializedObject, RendererPropertyNames); profileProperty = FindProperty(serializedObject, ProfilePropertyNames); gazeOriginProperty = FindProperty(serializedObject, OriginPropertyNames); headReferenceProperty = FindProperty(serializedObject, HeadPropertyNames); } private void RefreshDriverCache() { const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; MethodInfo refreshMethod = Driver.GetType().GetMethod("RebuildCache", flags) ?? Driver.GetType().GetMethod("InvalidateCache", flags); refreshMethod?.Invoke(Driver, null); EditorUtility.SetDirty(Driver); } private bool SetDriverObjectReference( SerializedProperty serializedProperty, IReadOnlyList memberNames, UnityEngine.Object value) { if (serializedProperty != null && serializedProperty.propertyType == SerializedPropertyType.ObjectReference) { serializedObject.Update(); serializedProperty.objectReferenceValue = value; serializedObject.ApplyModifiedProperties(); CacheDriverProperties(); return true; } return TrySetMemberValue(Driver, memberNames, value); } private static T GetObjectReference( SerializedProperty serializedProperty, object owner, IReadOnlyList memberNames) where T : UnityEngine.Object { if (serializedProperty != null && serializedProperty.propertyType == SerializedPropertyType.ObjectReference) { return serializedProperty.objectReferenceValue as T; } return GetMemberValue(owner, memberNames) as T; } private static bool TrySetMemberValue( object owner, IReadOnlyList memberNames, object value) { if (owner == null) { return false; } const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; Type type = owner.GetType(); for (int index = 0; index < memberNames.Count; index++) { string memberName = memberNames[index]; FieldInfo field = type.GetField(memberName, flags); if (field != null && (value == null || field.FieldType.IsInstanceOfType(value))) { field.SetValue(owner, value); return true; } PropertyInfo property = type.GetProperty(memberName, flags); if (property != null && property.CanWrite && (value == null || property.PropertyType.IsInstanceOfType(value))) { property.SetValue(owner, value); return true; } } return false; } private static object GetMemberValue(object owner, IReadOnlyList memberNames) { if (owner == null) { return null; } const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; Type type = owner.GetType(); for (int index = 0; index < memberNames.Count; index++) { string memberName = memberNames[index]; FieldInfo field = type.GetField(memberName, flags); if (field != null) { return field.GetValue(owner); } PropertyInfo property = type.GetProperty(memberName, flags); if (property != null && property.CanRead) { return property.GetValue(owner); } } return null; } private static SerializedProperty FindProperty(SerializedObject owner, IReadOnlyList names) { if (owner == null) { return null; } for (int index = 0; index < names.Count; index++) { SerializedProperty exact = owner.FindProperty(names[index]); if (exact != null) { return exact; } } var normalizedNames = new HashSet(StringComparer.Ordinal); for (int index = 0; index < names.Count; index++) { normalizedNames.Add(Normalize(names[index])); } SerializedProperty iterator = owner.GetIterator(); bool enterChildren = true; while (iterator.NextVisible(enterChildren)) { enterChildren = false; if (iterator.depth == 0 && normalizedNames.Contains(Normalize(iterator.name))) { return iterator.Copy(); } } return null; } private static bool TryFindStandardChannels( Mesh mesh, out List matches, out string issue) { matches = new List(StandardEyeLookChannels.Length); var missing = new List(); for (int standardIndex = 0; standardIndex < StandardEyeLookChannels.Length; standardIndex++) { string standardName = StandardEyeLookChannels[standardIndex]; int meshIndex = FindBestBlendShapeMatch(mesh, standardName); if (meshIndex < 0) { missing.Add(standardName); continue; } matches.Add(new BlendShapeMatch( standardName, mesh.GetBlendShapeName(meshIndex), meshIndex)); } if (missing.Count > 0) { issue = "The mesh is missing ARKit eyeLook channels: " + string.Join(", ", missing); return false; } issue = null; return true; } private static int FindBestBlendShapeMatch(Mesh mesh, string standardName) { if (mesh == null) { return -1; } string normalizedStandardName = Normalize(standardName); int bestIndex = -1; int bestScore = -1; int bestNameLength = int.MaxValue; for (int index = 0; index < mesh.blendShapeCount; index++) { string candidate = mesh.GetBlendShapeName(index); string normalizedCandidate = Normalize(candidate); int score = -1; if (string.Equals(candidate, standardName, StringComparison.OrdinalIgnoreCase)) { score = 100; } else if (candidate.EndsWith(standardName, StringComparison.OrdinalIgnoreCase)) { score = 80; } else if (normalizedCandidate == normalizedStandardName) { score = 60; } else if (normalizedCandidate.EndsWith(normalizedStandardName, StringComparison.Ordinal)) { score = 40; } if (score > bestScore || score == bestScore && candidate.Length < bestNameLength) { bestScore = score; bestIndex = index; bestNameLength = candidate.Length; } } return bestIndex; } private static bool IsNameOrSuffixMatch(string candidate, string standardName) { if (string.IsNullOrWhiteSpace(candidate) || string.IsNullOrWhiteSpace(standardName)) { return false; } if (string.Equals(candidate, standardName, StringComparison.OrdinalIgnoreCase) || candidate.EndsWith(standardName, StringComparison.OrdinalIgnoreCase)) { return true; } string normalizedCandidate = Normalize(candidate); string normalizedStandard = Normalize(standardName); return normalizedCandidate == normalizedStandard || normalizedCandidate.EndsWith(normalizedStandard, StringComparison.Ordinal); } private static string GetStableChannelKey(string channelName) { for (int index = 0; index < StandardEyeLookChannels.Length; index++) { if (IsNameOrSuffixMatch(channelName, StandardEyeLookChannels[index])) { return "arkit:" + Normalize(StandardEyeLookChannels[index]); } } return "custom:" + Normalize(channelName); } private static IReadOnlyList GetConfiguredChannelNames(BlendshapeGazeProfile profile) { var names = new List(); if (profile == null) { names.AddRange(StandardEyeLookChannels); return names; } for (int index = 0; index < profile.Channels.Count; index++) { GazeBlendShapeChannel channel = profile.Channels[index]; if (channel != null && channel.Usage == GazeChannelUsage.EyeLook && !string.IsNullOrWhiteSpace(channel.BlendShapeName)) { names.Add(channel.BlendShapeName); } } if (names.Count > 0) { return names; } names.AddRange(StandardEyeLookChannels); return names; } private bool IsSampleCaptured(BlendshapeGazeProfile profile, int sampleIndex) { if (profile == null) { return false; } if (capturedThisSession.Contains(sampleIndex)) { return true; } int profileSampleIndex = (int)GridSamples[sampleIndex]; if (profileSampleIndex < 0 || profileSampleIndex >= profile.Samples.Count) { return false; } GazeCalibrationSample sample = profile.Samples[profileSampleIndex]; return sample != null && sample.Captured; } private void SetFeedback(string message, MessageType type) { feedbackMessage = message; feedbackType = type; Repaint(); } private static string Normalize(string value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } var characters = new char[value.Length]; int count = 0; for (int index = 0; index < value.Length; index++) { char character = value[index]; if (!char.IsLetterOrDigit(character)) { continue; } characters[count++] = char.ToLowerInvariant(character); } return new string(characters, 0, count); } private readonly struct BlendShapeMatch { internal BlendShapeMatch(string canonicalName, string meshName, int meshIndex) { CanonicalName = canonicalName; MeshName = meshName; MeshIndex = meshIndex; } internal string CanonicalName { get; } internal string MeshName { get; } internal int MeshIndex { get; } } } }