#nullable enable
using System;
using System.Collections.Generic;
using EPOOutline;
using RTGLite;
using UnityEngine;
using UVC.Core;
using UVC.Studio.Command;
using UVC.Studio.Config;
using UVC.Studio.Window;
using UVC.UIToolkit;
namespace UVC.Studio.Manager
{
///
/// 변환 도구 타입
///
public enum TransformToolType
{
/// 선택 도구 (기즈모 없음)
Select,
/// 이동 도구
Move,
/// 회전 도구
Rotate,
/// 크기 조절 도구
Scale
}
///
/// 스테이지 객체의 선택 상태를 관리하는 매니저 클래스
///
public class SelectionManager
{
#region Private Fields - Selection
///
/// 현재 선택된 StageObject 목록
///
private readonly HashSet _selectedObjects = new();
///
/// StageObjectManager 참조
///
private readonly StageObjectManager _stageObjectManager;
///
/// PropertyWindow 참조
///
private readonly UTKPropertyTabListWindow? _propertyWindow;
///
/// 현재 PropertyWindow에 표시 중인 StageObject
///
private StageObjectManager.StageObject? _currentDisplayedObject;
///
/// 현재 PropertyWindow에 표시 중인 자식 Transform (모델의 자식 선택 시)
///
private Transform? _currentDisplayedChildTransform;
///
/// 미리보기 색상 적용 전 원본 Material 색상을 저장하는 딕셔너리
/// Key: Renderer, Value: Material 인덱스별 (프로퍼티 이름, 원본 색상) 리스트
///
private readonly Dictionary> _originalColors = new();
///
/// 캐싱된 Outliner 참조 (매번 FindFirstObjectByType 호출 방지)
///
private Outliner? _cachedOutliner;
///
/// Outliner 캐시 초기화 여부
///
private bool _outlinerCacheInitialized = false;
#endregion
#region Private Fields - Gizmos
///
/// 이동 기즈모
///
private MoveGizmo? _moveGizmo;
///
/// 회전 기즈모
///
private RotateGizmo? _rotateGizmo;
///
/// 크기 조절 기즈모
///
private ScaleGizmo? _scaleGizmo;
///
/// 현재 활성화된 변환 도구 타입
///
private TransformToolType _currentToolType = TransformToolType.Select;
///
/// 기즈모 초기화 여부
///
private bool _gizmosInitialized = false;
///
/// 기즈모 타겟 객체 리스트 (캐시)
///
private readonly List _gizmoTargetObjects = new();
#endregion
#region Events
///
/// 선택 변경 시 발생하는 이벤트
/// (선택된 객체, 선택 여부)
///
public event Action? OnSelectionChanged;
///
/// 객체 이름이 변경되었을 때 발생하는 이벤트
/// (StageObject ID, 새 이름)
///
public event Action? OnObjectNameChanged;
///
/// 변환 도구가 변경되었을 때 발생하는 이벤트
///
public event Action? OnToolChanged;
#endregion
#region Public Properties
///
/// 현재 선택된 객체 목록 (읽기 전용)
///
public IReadOnlyCollection SelectedObjects => _selectedObjects;
///
/// 선택된 객체 수
///
public int SelectedCount => _selectedObjects.Count;
///
/// 현재 활성화된 변환 도구 타입
///
public TransformToolType CurrentToolType => _currentToolType;
#endregion
///
/// 생성자
///
/// StageObjectManager 참조
/// PropertyWindow 참조 (선택)
public SelectionManager(StageObjectManager stageObjectManager, UTKPropertyTabListWindow? propertyWindow = null)
{
_stageObjectManager = stageObjectManager;
_propertyWindow = propertyWindow;
// PropertyWindow 값 변경 이벤트 구독
if (_propertyWindow != null)
{
_propertyWindow.OnPropertyValueChanged += OnPropertyValueChanged;
}
}
///
/// 객체를 선택합니다
///
/// 선택할 객체
/// 기존 선택에 추가할지 여부 (false면 기존 선택 해제)
/// 기존 선택 해제 시 HierarchyWindow 선택도 해제할지 여부
public void Select(StageObjectManager.StageObject stageObject, bool addToSelection = false, bool clearHierarchySelection = true)
{
if (stageObject == null) return;
// 다중 선택이 아니면 기존 선택 해제
if (!addToSelection)
{
DeselectAll(clearHierarchySelection);
}
// 이미 선택되어 있으면 무시
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);
// 기즈모 타겟 업데이트
UpdateGizmoTargets();
}
///
/// StageObject ID로 객체를 선택합니다
///
/// 선택할 객체의 ID
/// 기존 선택에 추가할지 여부
/// 기존 선택 해제 시 HierarchyWindow 선택도 해제할지 여부
public void SelectById(string stageObjectId, bool addToSelection = false, bool clearHierarchySelection = true)
{
var stageObject = _stageObjectManager.GetById(stageObjectId);
if (stageObject != null)
{
Select(stageObject, addToSelection, clearHierarchySelection);
}
}
///
/// 객체 선택을 해제합니다
///
/// 선택 해제할 객체
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;
_gizmoTargetObjects.Clear();
}
// 기즈모 타겟 업데이트
UpdateGizmoTargets();
}
}
///
/// StageObject ID로 객체 선택을 해제합니다
///
/// 선택 해제할 객체의 ID
public void DeselectById(string stageObjectId)
{
var stageObject = _stageObjectManager.GetById(stageObjectId);
if (stageObject != null)
{
Deselect(stageObject);
}
}
///
/// 모든 선택을 해제합니다
///
/// HierarchyWindow 선택도 해제할지 여부 (기본값: true)
public void DeselectAll(bool clearHierarchySelection = true)
{
var objectsToDeselect = new List(_selectedObjects);
foreach (var obj in objectsToDeselect)
{
Deselect(obj);
}
// UTKComponentListWindow(TreeList) 선택도 해제
// UTKComponentListWindow에서 선택 처리 중일 때는 TreeList가 자체적으로 선택을 관리하므로 생략
if (clearHierarchySelection)
{
var hierarchy = InjectorAppContext.Instance.Get();
if (hierarchy != null)
{
hierarchy.ClearSelection();
}
}
// 자식 Transform 표시도 정리
ClearChildTransformDisplay();
}
///
/// 객체가 선택되어 있는지 확인합니다
///
/// 확인할 객체
/// 선택 여부
public bool IsSelected(StageObjectManager.StageObject stageObject)
{
return stageObject != null && _selectedObjects.Contains(stageObject);
}
///
/// 선택 상태를 토글합니다
///
/// 토글할 객체
/// 다중 선택 모드
public void ToggleSelection(StageObjectManager.StageObject stageObject, bool addToSelection = false)
{
if (IsSelected(stageObject))
{
Deselect(stageObject);
}
else
{
Select(stageObject, addToSelection);
}
}
///
/// Outlinable 컴포넌트의 활성화 상태를 설정합니다
///
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();
if (outlinable == null)
{
// 자식에서도 검색
outlinable = stageObject.GameObject.GetComponentInChildren();
}
if (outlinable != null)
{
outlinable.enabled = enabled;
// Outliner 존재 여부 확인 (캐싱)
if (!_outlinerCacheInitialized)
{
_cachedOutliner = UnityEngine.Object.FindFirstObjectByType();
_outlinerCacheInitialized = true;
if (_cachedOutliner == null)
{
Debug.LogWarning("[SelectionManager] Outliner component not found in scene! EPOOutline requires Outliner on camera.");
}
}
}
}
///
/// 객체의 가시성을 설정합니다
///
/// 대상 객체
/// 가시성 여부
public void SetVisibility(StageObjectManager.StageObject stageObject, bool visible)
{
if (stageObject?.GameObject != null)
{
stageObject.GameObject.SetActive(visible);
Debug.Log($"[SelectionManager] SetVisibility: {stageObject.GameObject.name} = {visible}");
}
}
///
/// StageObject ID로 객체의 가시성을 설정합니다
///
/// 대상 객체의 ID
/// 가시성 여부
public void SetVisibilityById(string stageObjectId, bool visible)
{
var stageObject = _stageObjectManager.GetById(stageObjectId);
if (stageObject != null)
{
SetVisibility(stageObject, visible);
}
}
#region Preview Color
///
/// 선택된 객체들의 Material 색상을 미리보기 색상으로 변경합니다.
/// 원본 색상은 저장되어 ClearPreviewColor 호출 시 복원됩니다.
///
/// 미리보기 색상
///
/// Material에서 색상 프로퍼티 이름을 찾습니다.
/// 다양한 셰이더 (Standard, URP, glTF 등)를 지원합니다.
///
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();
foreach (var renderer in renderers)
{
if (renderer == null) continue;
var materials = renderer.materials;
// 원본 색상 저장 (아직 저장되지 않은 경우에만)
if (!_originalColors.ContainsKey(renderer))
{
var originalColorList = new List<(int materialIndex, string propertyName, Color color)>();
for (int i = 0; i < materials.Length; i++)
{
var material = materials[i];
if (material == null) continue;
var colorPropName = GetColorPropertyName(material);
if (colorPropName == null) continue;
originalColorList.Add((i, colorPropName, material.GetColor(colorPropName)));
}
if (originalColorList.Count > 0)
{
_originalColors[renderer] = originalColorList;
}
}
// 미리보기 색상 적용 (모든 Material에)
for (int i = 0; i < materials.Length; i++)
{
var material = materials[i];
if (material == null) continue;
var colorPropName = GetColorPropertyName(material);
if (colorPropName == null) continue;
material.SetColor(colorPropName, previewColor);
}
}
}
}
///
/// 미리보기 색상을 해제하고 원본 Material 색상으로 복원합니다.
///
public void ClearPreviewColor()
{
foreach (var kvp in _originalColors)
{
var renderer = kvp.Key;
var originalColorList = kvp.Value;
if (renderer == null) continue;
var materials = renderer.materials;
// 저장된 각 Material의 원본 색상 복원
foreach (var (materialIndex, propertyName, originalColor) in originalColorList)
{
if (materialIndex < materials.Length && materials[materialIndex] != null)
{
materials[materialIndex].SetColor(propertyName, originalColor);
}
}
}
_originalColors.Clear();
}
#endregion
#region PropertyWindow Integration
///
/// 자식 Transform의 속성을 PropertyWindow에 표시합니다.
///
/// 표시할 자식 Transform
/// 자식 이름
public void DisplayChildTransformProperties(Transform childTransform, string childName)
{
if (_propertyWindow == null || childTransform == null) return;
// 기존 StageObject 표시 상태 초기화
_currentDisplayedObject = null;
_currentDisplayedChildTransform = childTransform;
var entries = new List();
// 1. 자식 이름 표시 (읽기 전용)
var nameProperty = new UTKStringPropertyItem("child_name", "Name", childName)
{
IsReadOnly = true,
};
entries.Add(nameProperty);
// 2. Transform 그룹 추가
var transformGroup = new UTKPropertyGroup("transform", "Transform");
transformGroup.AddItem(new UTKVector3PropertyItem("transform_position", "Position", childTransform.localPosition));
transformGroup.AddItem(new UTKVector3PropertyItem("transform_rotation", "Rotation", childTransform.localEulerAngles));
transformGroup.AddItem(new UTKVector3PropertyItem("transform_scale", "Scale", childTransform.localScale));
entries.Add(transformGroup);
_propertyWindow.LoadMixedProperties(entries);
Debug.Log($"[SelectionManager] DisplayChildTransformProperties: {childName}");
}
///
/// 자식 Transform 표시 상태를 초기화합니다.
///
public void ClearChildTransformDisplay()
{
_currentDisplayedChildTransform = null;
}
///
/// StageObject의 Equipment PropertiesInfo를 PropertyWindow에 표시합니다.
///
/// 표시할 StageObject
private void DisplayEquipmentProperties(StageObjectManager.StageObject stageObject)
{
if (_propertyWindow == null) return;
// 자식 Transform 표시 상태 초기화
_currentDisplayedChildTransform = null;
_currentDisplayedObject = stageObject;
var equipment = stageObject.Equipment;
var entries = new List();
// 1. object_name 속성 추가 (수정 가능, 그룹 없이 개별)
var nameProperty = new UTKStringPropertyItem("object_name", "Name",
stageObject.GameObject != null ? stageObject.GameObject.name : "Unknown")
{
IsReadOnly = false,
};
entries.Add(nameProperty);
// 2. Transform 그룹 추가
if (stageObject.GameObject != null)
{
var transform = stageObject.GameObject.transform;
var transformGroup = new UTKPropertyGroup("transform", "Transform");
transformGroup.AddItem(new UTKVector3PropertyItem("transform_position", "Position", transform.localPosition));
transformGroup.AddItem(new UTKVector3PropertyItem("transform_rotation", "Rotation", transform.localEulerAngles));
transformGroup.AddItem(new UTKVector3PropertyItem("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);
if (propertyItem != null)
{
entries.Add(propertyItem);
}
}
}
else
{
// 일반 섹션은 그룹으로 묶음
var group = new UTKPropertyGroup(
$"section_{propInfo.section}",
propInfo.section ?? "Properties"
);
foreach (var prop in propInfo.properties)
{
var propertyItem = CreatePropertyItem(prop);
if (propertyItem != null)
{
group.AddItem(propertyItem);
}
}
if (group.ItemCount > 0)
{
entries.Add(group);
}
}
}
}
// 4. StatusInfo 추가
if (equipment?.statusInfo != null)
{
// Network 상태 섹션
if (equipment.statusInfo.network != null)
{
var networkGroup = CreateStatusSectionGroup(equipment.statusInfo.network, "status_network");
if (networkGroup != null)
{
entries.Add(networkGroup);
}
}
// Equipment 상태 섹션
if (equipment.statusInfo.equipment != null)
{
var equipmentGroup = CreateStatusSectionGroup(equipment.statusInfo.equipment, "status_equipment");
if (equipmentGroup != null)
{
entries.Add(equipmentGroup);
}
}
}
_propertyWindow.LoadMixedProperties(entries);
}
///
/// PropertyWindow에서 속성 값이 변경되었을 때 호출되는 핸들러
///
private void OnPropertyValueChanged(UTKPropertyValueChangedEventArgs e)
{
// 자식 Transform이 표시 중인 경우
if (_currentDisplayedChildTransform != null)
{
HandleChildTransformPropertyChange(e);
return;
}
if (_currentDisplayedObject == null) return;
HandleStageObjectPropertyChange(e);
}
///
/// 자식 Transform 속성 변경 처리
///
private void HandleChildTransformPropertyChange(UTKPropertyValueChangedEventArgs e)
{
if (_currentDisplayedChildTransform == null) return;
bool transformChanged = false;
switch (e.PropertyId)
{
case "transform_position":
if (e.NewValue is Vector3 childPos && e.OldValue is Vector3 oldChildPos)
{
_currentDisplayedChildTransform.localPosition = childPos;
RecordTransformPropertyChange(_currentDisplayedChildTransform, "position", oldChildPos, childPos);
transformChanged = true;
}
break;
case "transform_rotation":
if (e.NewValue is Vector3 childRot && e.OldValue is Vector3 oldChildRot)
{
_currentDisplayedChildTransform.localEulerAngles = childRot;
RecordTransformPropertyChange(_currentDisplayedChildTransform, "rotation", oldChildRot, childRot);
transformChanged = true;
}
break;
case "transform_scale":
if (e.NewValue is Vector3 childScale && e.OldValue is Vector3 oldChildScale)
{
_currentDisplayedChildTransform.localScale = childScale;
RecordTransformPropertyChange(_currentDisplayedChildTransform, "scale", oldChildScale, childScale);
transformChanged = true;
}
break;
}
// Transform이 변경되었으면 Gizmo 새로고침
if (transformChanged)
{
GizmoUndoBridge.Instance?.RefreshGizmos();
}
}
///
/// StageObject 속성 변경 처리
///
private void HandleStageObjectPropertyChange(UTKPropertyValueChangedEventArgs e)
{
if (_currentDisplayedObject?.GameObject == null) return;
bool transformChanged = false;
switch (e.PropertyId)
{
case "object_name":
HandleObjectNameChanged(e.NewValue?.ToString() ?? "");
break;
case "transform_position":
if (e.NewValue is Vector3 pos && e.OldValue is Vector3 oldPos)
{
_currentDisplayedObject.GameObject.transform.localPosition = pos;
RecordTransformPropertyChange(_currentDisplayedObject.GameObject.transform, "position", oldPos, pos);
transformChanged = true;
}
break;
case "transform_rotation":
if (e.NewValue is Vector3 rot && e.OldValue is Vector3 oldRot)
{
_currentDisplayedObject.GameObject.transform.localEulerAngles = rot;
RecordTransformPropertyChange(_currentDisplayedObject.GameObject.transform, "rotation", oldRot, rot);
transformChanged = true;
}
break;
case "transform_scale":
if (e.NewValue is Vector3 scale && e.OldValue is Vector3 oldScale)
{
_currentDisplayedObject.GameObject.transform.localScale = scale;
RecordTransformPropertyChange(_currentDisplayedObject.GameObject.transform, "scale", oldScale, scale);
transformChanged = true;
}
break;
default:
// 일반 속성 변경은 PropertyChangeCommand로 기록
RecordPropertyChange(e);
break;
}
// Transform이 변경되었으면 Gizmo 새로고침
if (transformChanged)
{
GizmoUndoBridge.Instance?.RefreshGizmos();
}
}
///
/// Transform 속성 변경을 UndoRedoManager에 기록합니다.
///
private void RecordTransformPropertyChange(Transform transform, string propertyType, Vector3 oldValue, Vector3 newValue)
{
if (oldValue == newValue) return;
var command = new TransformPropertyChangeCommand(transform, propertyType, oldValue, newValue);
var undoRedoManager = UndoRedoManager.Instance;
if (undoRedoManager != null)
{
undoRedoManager.RecordCommand(command);
Debug.Log($"[SelectionManager] Recorded transform property change: {propertyType}");
}
}
///
/// 일반 속성 변경을 UndoRedoManager에 기록합니다.
///
private void RecordPropertyChange(UTKPropertyValueChangedEventArgs e)
{
if (e.OldValue == e.NewValue) return;
// PropertyChangeCommand에 값 적용 함수 전달
var command = new PropertyChangeCommand(
e.PropertyId,
e.PropertyId, // 속성 이름으로 ID 사용
e.OldValue,
e.NewValue,
ApplyPropertyValue
);
var undoRedoManager = UndoRedoManager.Instance;
if (undoRedoManager != null)
{
undoRedoManager.RecordCommand(command);
Debug.Log($"[SelectionManager] Recorded property change: {e.PropertyId}");
}
}
///
/// 속성 값을 적용합니다. (Undo/Redo 시 호출)
///
private void ApplyPropertyValue(string propertyId, object? value)
{
if (_currentDisplayedObject == null) return;
// PropertyWindow에 값 반영
if (_propertyWindow != null)
{
_propertyWindow.SetPropertyValue(propertyId, value);
}
Debug.Log($"[SelectionManager] Applied property value: {propertyId} = {value}");
}
///
/// 객체 이름 변경 처리
///
/// 새 이름
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();
if (hierarchy != null)
{
hierarchy.UpdateItemName(_currentDisplayedObject.Id, newName);
}
}
// 3. 이벤트 발생
OnObjectNameChanged?.Invoke(_currentDisplayedObject.Id, newName);
Debug.Log($"[SelectionManager] Object name changed: {_currentDisplayedObject.Id} -> {newName}");
}
///
/// StatusSection을 PropertyGroup으로 변환합니다.
/// ColorStateProperty를 사용하여 label, stat, color를 하나의 항목으로 표시합니다.
///
/// 변환할 StatusSection
/// 그룹 ID 접두사
/// 순서
/// 생성된 PropertyGroup, 속성이 없으면 null
private static UTKPropertyGroup? CreateStatusSectionGroup(StatusSection section, string groupIdPrefix)
{
if (section.properties == null || section.properties.Count == 0)
return null;
var group = new UTKPropertyGroup(
groupIdPrefix,
section.section ?? "Status"
);
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