Files
XRLib/Assets/Scripts/Studio/Manager/SelectionManager.cs

711 lines
27 KiB
C#
Raw Normal View History

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using EPOOutline;
using UnityEngine;
using UVC.Core;
using UVC.Studio.Config;
using UVC.Studio.Tab;
using UVC.UI.Window.PropertyWindow;
namespace UVC.Studio.Manager
{
/// <summary>
/// 스테이지 객체의 선택 상태를 관리하는 매니저 클래스
/// </summary>
public class SelectionManager
{
/// <summary>
/// 현재 선택된 StageObject 목록
/// </summary>
private readonly HashSet<StageObjectManager.StageObject> _selectedObjects = new();
/// <summary>
/// StageObjectManager 참조
/// </summary>
private readonly StageObjectManager _stageObjectManager;
/// <summary>
/// PropertyWindow 참조
/// </summary>
private readonly PropertyWindow? _propertyWindow;
/// <summary>
/// 현재 PropertyWindow에 표시 중인 StageObject
/// </summary>
private StageObjectManager.StageObject? _currentDisplayedObject;
/// <summary>
/// 미리보기 색상 적용 전 원본 Material 색상을 저장하는 딕셔너리
/// Key: Renderer, Value: (프로퍼티 이름, 원본 색상)
/// </summary>
private readonly Dictionary<Renderer, (string propertyName, Color color)> _originalColors = new();
/// <summary>
/// 선택 변경 시 발생하는 이벤트
/// (선택된 객체, 선택 여부)
/// </summary>
public event Action<StageObjectManager.StageObject, bool>? OnSelectionChanged;
/// <summary>
/// 객체 이름이 변경되었을 때 발생하는 이벤트
/// (StageObject ID, 새 이름)
/// </summary>
public event Action<string, string>? OnObjectNameChanged;
/// <summary>
/// 현재 선택된 객체 목록 (읽기 전용)
/// </summary>
public IReadOnlyCollection<StageObjectManager.StageObject> SelectedObjects => _selectedObjects;
/// <summary>
/// 선택된 객체 수
/// </summary>
public int SelectedCount => _selectedObjects.Count;
/// <summary>
/// 생성자
/// </summary>
/// <param name="stageObjectManager">StageObjectManager 참조</param>
/// <param name="propertyWindow">PropertyWindow 참조 (선택)</param>
public SelectionManager(StageObjectManager stageObjectManager, PropertyWindow? propertyWindow = null)
{
_stageObjectManager = stageObjectManager;
_propertyWindow = propertyWindow;
// PropertyWindow 값 변경 이벤트 구독
if (_propertyWindow != null)
{
_propertyWindow.PropertyValueChanged += OnPropertyValueChanged;
}
}
/// <summary>
/// 객체를 선택합니다
/// </summary>
/// <param name="stageObject">선택할 객체</param>
/// <param name="addToSelection">기존 선택에 추가할지 여부 (false면 기존 선택 해제)</param>
public void Select(StageObjectManager.StageObject stageObject, bool addToSelection = false)
{
if (stageObject == null) return;
// 다중 선택이 아니면 기존 선택 해제
if (!addToSelection)
{
DeselectAll();
}
// 이미 선택되어 있으면 무시
if (_selectedObjects.Contains(stageObject)) return;
// 선택 추가
_selectedObjects.Add(stageObject);
// Outlinable 활성화
SetOutlinableEnabled(stageObject, true);
Debug.Log($"[SelectionManager] Selected: {stageObject.GameObject?.name}");
OnSelectionChanged?.Invoke(stageObject, true);
// PropertyWindow에 선택된 객체의 속성 표시
DisplayEquipmentProperties(stageObject);
}
/// <summary>
/// StageObject ID로 객체를 선택합니다
/// </summary>
/// <param name="stageObjectId">선택할 객체의 ID</param>
/// <param name="addToSelection">기존 선택에 추가할지 여부</param>
public void SelectById(string stageObjectId, bool addToSelection = false)
{
var stageObject = _stageObjectManager.GetById(stageObjectId);
if (stageObject != null)
{
Select(stageObject, addToSelection);
}
}
/// <summary>
/// 객체 선택을 해제합니다
/// </summary>
/// <param name="stageObject">선택 해제할 객체</param>
public void Deselect(StageObjectManager.StageObject stageObject)
{
if (stageObject == null) return;
if (_selectedObjects.Remove(stageObject))
{
// Outlinable 비활성화
SetOutlinableEnabled(stageObject, false);
Debug.Log($"[SelectionManager] Deselected: {stageObject.GameObject?.name}");
OnSelectionChanged?.Invoke(stageObject, false);
// 모든 선택이 해제되면 정리 작업 수행
if (_selectedObjects.Count == 0)
{
// 미리보기 색상 원복 및 클리어
ClearPreviewColor();
// PropertyWindow 항목 모두 제거
if (_propertyWindow != null)
{
_propertyWindow.Clear();
}
_currentDisplayedObject = null;
}
}
}
/// <summary>
/// StageObject ID로 객체 선택을 해제합니다
/// </summary>
/// <param name="stageObjectId">선택 해제할 객체의 ID</param>
public void DeselectById(string stageObjectId)
{
var stageObject = _stageObjectManager.GetById(stageObjectId);
if (stageObject != null)
{
Deselect(stageObject);
}
}
/// <summary>
/// 모든 선택을 해제합니다
/// </summary>
public void DeselectAll()
{
var objectsToDeselect = new List<StageObjectManager.StageObject>(_selectedObjects);
foreach (var obj in objectsToDeselect)
{
Deselect(obj);
}
}
/// <summary>
/// 객체가 선택되어 있는지 확인합니다
/// </summary>
/// <param name="stageObject">확인할 객체</param>
/// <returns>선택 여부</returns>
public bool IsSelected(StageObjectManager.StageObject stageObject)
{
return stageObject != null && _selectedObjects.Contains(stageObject);
}
/// <summary>
/// 선택 상태를 토글합니다
/// </summary>
/// <param name="stageObject">토글할 객체</param>
/// <param name="addToSelection">다중 선택 모드</param>
public void ToggleSelection(StageObjectManager.StageObject stageObject, bool addToSelection = false)
{
if (IsSelected(stageObject))
{
Deselect(stageObject);
}
else
{
Select(stageObject, addToSelection);
}
}
/// <summary>
/// Outlinable 컴포넌트의 활성화 상태를 설정합니다
/// </summary>
private void SetOutlinableEnabled(StageObjectManager.StageObject stageObject, bool enabled)
{
if (stageObject.GameObject == null)
{
Debug.LogWarning("[SelectionManager] SetOutlinableEnabled: GameObject is null");
return;
}
var outlinable = stageObject.GameObject.GetComponent<Outlinable>();
if (outlinable == null)
{
// 자식에서도 검색
outlinable = stageObject.GameObject.GetComponentInChildren<Outlinable>();
}
if (outlinable != null)
{
// Outlinable 설정이 제대로 되어있는지 확인하고 필요시 설정
EnsureOutlinableSettings(outlinable);
outlinable.enabled = enabled;
Debug.Log($"[SelectionManager] SetOutlinableEnabled: {stageObject.GameObject.name} -> {enabled}, Targets: {outlinable.OutlineTargets.Count}, RenderStyle: {outlinable.RenderStyle}, FrontEnabled: {outlinable.FrontParameters.Enabled}, FrontColor: {outlinable.FrontParameters.Color}");
// Outliner 존재 여부 확인
var outliner = UnityEngine.Object.FindFirstObjectByType<Outliner>();
if (outliner != null)
{
Debug.Log($"[SelectionManager] Outliner found on: {outliner.gameObject.name}, enabled: {outliner.enabled}");
}
else
{
Debug.LogWarning("[SelectionManager] Outliner component not found in scene! EPOOutline requires Outliner on camera.");
}
}
else
{
Debug.LogWarning($"[SelectionManager] SetOutlinableEnabled: Outlinable not found on {stageObject.GameObject.name}");
}
}
/// <summary>
/// Outlinable의 설정이 제대로 되어있는지 확인하고 필요시 설정합니다
/// </summary>
private void EnsureOutlinableSettings(Outlinable outlinable)
{
// RenderStyle을 FrontBack으로 강제 설정
if (outlinable.RenderStyle != RenderStyle.FrontBack)
{
outlinable.RenderStyle = RenderStyle.FrontBack;
}
// Front 아웃라인이 비활성화 되어있으면 활성화
if (!outlinable.FrontParameters.Enabled)
{
outlinable.FrontParameters.Enabled = true;
outlinable.FrontParameters.Color = new Color(1f, 0.5f, 0f, 1f); // 주황색
outlinable.FrontParameters.DilateShift = 1.0f;
outlinable.FrontParameters.BlurShift = 1.0f;
}
// Back 아웃라인 설정
if (!outlinable.BackParameters.Enabled)
{
outlinable.BackParameters.Enabled = true;
outlinable.BackParameters.Color = new Color(1f, 0.5f, 0f, 0.5f); // 반투명 주황색
outlinable.BackParameters.DilateShift = 1.0f;
outlinable.BackParameters.BlurShift = 1.0f;
}
// OutlineTargets가 비어있으면 Renderer들을 등록
if (outlinable.OutlineTargets.Count == 0)
{
var renderers = outlinable.GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
{
var target = new OutlineTarget(renderer)
{
CullMode = UnityEngine.Rendering.CullMode.Off
};
outlinable.AddTarget(target);
}
Debug.Log($"[SelectionManager] Added {renderers.Length} OutlineTargets to Outlinable");
}
}
/// <summary>
/// 객체의 가시성을 설정합니다
/// </summary>
/// <param name="stageObject">대상 객체</param>
/// <param name="visible">가시성 여부</param>
public void SetVisibility(StageObjectManager.StageObject stageObject, bool visible)
{
if (stageObject?.GameObject != null)
{
stageObject.GameObject.SetActive(visible);
Debug.Log($"[SelectionManager] SetVisibility: {stageObject.GameObject.name} = {visible}");
}
}
/// <summary>
/// StageObject ID로 객체의 가시성을 설정합니다
/// </summary>
/// <param name="stageObjectId">대상 객체의 ID</param>
/// <param name="visible">가시성 여부</param>
public void SetVisibilityById(string stageObjectId, bool visible)
{
var stageObject = _stageObjectManager.GetById(stageObjectId);
if (stageObject != null)
{
SetVisibility(stageObject, visible);
}
}
#region Preview Color
/// <summary>
/// 선택된 객체들의 Material 색상을 미리보기 색상으로 변경합니다.
/// 원본 색상은 저장되어 ClearPreviewColor 호출 시 복원됩니다.
/// </summary>
/// <param name="previewColor">미리보기 색상</param>
/// <summary>
/// Material에서 색상 프로퍼티 이름을 찾습니다.
/// 다양한 셰이더 (Standard, URP, glTF 등)를 지원합니다.
/// </summary>
private static string? GetColorPropertyName(Material material)
{
// 일반적인 색상 프로퍼티 이름들 (우선순위 순)
string[] colorPropertyNames = {
"_Color", // Standard Shader
"_BaseColor", // URP Lit Shader
"baseColorFactor", // glTFast Shader
"_BaseColorFactor", // glTF Shader (대체)
"Base_Color", // Shader Graph 기본 이름
"_MainColor" // 커스텀 셰이더
};
foreach (var propName in colorPropertyNames)
{
if (material.HasProperty(propName))
{
return propName;
}
}
// 찾지 못한 경우 디버그용: Material의 모든 프로퍼티 출력
// Debug.LogWarning($"[SelectionManager] Shader: {material.shader.name}");
// var propertyCount = material.shader.GetPropertyCount();
// for (int i = 0; i < propertyCount; i++)
// {
// var propName = material.shader.GetPropertyName(i);
// var propType = material.shader.GetPropertyType(i);
// if (propType == UnityEngine.Rendering.ShaderPropertyType.Color)
// {
// Debug.LogWarning($"[SelectionManager] Color Property: {propName}");
// }
// }
return null;
}
public void PreviewColor(Color previewColor)
{
if (_selectedObjects.Count == 0) return;
foreach (var stageObject in _selectedObjects)
{
if (stageObject.GameObject == null) continue;
var renderers = stageObject.GameObject.GetComponentsInChildren<Renderer>();
foreach (var renderer in renderers)
{
if (renderer == null) continue;
var colorPropName = GetColorPropertyName(renderer.material);
if (colorPropName == null) continue;
// 원본 색상 저장 (아직 저장되지 않은 경우에만)
if (!_originalColors.ContainsKey(renderer))
{
_originalColors[renderer] = (colorPropName, renderer.material.GetColor(colorPropName));
}
// 미리보기 색상 적용
renderer.material.SetColor(colorPropName, previewColor);
}
}
}
/// <summary>
/// 미리보기 색상을 해제하고 원본 Material 색상으로 복원합니다.
/// </summary>
public void ClearPreviewColor()
{
foreach (var kvp in _originalColors)
{
var renderer = kvp.Key;
var (propertyName, originalColor) = kvp.Value;
if (renderer == null) continue;
// 저장된 프로퍼티 이름으로 원본 색상 복원
renderer.material.SetColor(propertyName, originalColor);
}
_originalColors.Clear();
}
#endregion
#region PropertyWindow Integration
/// <summary>
/// StageObject의 Equipment PropertiesInfo를 PropertyWindow에 표시합니다.
/// </summary>
/// <param name="stageObject">표시할 StageObject</param>
private void DisplayEquipmentProperties(StageObjectManager.StageObject stageObject)
{
if (_propertyWindow == null) return;
_currentDisplayedObject = stageObject;
var equipment = stageObject.Equipment;
var entries = new List<IPropertyEntry>();
int orderIndex = 0;
// 1. object_name 속성 추가 (수정 가능, 그룹 없이 개별)
var nameProperty = new StringProperty("object_name", "Name",
stageObject.GameObject != null ? stageObject.GameObject.name : "Unknown")
{
IsReadOnly = false,
Order = orderIndex++
};
entries.Add(nameProperty);
// 2. Transform 그룹 추가
if (stageObject.GameObject != null)
{
var transform = stageObject.GameObject.transform;
var transformGroup = new PropertyGroup("transform", "Transform", order: orderIndex++);
transformGroup.AddItems(new IPropertyItem[]
{
new Vector3Property("transform_position", "Position", transform.localPosition),
new Vector3Property("transform_rotation", "Rotation", transform.localEulerAngles),
new Vector3Property("transform_scale", "Scale", transform.localScale)
});
entries.Add(transformGroup);
}
// 3. Equipment의 PropertiesInfo를 PropertyGroup으로 변환
if (equipment?.propertiesInfo != null)
{
foreach (var propInfo in equipment.propertiesInfo)
{
// section이 "root"이면 그룹 없이 개별 등록
if (string.Equals(propInfo.section, "root", StringComparison.OrdinalIgnoreCase))
{
foreach (var prop in propInfo.properties)
{
var propertyItem = CreatePropertyItem(prop, orderIndex++);
if (propertyItem != null)
{
entries.Add(propertyItem);
}
}
}
else
{
// 일반 섹션은 그룹으로 묶음
var group = new PropertyGroup(
$"section_{propInfo.section}",
propInfo.section ?? "Properties",
order: orderIndex++
);
foreach (var prop in propInfo.properties)
{
var propertyItem = CreatePropertyItem(prop);
if (propertyItem != null)
{
group.AddItem(propertyItem);
}
}
if (group.Count > 0)
{
entries.Add(group);
}
}
}
}
// 4. StatusInfo 추가
if (equipment?.statusInfo != null)
{
// Network 상태 섹션
if (equipment.statusInfo.network != null)
{
var networkGroup = CreateStatusSectionGroup(
equipment.statusInfo.network,
"status_network",
orderIndex++
);
if (networkGroup != null)
{
entries.Add(networkGroup);
}
}
// Equipment 상태 섹션
if (equipment.statusInfo.equipment != null)
{
var equipmentGroup = CreateStatusSectionGroup(
equipment.statusInfo.equipment,
"status_equipment",
orderIndex++
);
if (equipmentGroup != null)
{
entries.Add(equipmentGroup);
}
}
}
_propertyWindow.LoadMixedProperties(entries);
}
/// <summary>
/// PropertyWindow에서 속성 값이 변경되었을 때 호출되는 핸들러
/// </summary>
private void OnPropertyValueChanged(object? sender, PropertyValueChangedEventArgs e)
{
if (_currentDisplayedObject == null) return;
switch (e.PropertyId)
{
case "object_name":
HandleObjectNameChanged(e.NewValue?.ToString() ?? "");
break;
case "transform_position":
if (_currentDisplayedObject.GameObject != null && e.NewValue is Vector3 pos)
{
_currentDisplayedObject.GameObject.transform.localPosition = pos;
}
break;
case "transform_rotation":
if (_currentDisplayedObject.GameObject != null && e.NewValue is Vector3 rot)
{
_currentDisplayedObject.GameObject.transform.localEulerAngles = rot;
}
break;
case "transform_scale":
if (_currentDisplayedObject.GameObject != null && e.NewValue is Vector3 scale)
{
_currentDisplayedObject.GameObject.transform.localScale = scale;
}
break;
}
}
/// <summary>
/// 객체 이름 변경 처리
/// </summary>
/// <param name="newName">새 이름</param>
private void HandleObjectNameChanged(string newName)
{
if (_currentDisplayedObject == null || string.IsNullOrEmpty(newName)) return;
// 1. GameObject.name 변경
if (_currentDisplayedObject.GameObject != null)
{
_currentDisplayedObject.GameObject.name = newName;
}
// 2. HierarchyWindow 반영
if (InjectorAppContext.Instance != null)
{
var hierarchy = InjectorAppContext.Instance.Get<StudioSideTabBarHierarchy>();
if (hierarchy != null)
{
hierarchy.UpdateItemName(_currentDisplayedObject.Id, newName);
}
}
// 3. 이벤트 발생
OnObjectNameChanged?.Invoke(_currentDisplayedObject.Id, newName);
Debug.Log($"[SelectionManager] Object name changed: {_currentDisplayedObject.Id} -> {newName}");
}
/// <summary>
/// StatusSection을 PropertyGroup으로 변환합니다.
/// ColorStateProperty를 사용하여 label, stat, color를 하나의 항목으로 표시합니다.
/// </summary>
/// <param name="section">변환할 StatusSection</param>
/// <param name="groupIdPrefix">그룹 ID 접두사</param>
/// <param name="order">순서</param>
/// <returns>생성된 PropertyGroup, 속성이 없으면 null</returns>
private static PropertyGroup? CreateStatusSectionGroup(StatusSection section, string groupIdPrefix, int order)
{
if (section.properties == null || section.properties.Count == 0)
return null;
var group = new PropertyGroup(
groupIdPrefix,
section.section ?? "Status",
order: order
);
for (int i = 0; i < section.properties.Count; i++)
{
var statusProp = section.properties[i];
string propId = $"{groupIdPrefix}_{i}";
// value가 null이 아닌 경우에만 Color 파싱
Color? color = statusProp.value != null ? ParseHexColor(statusProp.value) : null;
// ColorStateProperty 생성: Tuple<label, stat, color?>
var colorStateProperty = new ColorStateProperty(
propId,
statusProp.label ?? "", // name으로 label 사용
new Tuple<string, Color?>(
statusProp.stat ?? "",
color
)
);
group.AddItem(colorStateProperty);
}
return group.Count > 0 ? group : null;
}
/// <summary>
/// HEX 색상 코드를 Color로 변환합니다.
/// </summary>
/// <param name="hex">HEX 색상 코드 (예: "#FF0000")</param>
/// <returns>변환된 Color, 실패 시 white</returns>
private static Color ParseHexColor(string hex)
{
if (string.IsNullOrEmpty(hex))
return Color.white;
// # 제거
if (hex.StartsWith("#"))
hex = hex[1..];
if (ColorUtility.TryParseHtmlString($"#{hex}", out Color color))
return color;
return Color.white;
}
/// <summary>
/// Library.PropertyItem을 PropertyWindow.IPropertyItem으로 변환합니다.
/// </summary>
/// <param name="prop">변환할 PropertyItem</param>
/// <param name="order">순서 (선택)</param>
/// <returns>변환된 IPropertyItem</returns>
private static IPropertyItem CreatePropertyItem(PropertyItem prop, int order = 0)
{
string id = prop.id ?? Guid.NewGuid().ToString("N")[..8];
string label = prop.label ?? id;
// 단위가 있으면 레이블에 표시
if (!string.IsNullOrEmpty(prop.unit))
{
label = $"{label} ({prop.unit})";
}
IPropertyItem item = prop.type?.ToLower() switch
{
"float" => new FloatProperty(id, label, prop.GetFloatValue()),
"int" => new IntProperty(id, label, prop.GetIntValue()),
_ => new StringProperty(id, label, prop.GetStringValue())
};
if (item is PropertyItem<string> strItem)
{
strItem.Order = order;
}
else if (item is PropertyItem<float> floatItem)
{
floatItem.Order = order;
}
else if (item is PropertyItem<int> intItem)
{
intItem.Order = order;
}
return item;
}
#endregion
}
}