1466 lines
56 KiB
C#
1466 lines
56 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
|
|
namespace Streamingle.Gaze.Editor
|
|
{
|
|
/// <summary>
|
|
/// Guided calibration UI for authoring the five cardinal gaze poses and optionally
|
|
/// refining the generated 3x3 grid. Preview writes are owned by a disposable session
|
|
/// so opening the tool never permanently changes the face renderer.
|
|
/// </summary>
|
|
public sealed class GazeCalibrationWindow : EditorWindow
|
|
{
|
|
private const float PadMinimumSize = 230f;
|
|
private const float PadMaximumSize = 380f;
|
|
private const float MarkerSize = 32f;
|
|
private const float MarkerHitRadius = 23f;
|
|
|
|
private static readonly GazeCalibrationPoint[] BasicPoints =
|
|
{
|
|
GazeCalibrationPoint.Center,
|
|
GazeCalibrationPoint.CenterLeft,
|
|
GazeCalibrationPoint.CenterRight,
|
|
GazeCalibrationPoint.UpCenter,
|
|
GazeCalibrationPoint.DownCenter
|
|
};
|
|
|
|
private static readonly GazeCalibrationPoint[] AllPoints =
|
|
{
|
|
GazeCalibrationPoint.DownLeft,
|
|
GazeCalibrationPoint.DownCenter,
|
|
GazeCalibrationPoint.DownRight,
|
|
GazeCalibrationPoint.CenterLeft,
|
|
GazeCalibrationPoint.Center,
|
|
GazeCalibrationPoint.CenterRight,
|
|
GazeCalibrationPoint.UpLeft,
|
|
GazeCalibrationPoint.UpCenter,
|
|
GazeCalibrationPoint.UpRight
|
|
};
|
|
|
|
private GazeCalibrationPreviewSession previewSession;
|
|
|
|
[SerializeField] private BlendshapeGazeDriver driver;
|
|
[SerializeField] private GazeCalibrationPoint selectedPoint = GazeCalibrationPoint.Center;
|
|
private Vector2 scrollPosition;
|
|
[SerializeField] private bool advancedGrid;
|
|
[SerializeField] private bool liveInterpolation = true;
|
|
private float[] draftWeights = Array.Empty<float>();
|
|
private GazeCalibrationPoint draftPoint;
|
|
private string draftLayout;
|
|
private bool hasDraft;
|
|
private BlendshapeGazeProfile calibrationProfile;
|
|
private BlendshapeGazeProfile workingProfile;
|
|
private bool isFinishingCalibration;
|
|
private float[] copiedWeights;
|
|
private string copiedLayout;
|
|
private string feedback;
|
|
private MessageType feedbackType = MessageType.Info;
|
|
|
|
[MenuItem("Tools/Streamingle Utilities/Gaze/Calibration")]
|
|
private static void OpenFromMenu()
|
|
{
|
|
var selectedDriver = Selection.activeGameObject != null
|
|
? Selection.activeGameObject.GetComponentInParent<BlendshapeGazeDriver>()
|
|
: Selection.activeObject as BlendshapeGazeDriver;
|
|
Open(selectedDriver);
|
|
}
|
|
|
|
public static void Open(BlendshapeGazeDriver driver)
|
|
{
|
|
var window = GetWindow<GazeCalibrationWindow>();
|
|
window.titleContent = new GUIContent("Gaze Calibration");
|
|
window.minSize = new Vector2(430f, 600f);
|
|
window.SetDriver(driver);
|
|
window.Show();
|
|
window.Focus();
|
|
}
|
|
|
|
private GazeCalibrationPreviewSession Session
|
|
{
|
|
get
|
|
{
|
|
if (previewSession == null)
|
|
{
|
|
previewSession = new GazeCalibrationPreviewSession();
|
|
previewSession.Stopped += OnPreviewSessionStopped;
|
|
}
|
|
|
|
return previewSession;
|
|
}
|
|
}
|
|
|
|
private bool IsCalibrating => previewSession != null && previewSession.IsActive;
|
|
|
|
private void OnEnable()
|
|
{
|
|
titleContent = new GUIContent("Gaze Calibration");
|
|
minSize = new Vector2(430f, 600f);
|
|
Undo.undoRedoPerformed += OnUndoRedo;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
Undo.undoRedoPerformed -= OnUndoRedo;
|
|
FinishCalibration(false, false);
|
|
if (previewSession != null)
|
|
{
|
|
previewSession.Stopped -= OnPreviewSessionStopped;
|
|
previewSession.Dispose();
|
|
}
|
|
previewSession = null;
|
|
}
|
|
|
|
private void OnGUI()
|
|
{
|
|
// A session can stop itself on play-mode changes or invalid mappings.
|
|
// Treat that as Cancel so a broken/closed preview never commits a draft.
|
|
if (!IsCalibrating && workingProfile != null)
|
|
FinishCalibration(false, false);
|
|
|
|
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
|
DrawHeader();
|
|
EditorGUILayout.Space(6f);
|
|
DrawDriverField();
|
|
EditorGUILayout.Space(6f);
|
|
DrawSetup();
|
|
EditorGUILayout.Space(8f);
|
|
DrawCalibrationControls();
|
|
|
|
if (IsCalibrating)
|
|
{
|
|
EditorGUILayout.Space(10f);
|
|
DrawDirectionLimits();
|
|
EditorGUILayout.Space(10f);
|
|
DrawPoseTools();
|
|
EditorGUILayout.Space(8f);
|
|
DrawCalibrationPad();
|
|
EditorGUILayout.Space(10f);
|
|
DrawSelectedPoseEditor();
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(feedback))
|
|
{
|
|
EditorGUILayout.Space(8f);
|
|
EditorGUILayout.HelpBox(feedback, feedbackType);
|
|
}
|
|
|
|
EditorGUILayout.EndScrollView();
|
|
}
|
|
|
|
private void DrawHeader()
|
|
{
|
|
EditorGUILayout.LabelField("Blendshape Gaze Calibration", EditorStyles.boldLabel);
|
|
EditorGUILayout.HelpBox(
|
|
"Tune Center, Left, Right, Up and Down directly here. Generate the four corners, " +
|
|
"then use the pad to inspect interpolation without editing the renderer inspector. " +
|
|
"Direction labels use the character's perspective.",
|
|
MessageType.Info);
|
|
}
|
|
|
|
private void DrawDriverField()
|
|
{
|
|
using (new EditorGUI.DisabledScope(IsCalibrating))
|
|
{
|
|
var nextDriver = (BlendshapeGazeDriver)EditorGUILayout.ObjectField(
|
|
"Gaze Driver",
|
|
driver,
|
|
typeof(BlendshapeGazeDriver),
|
|
true);
|
|
if (nextDriver != driver)
|
|
SetDriver(nextDriver);
|
|
}
|
|
|
|
if (driver == null)
|
|
EditorGUILayout.HelpBox("Assign a Blendshape Gaze Driver to begin.", MessageType.Warning);
|
|
}
|
|
|
|
private void DrawSetup()
|
|
{
|
|
EditorGUILayout.LabelField("1. Setup", EditorStyles.boldLabel);
|
|
if (driver == null)
|
|
return;
|
|
|
|
using (new EditorGUI.DisabledScope(IsCalibrating))
|
|
{
|
|
var nextRenderer = (SkinnedMeshRenderer)EditorGUILayout.ObjectField(
|
|
"Target Renderer",
|
|
driver.TargetRenderer,
|
|
typeof(SkinnedMeshRenderer),
|
|
true);
|
|
if (nextRenderer != driver.TargetRenderer)
|
|
AssignRenderer(nextRenderer);
|
|
|
|
var nextProfile = (BlendshapeGazeProfile)EditorGUILayout.ObjectField(
|
|
"Calibration Profile",
|
|
driver.Profile,
|
|
typeof(BlendshapeGazeProfile),
|
|
false);
|
|
if (nextProfile != driver.Profile)
|
|
AssignProfile(nextProfile);
|
|
|
|
var nextOrigin = (Transform)EditorGUILayout.ObjectField(
|
|
"Gaze Origin",
|
|
driver.GazeOrigin,
|
|
typeof(Transform),
|
|
true);
|
|
if (nextOrigin != driver.GazeOrigin)
|
|
AssignOrigin(nextOrigin);
|
|
}
|
|
|
|
DrawSetupChecklist();
|
|
|
|
using (new EditorGUI.DisabledScope(IsCalibrating))
|
|
{
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
using (new EditorGUI.DisabledScope(driver.Profile != null))
|
|
{
|
|
if (GUILayout.Button("Create Profile"))
|
|
CreateProfile();
|
|
}
|
|
|
|
using (new EditorGUI.DisabledScope(
|
|
driver.TargetRenderer == null
|
|
|| driver.TargetRenderer.sharedMesh == null
|
|
|| driver.Profile == null))
|
|
{
|
|
if (GUILayout.Button("Sync ARKit EyeLook 8"))
|
|
SyncEyeLookChannels();
|
|
}
|
|
}
|
|
|
|
using (new EditorGUI.DisabledScope(driver.Profile == null))
|
|
{
|
|
if (GUILayout.Button("Edit Profile Channels Manually"))
|
|
{
|
|
Selection.activeObject = driver.Profile;
|
|
EditorGUIUtility.PingObject(driver.Profile);
|
|
SetFeedback(
|
|
"Edit Channels in the selected profile Inspector, then return here to calibrate.",
|
|
MessageType.Info);
|
|
}
|
|
}
|
|
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
using (new EditorGUI.DisabledScope(driver.TargetRenderer == null))
|
|
{
|
|
if (GUILayout.Button(driver.GazeOrigin == null
|
|
? "Auto Create Gaze Origin"
|
|
: "Recalculate Gaze Origin"))
|
|
AutoCreateOrRecalculateOrigin();
|
|
}
|
|
|
|
using (new EditorGUI.DisabledScope(driver.GazeOrigin == null))
|
|
{
|
|
if (GUILayout.Button("Align Origin Forward to Scene Camera"))
|
|
AlignOriginForwardToSceneCamera();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (driver.HeadReference != null)
|
|
{
|
|
EditorGUILayout.HelpBox(
|
|
"Gaze Origin rotation is the runtime direction basis. Head Reference is used only as a fallback " +
|
|
"when Gaze Origin is not assigned.",
|
|
MessageType.Info);
|
|
}
|
|
}
|
|
|
|
private void DrawSetupChecklist()
|
|
{
|
|
var renderer = driver.TargetRenderer;
|
|
var profile = driver.Profile;
|
|
var hasRendererMesh = renderer != null && renderer.sharedMesh != null;
|
|
var sourceMeshMatches = profile == null
|
|
|| profile.SourceMesh == null
|
|
|| (hasRendererMesh && profile.SourceMesh == renderer.sharedMesh);
|
|
DrawCheck("Renderer and mesh", hasRendererMesh && sourceMeshMatches);
|
|
DrawCheck("Calibration profile", profile != null);
|
|
DrawCheck("Gaze origin", driver.GazeOrigin != null);
|
|
|
|
if (hasRendererMesh && profile != null && !sourceMeshMatches)
|
|
{
|
|
EditorGUILayout.HelpBox(
|
|
$"Profile mesh '{profile.SourceMesh.name}' does not match renderer mesh " +
|
|
$"'{renderer.sharedMesh.name}'. Sync the intended Face mesh before calibration.",
|
|
MessageType.Error);
|
|
}
|
|
|
|
var configured = profile != null ? profile.Channels.Count : 0;
|
|
var mapped = 0;
|
|
var missing = new List<string>();
|
|
if (renderer != null && renderer.sharedMesh != null && profile != null)
|
|
{
|
|
for (var index = 0; index < profile.Channels.Count; index++)
|
|
{
|
|
var channel = profile.Channels[index];
|
|
if (channel != null && profile.FindBlendShapeIndex(renderer.sharedMesh, index) >= 0)
|
|
mapped++;
|
|
else
|
|
missing.Add(channel != null ? channel.BlendShapeName : $"Channel {index + 1}");
|
|
}
|
|
}
|
|
|
|
DrawCheck($"Channels mapped ({mapped}/{configured})", configured > 0 && mapped == configured);
|
|
if (missing.Count > 0)
|
|
{
|
|
EditorGUILayout.HelpBox(
|
|
"Missing on renderer: " + string.Join(", ", missing),
|
|
MessageType.Warning);
|
|
}
|
|
}
|
|
|
|
private static void DrawCheck(string label, bool complete)
|
|
{
|
|
var previous = GUI.color;
|
|
GUI.color = complete ? new Color(0.55f, 0.95f, 0.6f) : new Color(1f, 0.68f, 0.45f);
|
|
EditorGUILayout.LabelField(complete ? "\u2713 " + label : "\u25cb " + label);
|
|
GUI.color = previous;
|
|
}
|
|
|
|
private void DrawCalibrationControls()
|
|
{
|
|
EditorGUILayout.LabelField("2. Calibration", EditorStyles.boldLabel);
|
|
if (!IsCalibrating)
|
|
{
|
|
var canStart = CanStartCalibration(out var reason);
|
|
using (new EditorGUI.DisabledScope(!canStart))
|
|
{
|
|
if (GUILayout.Button("Start Calibration", GUILayout.Height(34f)))
|
|
StartCalibration();
|
|
}
|
|
|
|
if (driver != null && !canStart && !string.IsNullOrEmpty(reason))
|
|
EditorGUILayout.HelpBox(reason, MessageType.Warning);
|
|
}
|
|
else
|
|
{
|
|
var profile = Session.Profile;
|
|
var cardinalCount = CountCaptured(profile, BasicPoints);
|
|
var totalCount = CountCaptured(profile, AllPoints);
|
|
var fullyCalibrated = profile != null && profile.IsFullyCalibrated;
|
|
EditorGUILayout.HelpBox(
|
|
$"Cardinal poses: {cardinalCount}/5 Full grid: {totalCount}/9" +
|
|
(fullyCalibrated
|
|
? "\nReady for runtime interpolation."
|
|
: "\nYou can save a draft now, but runtime gaze stays inactive until 9/9."),
|
|
fullyCalibrated ? MessageType.Info : MessageType.Warning);
|
|
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
var previous = GUI.backgroundColor;
|
|
GUI.backgroundColor = new Color(0.55f, 0.85f, 0.65f);
|
|
var applyLabel = fullyCalibrated
|
|
? "Apply & Finish"
|
|
: "Save Draft & Finish";
|
|
if (GUILayout.Button(applyLabel, GUILayout.Height(34f)))
|
|
{
|
|
GUI.backgroundColor = previous;
|
|
FinishCalibration(true, true);
|
|
return;
|
|
}
|
|
GUI.backgroundColor = previous;
|
|
|
|
if (GUILayout.Button("Cancel & Revert", GUILayout.Height(34f)))
|
|
{
|
|
FinishCalibration(false, true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
EditorGUILayout.HelpBox(
|
|
"Apply saves profile edits. Cancel or closing this window restores both the profile snapshot " +
|
|
"and the face pose from when calibration started.",
|
|
MessageType.None);
|
|
}
|
|
}
|
|
|
|
private void DrawDirectionLimits()
|
|
{
|
|
var profile = Session.Profile;
|
|
if (profile == null)
|
|
return;
|
|
|
|
EditorGUILayout.LabelField("3. Asymmetric View Limits", EditorStyles.boldLabel);
|
|
EditorGUILayout.HelpBox(
|
|
"Angles outside these limits clamp to the closest edge pose. Values are in character-local degrees; " +
|
|
"Left/Right are named from the character's perspective.",
|
|
MessageType.None);
|
|
|
|
var left = profile.MinYaw;
|
|
var right = profile.MaxYaw;
|
|
var down = profile.MinPitch;
|
|
var up = profile.MaxPitch;
|
|
|
|
EditorGUI.BeginChangeCheck();
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
left = EditorGUILayout.Slider(new GUIContent("Left Yaw", "Negative yaw limit."), left, -90f, -0.1f);
|
|
right = EditorGUILayout.Slider(new GUIContent("Right Yaw", "Positive yaw limit."), right, 0.1f, 90f);
|
|
}
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
down = EditorGUILayout.Slider(new GUIContent("Down Pitch", "Negative pitch limit."), down, -90f, -0.1f);
|
|
up = EditorGUILayout.Slider(new GUIContent("Up Pitch", "Positive pitch limit."), up, 0.1f, 90f);
|
|
}
|
|
|
|
if (EditorGUI.EndChangeCheck())
|
|
{
|
|
Undo.RecordObject(profile, "Change Gaze View Limits");
|
|
profile.SetDirectionLimits(left, right, down, up);
|
|
MarkProfileDirty();
|
|
}
|
|
}
|
|
|
|
private void DrawPoseTools()
|
|
{
|
|
var profile = Session.Profile;
|
|
if (profile == null)
|
|
return;
|
|
|
|
EditorGUILayout.LabelField("4. Pose Tools", EditorStyles.boldLabel);
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
if (GUILayout.Button("Auto Seed ARKit"))
|
|
SeedStandardEyeLook();
|
|
|
|
using (new EditorGUI.DisabledScope(!profile.CanGenerateCorners))
|
|
{
|
|
if (GUILayout.Button("Generate Corners"))
|
|
GenerateCorners();
|
|
}
|
|
}
|
|
|
|
var opposite = GetHorizontalOpposite(selectedPoint);
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
using (new EditorGUI.DisabledScope(!profile.IsSampleCaptured(selectedPoint)))
|
|
{
|
|
var mirrorLabel = opposite == selectedPoint
|
|
? "Swap Left/Right Values"
|
|
: $"Mirror to {GetPointLabel(opposite)}";
|
|
if (GUILayout.Button(mirrorLabel))
|
|
MirrorSelected(opposite);
|
|
}
|
|
|
|
using (new EditorGUI.DisabledScope(
|
|
selectedPoint != GazeCalibrationPoint.Center
|
|
&& !profile.IsSampleCaptured(GazeCalibrationPoint.Center)))
|
|
{
|
|
var resetLabel = selectedPoint == GazeCalibrationPoint.Center
|
|
? "Reset Center to Session Start"
|
|
: "Reset Pose to Center";
|
|
if (GUILayout.Button(resetLabel))
|
|
ResetSelectedToCenter();
|
|
}
|
|
}
|
|
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
using (new EditorGUI.DisabledScope(!profile.IsSampleCaptured(selectedPoint)))
|
|
{
|
|
if (GUILayout.Button("Copy Pose"))
|
|
CopySelected();
|
|
}
|
|
|
|
var canPaste = copiedWeights != null
|
|
&& copiedWeights.Length == profile.Channels.Count
|
|
&& copiedLayout == GetChannelLayout(profile);
|
|
using (new EditorGUI.DisabledScope(!canPaste))
|
|
{
|
|
if (GUILayout.Button("Paste Pose"))
|
|
PasteSelected();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawCalibrationPad()
|
|
{
|
|
var profile = Session.Profile;
|
|
if (profile == null)
|
|
return;
|
|
|
|
EditorGUILayout.LabelField("5. Gaze Pad", EditorStyles.boldLabel);
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
var nextAdvanced = EditorGUILayout.ToggleLeft(
|
|
"Advanced 3x3",
|
|
advancedGrid,
|
|
GUILayout.Width(125f));
|
|
if (advancedGrid && !nextAdvanced && !ContainsPoint(BasicPoints, selectedPoint))
|
|
SelectPoint(GazeCalibrationPoint.Center);
|
|
advancedGrid = nextAdvanced;
|
|
liveInterpolation = EditorGUILayout.ToggleLeft(
|
|
"Live Interpolation",
|
|
liveInterpolation,
|
|
GUILayout.Width(145f));
|
|
}
|
|
|
|
if (liveInterpolation && !profile.IsFullyCalibrated)
|
|
{
|
|
EditorGUILayout.HelpBox(
|
|
"Live interpolation becomes available after all nine points exist. Tune five points, then Generate Corners.",
|
|
MessageType.None);
|
|
}
|
|
|
|
var size = Mathf.Clamp(position.width - 72f, PadMinimumSize, PadMaximumSize);
|
|
using (new EditorGUILayout.HorizontalScope())
|
|
{
|
|
GUILayout.FlexibleSpace();
|
|
var padRect = GUILayoutUtility.GetRect(size, size, GUILayout.Width(size), GUILayout.Height(size));
|
|
GUILayout.FlexibleSpace();
|
|
DrawPadBackground(padRect);
|
|
HandlePadInput(padRect, advancedGrid ? AllPoints : BasicPoints);
|
|
DrawPadMarkers(padRect, advancedGrid ? AllPoints : BasicPoints);
|
|
}
|
|
|
|
EditorGUILayout.LabelField(
|
|
$"Selected: {GetPointLabel(selectedPoint)}" +
|
|
(!profile.IsSampleCaptured(selectedPoint)
|
|
? " | Draft (move a slider to save this pose)"
|
|
: string.Empty) +
|
|
(Session.IsFreePreview
|
|
? $" | Live: ({Session.PreviewCoordinates.x:0.00}, {Session.PreviewCoordinates.y:0.00})"
|
|
: string.Empty),
|
|
EditorStyles.centeredGreyMiniLabel);
|
|
}
|
|
|
|
private static void DrawPadBackground(Rect rect)
|
|
{
|
|
EditorGUI.DrawRect(rect, new Color(0.105f, 0.115f, 0.13f));
|
|
EditorGUI.DrawRect(new Rect(rect.x + rect.width * 0.5f, rect.y, 1f, rect.height), new Color(0.35f, 0.38f, 0.42f));
|
|
EditorGUI.DrawRect(new Rect(rect.x, rect.y + rect.height * 0.5f, rect.width, 1f), new Color(0.35f, 0.38f, 0.42f));
|
|
EditorGUI.DrawRect(new Rect(rect.x, rect.y, rect.width, 1f), new Color(0.45f, 0.48f, 0.52f));
|
|
EditorGUI.DrawRect(new Rect(rect.x, rect.yMax - 1f, rect.width, 1f), new Color(0.45f, 0.48f, 0.52f));
|
|
EditorGUI.DrawRect(new Rect(rect.x, rect.y, 1f, rect.height), new Color(0.45f, 0.48f, 0.52f));
|
|
EditorGUI.DrawRect(new Rect(rect.xMax - 1f, rect.y, 1f, rect.height), new Color(0.45f, 0.48f, 0.52f));
|
|
}
|
|
|
|
private void DrawPadMarkers(Rect rect, IReadOnlyList<GazeCalibrationPoint> visiblePoints)
|
|
{
|
|
var profile = Session.Profile;
|
|
var markerStyle = new GUIStyle(EditorStyles.miniButton)
|
|
{
|
|
alignment = TextAnchor.MiddleCenter,
|
|
fontStyle = FontStyle.Bold
|
|
};
|
|
|
|
for (var index = 0; index < visiblePoints.Count; index++)
|
|
{
|
|
var point = visiblePoints[index];
|
|
var center = PointToPad(rect, CoordinatesFor(point));
|
|
var marker = new Rect(
|
|
center.x - MarkerSize * 0.5f,
|
|
center.y - MarkerSize * 0.5f,
|
|
MarkerSize,
|
|
MarkerSize);
|
|
|
|
var previous = GUI.backgroundColor;
|
|
if (point == selectedPoint)
|
|
GUI.backgroundColor = new Color(0.35f, 0.72f, 1f);
|
|
else if (profile.IsSampleCaptured(point))
|
|
GUI.backgroundColor = new Color(0.45f, 0.86f, 0.5f);
|
|
else
|
|
GUI.backgroundColor = new Color(1f, 0.65f, 0.3f);
|
|
|
|
GUI.Label(marker, GetPointGlyph(point), markerStyle);
|
|
GUI.backgroundColor = previous;
|
|
}
|
|
|
|
if (Session.IsFreePreview)
|
|
{
|
|
var live = PointToPad(rect, Session.PreviewCoordinates);
|
|
EditorGUI.DrawRect(new Rect(live.x - 3f, live.y - 3f, 6f, 6f), Color.cyan);
|
|
}
|
|
}
|
|
|
|
private void HandlePadInput(Rect rect, IReadOnlyList<GazeCalibrationPoint> visiblePoints)
|
|
{
|
|
var current = Event.current;
|
|
var controlId = GUIUtility.GetControlID("StreamingleGazeCalibrationPad".GetHashCode(), FocusType.Passive, rect);
|
|
|
|
if (current.type == EventType.MouseDown && current.button == 0 && rect.Contains(current.mousePosition))
|
|
{
|
|
var closest = FindClosestPoint(rect, current.mousePosition, visiblePoints, out var distance);
|
|
if (distance <= MarkerHitRadius || !liveInterpolation || !Session.Profile.IsFullyCalibrated)
|
|
{
|
|
SelectPoint(closest);
|
|
}
|
|
else
|
|
{
|
|
GUIUtility.hotControl = controlId;
|
|
PreviewPadPosition(rect, current.mousePosition);
|
|
}
|
|
current.Use();
|
|
}
|
|
else if (current.type == EventType.MouseDrag && GUIUtility.hotControl == controlId)
|
|
{
|
|
PreviewPadPosition(rect, current.mousePosition);
|
|
current.Use();
|
|
}
|
|
else if (current.type == EventType.MouseUp && GUIUtility.hotControl == controlId)
|
|
{
|
|
PreviewPadPosition(rect, current.mousePosition);
|
|
GUIUtility.hotControl = 0;
|
|
current.Use();
|
|
}
|
|
}
|
|
|
|
private void PreviewPadPosition(Rect rect, Vector2 mousePosition)
|
|
{
|
|
if (!liveInterpolation || !Session.Profile.IsFullyCalibrated)
|
|
return;
|
|
|
|
var normalized = new Vector2(
|
|
Mathf.Clamp((mousePosition.x - rect.x) / rect.width * 2f - 1f, -1f, 1f),
|
|
Mathf.Clamp((rect.yMax - mousePosition.y) / rect.height * 2f - 1f, -1f, 1f));
|
|
Session.PreviewNormalized(normalized);
|
|
SceneView.RepaintAll();
|
|
Repaint();
|
|
}
|
|
|
|
private void DrawSelectedPoseEditor()
|
|
{
|
|
var profile = Session.Profile;
|
|
if (profile == null || profile.Channels.Count == 0)
|
|
return;
|
|
|
|
EnsureDraftLoaded();
|
|
if (draftWeights.Length != profile.Channels.Count)
|
|
return;
|
|
|
|
EditorGUILayout.LabelField($"6. {GetPointLabel(selectedPoint)} Pose", EditorStyles.boldLabel);
|
|
EditorGUILayout.HelpBox(
|
|
profile.IsSampleCaptured(selectedPoint)
|
|
? "The face preview updates immediately. Apply & Finish keeps this pose."
|
|
: "This is a temporary draft copied from Center. Moving any slider saves this point; " +
|
|
"selecting it alone does not mark it complete.",
|
|
profile.IsSampleCaptured(selectedPoint) ? MessageType.None : MessageType.Info);
|
|
|
|
EditorGUI.BeginChangeCheck();
|
|
DrawChannelGroup("Left Eye", ChannelSide.Left, profile, draftWeights);
|
|
DrawChannelGroup("Right Eye", ChannelSide.Right, profile, draftWeights);
|
|
DrawChannelGroup("Other / Correctives", ChannelSide.Other, profile, draftWeights);
|
|
if (EditorGUI.EndChangeCheck())
|
|
{
|
|
Undo.RecordObject(profile, "Edit Gaze Calibration Pose");
|
|
profile.SetSample(selectedPoint, draftWeights);
|
|
MarkProfileDirty();
|
|
Session.PreviewWeights(draftWeights, selectedPoint);
|
|
SceneView.RepaintAll();
|
|
}
|
|
}
|
|
|
|
private static void DrawChannelGroup(
|
|
string title,
|
|
ChannelSide side,
|
|
BlendshapeGazeProfile profile,
|
|
float[] weights)
|
|
{
|
|
var hasAny = false;
|
|
for (var index = 0; index < profile.Channels.Count; index++)
|
|
{
|
|
var channel = profile.Channels[index];
|
|
if (GetChannelSide(channel != null ? channel.BlendShapeName : null) == side)
|
|
{
|
|
hasAny = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hasAny)
|
|
return;
|
|
|
|
EditorGUILayout.Space(3f);
|
|
EditorGUILayout.LabelField(title, EditorStyles.miniBoldLabel);
|
|
for (var index = 0; index < profile.Channels.Count; index++)
|
|
{
|
|
var channel = profile.Channels[index];
|
|
if (channel == null || GetChannelSide(channel.BlendShapeName) != side)
|
|
continue;
|
|
|
|
var tooltip = channel.Usage == GazeChannelUsage.Corrective
|
|
? $"Corrective ({channel.CorrectiveBlendMode})"
|
|
: "EyeLook override";
|
|
var minimum = channel.Usage == GazeChannelUsage.Corrective ? -100f : 0f;
|
|
weights[index] = EditorGUILayout.Slider(
|
|
new GUIContent(channel.BlendShapeName, tooltip),
|
|
weights[index],
|
|
minimum,
|
|
100f);
|
|
}
|
|
}
|
|
|
|
private void StartCalibration()
|
|
{
|
|
if (!CanStartCalibration(out var reason))
|
|
{
|
|
SetFeedback(reason, MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
var originalProfile = driver.Profile;
|
|
var editableProfile = Instantiate(originalProfile);
|
|
editableProfile.name = originalProfile.name;
|
|
editableProfile.hideFlags = HideFlags.HideAndDontSave;
|
|
|
|
if (!Session.Start(driver, editableProfile, out var error))
|
|
{
|
|
DestroyImmediate(editableProfile);
|
|
SetFeedback(error, MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
calibrationProfile = originalProfile;
|
|
workingProfile = editableProfile;
|
|
selectedPoint = GazeCalibrationPoint.Center;
|
|
hasDraft = false;
|
|
LoadDraftForSelectedPoint();
|
|
PreviewDraft();
|
|
SetFeedback(
|
|
"Calibration started. Tune Center first, then the four cardinal poses.",
|
|
MessageType.Info);
|
|
SceneView.RepaintAll();
|
|
}
|
|
|
|
private void FinishCalibration(bool applyChanges, bool showFeedback)
|
|
{
|
|
if (isFinishingCalibration)
|
|
return;
|
|
|
|
isFinishingCalibration = true;
|
|
var originalProfile = calibrationProfile != null
|
|
? calibrationProfile
|
|
: driver != null ? driver.Profile : null;
|
|
var editableProfile = workingProfile != null
|
|
? workingProfile
|
|
: previewSession != null ? previewSession.Profile : null;
|
|
|
|
try
|
|
{
|
|
if (applyChanges && originalProfile != null && editableProfile != null
|
|
&& originalProfile != editableProfile)
|
|
{
|
|
Undo.RecordObject(originalProfile, "Apply Gaze Calibration");
|
|
EditorUtility.CopySerializedManagedFieldsOnly(editableProfile, originalProfile);
|
|
EditorUtility.SetDirty(originalProfile);
|
|
}
|
|
|
|
// Keep the driver override until the original renderer pose has
|
|
// been restored and any working-copy data has been committed.
|
|
previewSession?.Stop();
|
|
|
|
if (applyChanges && originalProfile != null)
|
|
{
|
|
AssetDatabase.SaveAssetIfDirty(originalProfile);
|
|
driver?.RebuildCache();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
previewSession?.Stop();
|
|
if (editableProfile != null && editableProfile != originalProfile)
|
|
{
|
|
Undo.ClearUndo(editableProfile);
|
|
DestroyImmediate(editableProfile);
|
|
}
|
|
|
|
calibrationProfile = null;
|
|
workingProfile = null;
|
|
hasDraft = false;
|
|
draftWeights = Array.Empty<float>();
|
|
draftLayout = null;
|
|
isFinishingCalibration = false;
|
|
SceneView.RepaintAll();
|
|
}
|
|
|
|
if (showFeedback)
|
|
{
|
|
SetFeedback(
|
|
applyChanges
|
|
? "Calibration changes were saved. The original face pose was restored."
|
|
: "Calibration changes were reverted. The original face pose was restored.",
|
|
MessageType.Info);
|
|
}
|
|
}
|
|
|
|
private void OnPreviewSessionStopped()
|
|
{
|
|
if (isFinishingCalibration || workingProfile == null)
|
|
return;
|
|
|
|
FinishCalibration(false, false);
|
|
Repaint();
|
|
}
|
|
|
|
private void EnsureDraftLoaded()
|
|
{
|
|
if (!IsCalibrating || Session.Profile == null)
|
|
return;
|
|
|
|
var layout = GetChannelLayout(Session.Profile);
|
|
if (hasDraft
|
|
&& draftPoint == selectedPoint
|
|
&& draftWeights.Length == Session.Profile.Channels.Count
|
|
&& string.Equals(draftLayout, layout, StringComparison.Ordinal))
|
|
return;
|
|
|
|
LoadDraftForSelectedPoint();
|
|
}
|
|
|
|
private void LoadDraftForSelectedPoint()
|
|
{
|
|
if (!IsCalibrating || Session.Profile == null)
|
|
return;
|
|
|
|
var profile = Session.Profile;
|
|
if (profile.IsSampleCaptured(selectedPoint))
|
|
{
|
|
draftWeights = profile.GetSampleWeightsCopy(selectedPoint);
|
|
}
|
|
else if (selectedPoint != GazeCalibrationPoint.Center
|
|
&& profile.IsSampleCaptured(GazeCalibrationPoint.Center))
|
|
{
|
|
draftWeights = profile.GetSampleWeightsCopy(GazeCalibrationPoint.Center);
|
|
}
|
|
else
|
|
{
|
|
draftWeights = Session.GetOriginalWeightsCopy();
|
|
}
|
|
|
|
if (draftWeights.Length != profile.Channels.Count)
|
|
draftWeights = new float[profile.Channels.Count];
|
|
|
|
draftPoint = selectedPoint;
|
|
draftLayout = GetChannelLayout(profile);
|
|
hasDraft = true;
|
|
}
|
|
|
|
private void PreviewDraft()
|
|
{
|
|
if (!IsCalibrating)
|
|
return;
|
|
EnsureDraftLoaded();
|
|
if (hasDraft)
|
|
Session.PreviewWeights(draftWeights, selectedPoint);
|
|
}
|
|
|
|
private void ReloadDraftAndPreview()
|
|
{
|
|
hasDraft = false;
|
|
LoadDraftForSelectedPoint();
|
|
PreviewDraft();
|
|
SceneView.RepaintAll();
|
|
Repaint();
|
|
}
|
|
|
|
private void SelectPoint(GazeCalibrationPoint point)
|
|
{
|
|
selectedPoint = point;
|
|
hasDraft = false;
|
|
LoadDraftForSelectedPoint();
|
|
PreviewDraft();
|
|
SceneView.RepaintAll();
|
|
Repaint();
|
|
}
|
|
|
|
private void SeedStandardEyeLook()
|
|
{
|
|
var profile = Session.Profile;
|
|
if (HasAnyCapturedSamples(profile)
|
|
&& !EditorUtility.DisplayDialog(
|
|
"Replace Calibration Poses?",
|
|
"Auto Seed ARKit replaces all nine calibration poses with standard 0/100 values.",
|
|
"Replace",
|
|
"Cancel"))
|
|
return;
|
|
|
|
Undo.RecordObject(profile, "Auto Seed ARKit Gaze Poses");
|
|
if (!profile.SeedStandardEyeLook())
|
|
{
|
|
SetFeedback("Auto Seed requires all eight standard ARKit eyeLook channels.", MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
MarkProfileDirty();
|
|
ReloadDraftAndPreview();
|
|
SetFeedback("Seeded five cardinal poses and generated four corners.", MessageType.Info);
|
|
}
|
|
|
|
private void GenerateCorners()
|
|
{
|
|
var profile = Session.Profile;
|
|
var hasExistingCorner = profile.IsSampleCaptured(GazeCalibrationPoint.DownLeft)
|
|
|| profile.IsSampleCaptured(GazeCalibrationPoint.DownRight)
|
|
|| profile.IsSampleCaptured(GazeCalibrationPoint.UpLeft)
|
|
|| profile.IsSampleCaptured(GazeCalibrationPoint.UpRight);
|
|
if (hasExistingCorner
|
|
&& !EditorUtility.DisplayDialog(
|
|
"Regenerate Corners?",
|
|
"This replaces all four corner poses using Center + horizontal delta + vertical delta.",
|
|
"Regenerate",
|
|
"Cancel"))
|
|
return;
|
|
|
|
Undo.RecordObject(profile, "Generate Gaze Corner Poses");
|
|
if (!profile.GenerateCornersFromCardinals())
|
|
{
|
|
SetFeedback("Center, Left, Right, Up and Down must be completed first.", MessageType.Warning);
|
|
return;
|
|
}
|
|
|
|
MarkProfileDirty();
|
|
ReloadDraftAndPreview();
|
|
SetFeedback("Generated four corner poses. Enable Advanced 3x3 to refine them.", MessageType.Info);
|
|
}
|
|
|
|
private void MirrorSelected(GazeCalibrationPoint destination)
|
|
{
|
|
var profile = Session.Profile;
|
|
if (destination != selectedPoint && profile.IsSampleCaptured(destination)
|
|
&& !EditorUtility.DisplayDialog(
|
|
"Replace Mirrored Pose?",
|
|
$"{GetPointLabel(destination)} already contains a pose.",
|
|
"Replace",
|
|
"Cancel"))
|
|
return;
|
|
|
|
Undo.RecordObject(profile, "Mirror Gaze Calibration Pose");
|
|
if (!profile.CopySample(selectedPoint, destination, true))
|
|
return;
|
|
|
|
MarkProfileDirty();
|
|
SelectPoint(destination);
|
|
}
|
|
|
|
private void ResetSelectedToCenter()
|
|
{
|
|
var profile = Session.Profile;
|
|
float[] values;
|
|
if (selectedPoint == GazeCalibrationPoint.Center)
|
|
values = Session.GetOriginalWeightsCopy();
|
|
else
|
|
values = profile.GetSampleWeightsCopy(GazeCalibrationPoint.Center);
|
|
|
|
if (values.Length != profile.Channels.Count)
|
|
return;
|
|
|
|
Undo.RecordObject(profile, "Reset Gaze Pose to Center");
|
|
profile.SetSample(selectedPoint, values);
|
|
MarkProfileDirty();
|
|
ReloadDraftAndPreview();
|
|
}
|
|
|
|
private void CopySelected()
|
|
{
|
|
copiedWeights = Session.Profile.GetSampleWeightsCopy(selectedPoint);
|
|
copiedLayout = GetChannelLayout(Session.Profile);
|
|
SetFeedback($"Copied {GetPointLabel(selectedPoint)} pose.", MessageType.Info);
|
|
}
|
|
|
|
private void PasteSelected()
|
|
{
|
|
var profile = Session.Profile;
|
|
if (copiedWeights == null || copiedWeights.Length != profile.Channels.Count
|
|
|| copiedLayout != GetChannelLayout(profile))
|
|
return;
|
|
|
|
Undo.RecordObject(profile, "Paste Gaze Calibration Pose");
|
|
profile.SetSample(selectedPoint, (float[])copiedWeights.Clone());
|
|
MarkProfileDirty();
|
|
ReloadDraftAndPreview();
|
|
}
|
|
|
|
private void CreateProfile()
|
|
{
|
|
if (driver == null)
|
|
return;
|
|
|
|
var defaultName = driver.gameObject.name + "_GazeProfile";
|
|
var path = EditorUtility.SaveFilePanelInProject(
|
|
"Create Gaze Calibration Profile",
|
|
defaultName,
|
|
"asset",
|
|
"Choose where to create the gaze calibration profile.");
|
|
if (string.IsNullOrWhiteSpace(path))
|
|
return;
|
|
|
|
var profile = CreateInstance<BlendshapeGazeProfile>();
|
|
profile.name = System.IO.Path.GetFileNameWithoutExtension(path);
|
|
AssetDatabase.CreateAsset(profile, path);
|
|
Undo.RegisterCreatedObjectUndo(profile, "Create Gaze Calibration Profile");
|
|
AssignProfile(profile);
|
|
EditorGUIUtility.PingObject(profile);
|
|
SetFeedback("Created and assigned a calibration profile.", MessageType.Info);
|
|
}
|
|
|
|
private void SyncEyeLookChannels()
|
|
{
|
|
var profile = driver.Profile;
|
|
var renderer = driver.TargetRenderer;
|
|
if (profile == null || renderer == null)
|
|
return;
|
|
|
|
if (HasAnyCapturedSamples(profile)
|
|
&& !EditorUtility.DisplayDialog(
|
|
"Resync EyeLook Channels?",
|
|
"Changing the channel layout invalidates existing calibration poses.",
|
|
"Sync and Invalidate",
|
|
"Cancel"))
|
|
return;
|
|
|
|
Undo.RecordObject(profile, "Sync ARKit EyeLook Channels");
|
|
if (!profile.SyncEyeLookChannels(renderer))
|
|
{
|
|
SetFeedback(
|
|
"The renderer must contain all eight ARKit eyeLook blendshapes (prefixes are allowed).",
|
|
MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
EditorUtility.SetDirty(profile);
|
|
driver.RebuildCache();
|
|
SetFeedback("Mapped all eight ARKit eyeLook channels.", MessageType.Info);
|
|
}
|
|
|
|
private void AutoCreateOrRecalculateOrigin()
|
|
{
|
|
var renderer = driver.TargetRenderer;
|
|
if (renderer == null)
|
|
return;
|
|
|
|
if (EditorUtility.IsPersistent(driver) || EditorUtility.IsPersistent(renderer))
|
|
{
|
|
SetFeedback(
|
|
"Open the character in Prefab Mode or use a scene instance before creating Gaze Origin.",
|
|
MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
var estimate = GazeOriginEstimator.Estimate(renderer, GetConfiguredChannelNames(driver.Profile));
|
|
var origin = driver.GazeOrigin;
|
|
var parent = ResolveOriginParent(renderer);
|
|
if (parent == null && origin != null)
|
|
parent = origin.parent;
|
|
if (parent == null)
|
|
{
|
|
SetFeedback(
|
|
"Could not find the character Head. Assign Head Reference, then create Gaze Origin again.",
|
|
MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
var created = false;
|
|
if (origin == null)
|
|
{
|
|
origin = parent.Find("Face_GazeOrigin");
|
|
if (origin == null)
|
|
{
|
|
var originObject = new GameObject("Face_GazeOrigin");
|
|
Undo.RegisterCreatedObjectUndo(originObject, "Create Gaze Origin");
|
|
origin = originObject.transform;
|
|
created = true;
|
|
}
|
|
|
|
Undo.RecordObject(driver, "Assign Gaze Origin");
|
|
driver.GazeOrigin = origin;
|
|
EditorUtility.SetDirty(driver);
|
|
PrefabUtility.RecordPrefabInstancePropertyModifications(driver);
|
|
}
|
|
|
|
if (!created)
|
|
Undo.RecordObject(origin, "Recalculate Gaze Origin");
|
|
|
|
if (origin.parent != parent)
|
|
Undo.SetTransformParent(origin, parent, "Parent Gaze Origin Under Head");
|
|
if (created)
|
|
origin.rotation = parent.rotation;
|
|
|
|
origin.position = estimate.WorldPosition;
|
|
EditorUtility.SetDirty(origin);
|
|
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
|
|
driver.RebuildCache();
|
|
SetFeedback(
|
|
$"{estimate.Message} Parent: {parent.name}.",
|
|
estimate.UsedEyeBlendShapes ? MessageType.Info : MessageType.Warning);
|
|
SceneView.RepaintAll();
|
|
}
|
|
|
|
private Transform ResolveOriginParent(SkinnedMeshRenderer renderer)
|
|
{
|
|
if (driver.HeadReference != null)
|
|
return driver.HeadReference;
|
|
|
|
var animator = renderer != null
|
|
? renderer.GetComponentInParent<Animator>(true)
|
|
: null;
|
|
if (animator == null)
|
|
animator = driver.GetComponentInParent<Animator>(true);
|
|
if (animator != null && animator.avatar != null && animator.isHuman)
|
|
{
|
|
var humanoidHead = animator.GetBoneTransform(HumanBodyBones.Head);
|
|
if (humanoidHead != null)
|
|
return humanoidHead;
|
|
}
|
|
|
|
Transform suffixMatch = null;
|
|
Transform containsMatch = null;
|
|
var bones = renderer != null ? renderer.bones : null;
|
|
if (bones != null)
|
|
{
|
|
for (var index = 0; index < bones.Length; index++)
|
|
{
|
|
var bone = bones[index];
|
|
if (bone == null)
|
|
continue;
|
|
var normalizedName = Normalize(bone.name);
|
|
if (normalizedName == "head")
|
|
return bone;
|
|
if (suffixMatch == null && normalizedName.EndsWith("head", StringComparison.Ordinal))
|
|
suffixMatch = bone;
|
|
else if (containsMatch == null && normalizedName.Contains("head"))
|
|
containsMatch = bone;
|
|
}
|
|
}
|
|
|
|
return suffixMatch != null ? suffixMatch : containsMatch;
|
|
}
|
|
|
|
private void AlignOriginForwardToSceneCamera()
|
|
{
|
|
var origin = driver.GazeOrigin;
|
|
var sceneCamera = SceneView.lastActiveSceneView != null
|
|
? SceneView.lastActiveSceneView.camera
|
|
: null;
|
|
if (origin == null || sceneCamera == null)
|
|
{
|
|
SetFeedback("Open a Scene view and assign Gaze Origin first.", MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
var direction = sceneCamera.transform.position - origin.position;
|
|
if (direction.sqrMagnitude < 0.000001f)
|
|
{
|
|
SetFeedback("The Scene camera is too close to Gaze Origin.", MessageType.Error);
|
|
return;
|
|
}
|
|
|
|
var up = origin.parent != null
|
|
? origin.parent.up
|
|
: driver.HeadReference != null ? driver.HeadReference.up : Vector3.up;
|
|
if (Mathf.Abs(Vector3.Dot(direction.normalized, up.normalized)) > 0.999f)
|
|
up = Vector3.up;
|
|
|
|
Undo.RecordObject(origin, "Align Gaze Origin Forward");
|
|
origin.rotation = Quaternion.LookRotation(direction.normalized, up);
|
|
EditorUtility.SetDirty(origin);
|
|
PrefabUtility.RecordPrefabInstancePropertyModifications(origin);
|
|
driver.RebuildCache();
|
|
SetFeedback(
|
|
"Gaze Origin forward now points toward the Scene camera and remains authoritative at runtime.",
|
|
MessageType.Info);
|
|
SceneView.RepaintAll();
|
|
}
|
|
|
|
private void AssignRenderer(SkinnedMeshRenderer renderer)
|
|
{
|
|
Undo.RecordObject(driver, "Assign Gaze Target Renderer");
|
|
driver.TargetRenderer = renderer;
|
|
MarkDriverDirty();
|
|
}
|
|
|
|
private void AssignProfile(BlendshapeGazeProfile profile)
|
|
{
|
|
Undo.RecordObject(driver, "Assign Gaze Calibration Profile");
|
|
driver.Profile = profile;
|
|
MarkDriverDirty();
|
|
}
|
|
|
|
private void AssignOrigin(Transform origin)
|
|
{
|
|
Undo.RecordObject(driver, "Assign Gaze Origin");
|
|
driver.GazeOrigin = origin;
|
|
MarkDriverDirty();
|
|
}
|
|
|
|
private void MarkDriverDirty()
|
|
{
|
|
EditorUtility.SetDirty(driver);
|
|
PrefabUtility.RecordPrefabInstancePropertyModifications(driver);
|
|
driver.RebuildCache();
|
|
}
|
|
|
|
private void MarkProfileDirty()
|
|
{
|
|
if (Session.Profile != null)
|
|
EditorUtility.SetDirty(Session.Profile);
|
|
Repaint();
|
|
}
|
|
|
|
private void SetDriver(BlendshapeGazeDriver value)
|
|
{
|
|
if (driver == value)
|
|
return;
|
|
|
|
FinishCalibration(false, false);
|
|
driver = value;
|
|
feedback = null;
|
|
Repaint();
|
|
}
|
|
|
|
private void OnUndoRedo()
|
|
{
|
|
if (IsCalibrating)
|
|
{
|
|
ReloadDraftAndPreview();
|
|
}
|
|
Repaint();
|
|
}
|
|
|
|
private void SetFeedback(string message, MessageType type)
|
|
{
|
|
feedback = message;
|
|
feedbackType = type;
|
|
Repaint();
|
|
}
|
|
|
|
private bool CanStartCalibration(out string reason)
|
|
{
|
|
reason = null;
|
|
if (driver == null)
|
|
{
|
|
reason = "Assign a Blendshape Gaze Driver first.";
|
|
return false;
|
|
}
|
|
|
|
var renderer = driver.TargetRenderer;
|
|
if (renderer == null || renderer.sharedMesh == null)
|
|
{
|
|
reason = "Assign a Target Renderer with a Face mesh.";
|
|
return false;
|
|
}
|
|
|
|
var profile = driver.Profile;
|
|
if (profile == null)
|
|
{
|
|
reason = "Create or assign a Calibration Profile.";
|
|
return false;
|
|
}
|
|
|
|
if (!profile.IsCompatibleWith(renderer.sharedMesh))
|
|
{
|
|
reason = "The profile Source Mesh does not match the assigned Face mesh.";
|
|
return false;
|
|
}
|
|
|
|
if (driver.GazeOrigin == null)
|
|
{
|
|
reason = "Create or assign Gaze Origin before calibration.";
|
|
return false;
|
|
}
|
|
|
|
if (profile.Channels.Count == 0)
|
|
{
|
|
reason = "Sync ARKit EyeLook 8 or configure profile Channels manually.";
|
|
return false;
|
|
}
|
|
|
|
for (var index = 0; index < profile.Channels.Count; index++)
|
|
{
|
|
var channel = profile.Channels[index];
|
|
if (channel == null
|
|
|| string.IsNullOrWhiteSpace(channel.BlendShapeName)
|
|
|| profile.FindBlendShapeIndex(renderer.sharedMesh, index) < 0)
|
|
{
|
|
reason = channel == null
|
|
? $"Profile channel {index + 1} is invalid."
|
|
: $"Blendshape '{channel.BlendShapeName}' is missing on the assigned Face mesh.";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static int CountCaptured(
|
|
BlendshapeGazeProfile profile,
|
|
IReadOnlyList<GazeCalibrationPoint> points)
|
|
{
|
|
if (profile == null || points == null)
|
|
return 0;
|
|
var count = 0;
|
|
for (var index = 0; index < points.Count; index++)
|
|
{
|
|
if (profile.IsSampleCaptured(points[index]))
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static bool ContainsPoint(
|
|
IReadOnlyList<GazeCalibrationPoint> points,
|
|
GazeCalibrationPoint point)
|
|
{
|
|
for (var index = 0; index < points.Count; index++)
|
|
{
|
|
if (points[index] == point)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static bool HasAnyCapturedSamples(BlendshapeGazeProfile profile)
|
|
{
|
|
if (profile == null)
|
|
return false;
|
|
for (var index = 0; index < 9; index++)
|
|
{
|
|
if (profile.IsSampleCaptured((GazeCalibrationPoint)index))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static IReadOnlyList<string> GetConfiguredChannelNames(BlendshapeGazeProfile profile)
|
|
{
|
|
var names = new List<string>();
|
|
if (profile == null)
|
|
return names;
|
|
for (var index = 0; index < profile.Channels.Count; index++)
|
|
{
|
|
var channel = profile.Channels[index];
|
|
if (channel != null && !string.IsNullOrWhiteSpace(channel.BlendShapeName))
|
|
names.Add(channel.BlendShapeName);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
private static string GetChannelLayout(BlendshapeGazeProfile profile)
|
|
{
|
|
if (profile == null)
|
|
return string.Empty;
|
|
var names = new string[profile.Channels.Count];
|
|
for (var index = 0; index < names.Length; index++)
|
|
{
|
|
var channel = profile.Channels[index];
|
|
names[index] = channel != null ? channel.BlendShapeName : "<null>";
|
|
}
|
|
return string.Join("|", names);
|
|
}
|
|
|
|
private static GazeCalibrationPoint GetHorizontalOpposite(GazeCalibrationPoint point)
|
|
{
|
|
var index = (int)point;
|
|
var row = index / 3;
|
|
var column = index % 3;
|
|
return (GazeCalibrationPoint)(row * 3 + (2 - column));
|
|
}
|
|
|
|
private static Vector2 CoordinatesFor(GazeCalibrationPoint point)
|
|
{
|
|
var index = (int)point;
|
|
return new Vector2(index % 3 - 1, index / 3 - 1);
|
|
}
|
|
|
|
private static Vector2 PointToPad(Rect rect, Vector2 normalized)
|
|
{
|
|
const float inset = MarkerSize * 0.7f;
|
|
return new Vector2(
|
|
Mathf.Lerp(rect.x + inset, rect.xMax - inset, (normalized.x + 1f) * 0.5f),
|
|
Mathf.Lerp(rect.yMax - inset, rect.y + inset, (normalized.y + 1f) * 0.5f));
|
|
}
|
|
|
|
private static GazeCalibrationPoint FindClosestPoint(
|
|
Rect rect,
|
|
Vector2 mousePosition,
|
|
IReadOnlyList<GazeCalibrationPoint> points,
|
|
out float distance)
|
|
{
|
|
var closest = points[0];
|
|
distance = float.PositiveInfinity;
|
|
for (var index = 0; index < points.Count; index++)
|
|
{
|
|
var candidateDistance = Vector2.Distance(
|
|
mousePosition,
|
|
PointToPad(rect, CoordinatesFor(points[index])));
|
|
if (candidateDistance < distance)
|
|
{
|
|
closest = points[index];
|
|
distance = candidateDistance;
|
|
}
|
|
}
|
|
return closest;
|
|
}
|
|
|
|
private static string GetPointGlyph(GazeCalibrationPoint point)
|
|
{
|
|
switch (point)
|
|
{
|
|
case GazeCalibrationPoint.DownLeft: return "\u2199";
|
|
case GazeCalibrationPoint.DownCenter: return "\u2193";
|
|
case GazeCalibrationPoint.DownRight: return "\u2198";
|
|
case GazeCalibrationPoint.CenterLeft: return "\u2190";
|
|
case GazeCalibrationPoint.Center: return "\u25cf";
|
|
case GazeCalibrationPoint.CenterRight: return "\u2192";
|
|
case GazeCalibrationPoint.UpLeft: return "\u2196";
|
|
case GazeCalibrationPoint.UpCenter: return "\u2191";
|
|
case GazeCalibrationPoint.UpRight: return "\u2197";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
private static string GetPointLabel(GazeCalibrationPoint point)
|
|
{
|
|
switch (point)
|
|
{
|
|
case GazeCalibrationPoint.DownLeft: return "Down Left";
|
|
case GazeCalibrationPoint.DownCenter: return "Down";
|
|
case GazeCalibrationPoint.DownRight: return "Down Right";
|
|
case GazeCalibrationPoint.CenterLeft: return "Left";
|
|
case GazeCalibrationPoint.Center: return "Center";
|
|
case GazeCalibrationPoint.CenterRight: return "Right";
|
|
case GazeCalibrationPoint.UpLeft: return "Up Left";
|
|
case GazeCalibrationPoint.UpCenter: return "Up";
|
|
case GazeCalibrationPoint.UpRight: return "Up Right";
|
|
default: return point.ToString();
|
|
}
|
|
}
|
|
|
|
private static ChannelSide GetChannelSide(string channelName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(channelName))
|
|
return ChannelSide.Other;
|
|
var normalized = Normalize(channelName);
|
|
if (normalized.EndsWith("left", StringComparison.Ordinal))
|
|
return ChannelSide.Left;
|
|
if (normalized.EndsWith("right", StringComparison.Ordinal))
|
|
return ChannelSide.Right;
|
|
return ChannelSide.Other;
|
|
}
|
|
|
|
private static string Normalize(string value)
|
|
{
|
|
var characters = new char[value.Length];
|
|
var count = 0;
|
|
for (var index = 0; index < value.Length; index++)
|
|
{
|
|
var character = value[index];
|
|
if (char.IsLetterOrDigit(character))
|
|
characters[count++] = char.ToLowerInvariant(character);
|
|
}
|
|
return new string(characters, 0, count);
|
|
}
|
|
|
|
private enum ChannelSide
|
|
{
|
|
Left,
|
|
Right,
|
|
Other
|
|
}
|
|
}
|
|
}
|