선택한 모델 PropertyWindow 연결 완료

This commit is contained in:
logonkhi
2025-12-19 15:27:35 +09:00
parent deeaa9a7ad
commit 158a42ab9b
24 changed files with 2278 additions and 1185 deletions

View File

@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
@@ -21,7 +22,7 @@ namespace UVC.Studio.Config
/// LibraryXXX.json
/// └── [equipmentType]: List<EquipmentItem>
/// └── EquipmentItem
/// ├── model - 모델 식별자 (예: "SingleFork")
/// ├── id - 장비 식별자 (예: "SingleFork")
/// ├── label - 표시 이름 (예: "Single Fork")
/// ├── gltf - glTF 파일 경로 (예: "staker_crane/SingleFork.glb")
/// ├── image - 썸네일 이미지 경로 (선택)
@@ -65,7 +66,7 @@ namespace UVC.Studio.Config
/// // 데이터 접근
/// foreach (var crane in library.StakerCraneData.stakerCrane)
/// {
/// Debug.Log($"Model: {crane.model}, Label: {crane.label}");
/// Debug.Log($"ID: {crane.id}, Label: {crane.label}");
/// }
/// </code>
///
@@ -132,7 +133,7 @@ namespace UVC.Studio.Config
/// // 스태커 크레인 데이터 접근
/// foreach (var crane in _library.StakerCraneData.stakerCrane)
/// {
/// Debug.Log($"Model: {crane.model}, GLTF: {crane.gltf}");
/// Debug.Log($"ID: {crane.id}, GLTF: {crane.gltf}");
/// }
///
/// // AGV 데이터 접근
@@ -142,10 +143,10 @@ namespace UVC.Studio.Config
/// }
/// }
///
/// public EquipmentItem GetEquipmentByModel(string model)
/// public EquipmentItem GetEquipmentById(string id)
/// {
/// return _library.StakerCraneData.stakerCrane
/// .FirstOrDefault(e => e.model == model);
/// .FirstOrDefault(e => e.id == id);
/// }
/// }
/// </code>
@@ -491,12 +492,12 @@ namespace UVC.Studio.Config
public class EquipmentItem
{
/// <summary>
/// 모델 식별자
/// 장비 식별자
/// </summary>
/// <remarks>
/// 고유한 모델 ID (예: "SingleFork", "Rack_Single")
/// 고유한 장비 ID (예: "SingleFork", "Rack_Single")
/// </remarks>
public string model;
public string id;
/// <summary>
/// 표시 이름
@@ -754,7 +755,7 @@ namespace UVC.Studio.Config
/// UI 표시용 색상 코드 (예: "#228B22" - 녹색, "#8B0000" - 빨간색)
/// 빈 문자열이면 기본 색상 사용
/// </remarks>
public string value;
public string? value;
}
#endregion

View File

@@ -1,8 +1,13 @@
#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
{
@@ -21,12 +26,34 @@ namespace UVC.Studio.Manager
/// </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>
@@ -41,9 +68,17 @@ namespace UVC.Studio.Manager
/// 생성자
/// </summary>
/// <param name="stageObjectManager">StageObjectManager 참조</param>
public SelectionManager(StageObjectManager stageObjectManager)
/// <param name="propertyWindow">PropertyWindow 참조 (선택)</param>
public SelectionManager(StageObjectManager stageObjectManager, PropertyWindow? propertyWindow = null)
{
_stageObjectManager = stageObjectManager;
_propertyWindow = propertyWindow;
// PropertyWindow 값 변경 이벤트 구독
if (_propertyWindow != null)
{
_propertyWindow.PropertyValueChanged += OnPropertyValueChanged;
}
}
/// <summary>
@@ -72,6 +107,9 @@ namespace UVC.Studio.Manager
Debug.Log($"[SelectionManager] Selected: {stageObject.GameObject?.name}");
OnSelectionChanged?.Invoke(stageObject, true);
// PropertyWindow에 선택된 객체의 속성 표시
DisplayEquipmentProperties(stageObject);
}
/// <summary>
@@ -103,6 +141,20 @@ namespace UVC.Studio.Manager
Debug.Log($"[SelectionManager] Deselected: {stageObject.GameObject?.name}");
OnSelectionChanged?.Invoke(stageObject, false);
// 모든 선택이 해제되면 정리 작업 수행
if (_selectedObjects.Count == 0)
{
// 미리보기 색상 원복 및 클리어
ClearPreviewColor();
// PropertyWindow 항목 모두 제거
if (_propertyWindow != null)
{
_propertyWindow.Clear();
}
_currentDisplayedObject = null;
}
}
}
@@ -273,5 +325,386 @@ namespace UVC.Studio.Manager
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
}
}

View File

@@ -72,7 +72,7 @@ namespace UVC.Studio.Manager
};
_objects[id] = stageObject;
gameObject.name = GenerateUniqueName(equipment.model);
gameObject.name = GenerateUniqueName(equipment.id);
Debug.Log($"[StageObjectManager] Registered: {gameObject.name}");
OnObjectAdded?.Invoke(stageObject);

View File

@@ -44,8 +44,8 @@ namespace UVC.Studio
var stageObjectManager = new StageObjectManager();
Injector.RegisterInstance<StageObjectManager>(stageObjectManager);
// SelectionManager 등록 (StageObjectManager 의존)
var selectionManager = new SelectionManager(stageObjectManager);
// SelectionManager 등록 (StageObjectManager, PropertyWindow 의존)
var selectionManager = new SelectionManager(stageObjectManager, propertyWindow);
Injector.RegisterInstance<SelectionManager>(selectionManager);
}
}

View File

@@ -19,8 +19,6 @@ using UVC.UI.ToolBar;
using UVC.UI.Tooltip;
using UVC.UI.Window.PropertyWindow;
using UVC.Util;
using UVC.Studio.Config;
using UVC.Studio.Manager;
using ActionCommand = UVC.UI.Commands.ActionCommand;
namespace UVC.Studio
@@ -41,9 +39,6 @@ namespace UVC.Studio
[Inject]
private PropertyWindow propertyWindow;
[Inject]
private SelectionManager selectionManager;
//test code
MoveGizmo mMoveGizmo; // 이동 기즈모
RotateGizmo mRotateGizmo; // 회전 기즈모
@@ -81,8 +76,6 @@ namespace UVC.Studio
SetupTopMenu();
SetupToolBox();
SetupPropertyWindow();
SetupSelectionManager();
sideTabBar.InitTab();
Initialized?.Invoke();
@@ -400,228 +393,5 @@ namespace UVC.Studio
};
}
private void SetupPropertyWindow()
{
propertyWindow.LoadProperties(new List<IPropertyItem>
{
new StringProperty("prop1", "String Property", "Initial Value")
{
Description = "This is a sample string property.",
Tooltip = "Enter a string value here.",
IsReadOnly = false
},
new IntProperty("prop2", "Int Property", 42, true)
{
Description = "This is a sample integer property.",
Tooltip = "Enter an integer value here.",
IsReadOnly = false
},
new IntRangeProperty("prop2_range", "Int Range Property", new Tuple<int, int>(0, 100))
{
Description = "This is a sample integer range property.",
Tooltip = "Select an integer value within the range here.",
IsReadOnly = false
},
new FloatRangeProperty("prop3_range", "Float Range Property", new Tuple<float, float>(0.0f, 1.0f))
{
Description = "This is a sample float range property.",
Tooltip = "Select a float value within the range here.",
IsReadOnly = false
},
new FloatProperty("prop3", "Float Property", 0.5f, true)
{
Description = "This is a sample float property.",
Tooltip = "Enter an float value here.",
IsReadOnly = false
},
new BoolProperty("prop4", "Boolean Property", true)
{
Description = "This is a sample boolean property.",
Tooltip = "Toggle the boolean value here.",
IsReadOnly = false
},
new ColorProperty("prop5", "Color Property", Color.red)
{
Description = "This is a sample color property.",
Tooltip = "Select a color here.",
IsReadOnly = false
},
new Vector2Property("prop6_vec2", "Vector2 Property", new Vector2(1, 2))
{
Description = "This is a sample Vector2 property.",
Tooltip = "Enter a Vector2 value here.",
IsReadOnly = false
},
new Vector3Property("prop6_vec3", "Vector3 Property", new Vector3(1, 2, 3))
{
Description = "This is a sample Vector3 property.",
Tooltip = "Enter a Vector3 value here.",
IsReadOnly = false
},
new DateProperty("prop6_date", "Date Property", System.DateTime.Now)
{
//Description = "This is a sample date property.",
Tooltip = "Select a date here.",
IsReadOnly = false
},
new DateTimeProperty("prop6_datetime", "DateTime Property", System.DateTime.Now)
{
Description = "This is a sample date-time property.",
Tooltip = "Select a date and time here.",
IsReadOnly = false
},
new DateRangeProperty("prop6_daterange", "Date Range Property", new Tuple<DateTime, DateTime>(DateTime.Now.AddDays(-7), DateTime.Now))
{
Description = "This is a sample date range property.",
Tooltip = "Select a date range here.",
IsReadOnly = false
},
new DateTimeRangeProperty("prop6_datetimerange", "DateTime Range Property", new Tuple<DateTime, DateTime>(DateTime.Now.AddHours(-1), DateTime.Now))
{
Description = "This is a sample date-time range property.",
Tooltip = "Select a date-time range here.",
IsReadOnly = false
},
new EnumProperty("prop6", "Enum Property", SampleEnum.Option1)
{
Description = "This is a sample enum property.",
Tooltip = "Select an option here.",
IsReadOnly = false
},
new ListProperty("prop7", "List Property", new List<string> { "Item1", "Item2", "Item3" }, "Item1")
{
Description = "This is a sample list property.",
Tooltip = "Manage the list items here.",
IsReadOnly = false
},
new RadioGroupProperty("prop8", "Radio Group Property", new List<string> { "Option1", "Option2", "Option3" }, "Option1")
{
Description = "This is a sample radio group property.",
Tooltip = "Select one option here.",
IsReadOnly = false
}
});
propertyWindow.PropertyValueChanged += (sender, e) =>
{
Debug.Log($"Property Id:{e.PropertyId}, type:{e.PropertyType}, newValue: {e.NewValue}");
};
}
private void SetupSelectionManager()
{
if (selectionManager == null)
{
Debug.LogWarning("SelectionManager is not assigned in SceneMain.");
return;
}
selectionManager.OnSelectionChanged += OnSelectionChanged;
}
/// <summary>
/// SelectionManager에서 선택이 변경되었을 때 호출되는 콜백
/// </summary>
/// <param name="stageObject">선택/해제된 StageObject</param>
/// <param name="isSelected">선택 여부 (true: 선택, false: 해제)</param>
private void OnSelectionChanged(StageObjectManager.StageObject stageObject, bool isSelected)
{
if (isSelected && stageObject?.Equipment != null)
{
DisplayEquipmentProperties(stageObject);
}
else if (!isSelected && selectionManager.SelectedCount == 0)
{
// 모든 선택이 해제되면 PropertyWindow를 비움
propertyWindow.Clear();
}
}
/// <summary>
/// StageObject의 Equipment PropertiesInfo를 PropertyWindow에 표시합니다.
/// </summary>
/// <param name="stageObject">표시할 StageObject</param>
private void DisplayEquipmentProperties(StageObjectManager.StageObject stageObject)
{
var equipment = stageObject.Equipment;
if (equipment?.propertiesInfo == null || equipment.propertiesInfo.Count == 0)
{
propertyWindow.Clear();
return;
}
var groups = new List<IPropertyGroup>();
// 기본 정보 그룹 추가 (ID, 이름 등)
var basicGroup = new PropertyGroup("basic_info", "Basic Info", order: 0);
basicGroup.AddItems(new IPropertyItem[]
{
new StringProperty("object_id", "ID", stageObject.Id) { IsReadOnly = true },
new StringProperty("object_name", "Name", stageObject.GameObject?.name ?? "Unknown") { IsReadOnly = true },
new StringProperty("equipment_model", "Model", equipment.model ?? "") { IsReadOnly = true },
new StringProperty("equipment_label", "Label", equipment.label ?? "") { IsReadOnly = true }
});
groups.Add(basicGroup);
// Equipment의 PropertiesInfo를 PropertyGroup으로 변환
int orderIndex = 1;
foreach (var propInfo in equipment.propertiesInfo)
{
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)
{
groups.Add(group);
}
}
propertyWindow.LoadGroupedProperties(groups);
}
/// <summary>
/// Library.PropertyItem을 PropertyWindow.IPropertyItem으로 변환합니다.
/// </summary>
/// <param name="prop">변환할 PropertyItem</param>
/// <returns>변환된 IPropertyItem</returns>
private IPropertyItem CreatePropertyItem(Config.PropertyItem prop)
{
string id = prop.id ?? Guid.NewGuid().ToString("N")[..8];
string label = prop.label ?? id;
// 단위가 있으면 레이블에 표시
if (!string.IsNullOrEmpty(prop.unit))
{
label = $"{label} ({prop.unit})";
}
return prop.type?.ToLower() switch
{
"float" => new FloatProperty(id, label, prop.GetFloatValue()),
"int" => new IntProperty(id, label, prop.GetIntValue()),
_ => new StringProperty(id, label, prop.GetStringValue())
};
}
enum SampleEnum
{
Option1,
Option2,
Option3
}
}
}

View File

@@ -10,6 +10,7 @@ using UVC.Studio.Manager;
using UVC.UI.List.Accordion;
using UVC.UI.Tab;
using UVC.UI.Window;
using UnityEngine.EventSystems;
namespace UVC.Studio.Tab
{
@@ -137,6 +138,13 @@ namespace UVC.Studio.Tab
/// </summary>
private void OnGridItemEndDragHandler(AccordionGridItemData itemData, Vector2 screenPosition)
{
// UI 위에서 드래그가 끝났으면 프리뷰 제거
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
{
CancelDragPreview();
return;
}
if (dragPreview != null && draggingEquipment != null && stageObjectManager != null)
{
// 최종 위치 계산

View File

@@ -1,5 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UVC.Core;
@@ -54,6 +56,7 @@ namespace UVC.Studio.Tab
hierarchyWindow.OnItemDeselected += OnItemDeselectedHandler;
hierarchyWindow.OnItemVisibilityChanged += OnItemVisibilityChangedHandler;
//다른 클래스에서 이 컴포넌트를 주입 받을 수 있도록 등록
InjectorAppContext.Instance.Injector.RegisterInstance<StudioSideTabBarHierarchy>(this, ServiceLifetime.Scene);
}
@@ -142,7 +145,7 @@ namespace UVC.Studio.Tab
}
// SelectionManager 가져오기
_selectionManager = InjectorAppContext.Instance.Get<SelectionManager>();
if (_selectionManager == null) _selectionManager = InjectorAppContext.Instance.Get<SelectionManager>();
if (_selectionManager != null)
{
// 선택 변경 이벤트 구독 (화면 클릭으로 선택 시 HierarchyWindow 동기화)
@@ -152,6 +155,7 @@ namespace UVC.Studio.Tab
{
Debug.LogWarning("[StudioSideTabBarHierarchy] SelectionManager not found.");
}
isInitialized = true;
}
/// <summary>
@@ -218,11 +222,12 @@ namespace UVC.Studio.Tab
return;
}
// TreeListItemData 생성
var treeItem = new TreeListItemData(stageObject.GameObject != null ? stageObject.GameObject.name : stageObject.Equipment.model)
{
ExternalKey = stageObject.Id
};
// TreeListItemData 생성 (하위 자식 포함)
var treeItem = CreateTreeItemRecursive(
stageObject.GameObject != null ? stageObject.GameObject.transform : null,
stageObject.GameObject != null ? stageObject.GameObject.name : stageObject.Equipment.id,
stageObject.Id
);
// 매핑 저장
_stageObjectToTreeItem[stageObject.Id] = treeItem;
@@ -230,7 +235,36 @@ namespace UVC.Studio.Tab
// HierarchyWindow에 추가
hierarchyWindow.AddItem(treeItem);
Debug.Log($"[StudioSideTabBarHierarchy] Added TreeItem: {treeItem.Name}");
Debug.Log($"[StudioSideTabBarHierarchy] Added TreeItem: {treeItem.Name} (including all children)");
}
/// <summary>
/// Transform의 자식들을 재귀적으로 탐색하여 TreeListItemData를 생성
/// </summary>
/// <param name="transform">탐색할 Transform (null이면 이름만 사용)</param>
/// <param name="name">표시 이름</param>
/// <param name="externalKey">외부 키 (루트 노드에만 설정)</param>
/// <returns>생성된 TreeListItemData</returns>
private TreeListItemData CreateTreeItemRecursive(Transform? transform, string name, string? externalKey = null)
{
var treeItem = new TreeListItemData(name);
if (!string.IsNullOrEmpty(externalKey))
{
treeItem.ExternalKey = externalKey;
}
// Transform이 있으면 자식들을 재귀적으로 추가
if (transform != null)
{
foreach (Transform child in transform)
{
var childItem = CreateTreeItemRecursive(child, child.name);
treeItem.AddChild(childItem);
}
}
return treeItem;
}
/// <summary>
@@ -269,13 +303,70 @@ namespace UVC.Studio.Tab
return _stageObjectToTreeItem.TryGetValue(stageObjectId, out var treeItem) ? treeItem : null;
}
/// <summary>
/// StageObject의 표시 이름을 변경합니다.
/// HierarchyWindow의 TreeList에도 반영됩니다.
/// </summary>
/// <param name="stageObjectId">변경할 StageObject의 ID</param>
/// <param name="newName">새 이름</param>
public void UpdateItemName(string stageObjectId, string newName)
{
if (hierarchyWindow == null) return;
if (!_stageObjectToTreeItem.TryGetValue(stageObjectId, out var treeItem)) return;
// HierarchyWindow.SetItemName을 통해 TreeList UI도 함께 갱신
hierarchyWindow.SetItemName(treeItem, newName);
Debug.Log($"[StudioSideTabBarHierarchy] Updated item name: {stageObjectId} -> {newName}");
}
/// <summary>
/// 탭 콘텐츠에 데이터를 전달합니다.
/// 탭이 활성화될 때 호출되며, SelectionManager의 선택 상태를 HierarchyWindow에 동기화합니다.
/// </summary>
/// <param name="data">전달할 데이터 객체</param>
public void SetContentData(object? data)
{
Debug.Log("StudioSideTabBarHierarchy: SetContentData called");
// SelectionManager에 선택된 항목이 있으면 HierarchyWindow에 반영
SyncSelectionFromSelectionManager();
}
/// <summary>
/// SelectionManager의 현재 선택 상태를 HierarchyWindow에 동기화합니다.
/// </summary>
private async void SyncSelectionFromSelectionManager()
{
if (!isInitialized)
{
await UniTask.WaitUntil(() => isInitialized).TimeoutWithoutException(new TimeSpan(0, 0, 1));
}
Debug.Log($"StudioSideTabBarHierarchy: SyncSelectionFromSelectionManager called. _selectionManager == null:{_selectionManager == null}, hierarchyWindow == null:{hierarchyWindow == null}");
if (_selectionManager == null || hierarchyWindow == null) return;
var selectedObjects = _selectionManager.SelectedObjects;
if (selectedObjects.Count == 0) return;
_isProcessingSelection = true;
try
{
foreach (var stageObject in selectedObjects)
{
if (string.IsNullOrEmpty(stageObject.Id)) continue;
// 매핑에서 TreeListItemData 찾기
if (_stageObjectToTreeItem.TryGetValue(stageObject.Id, out var treeItem))
{
hierarchyWindow.SelectItem(treeItem.Name);
Debug.Log($"[StudioSideTabBarHierarchy] Synced existing selection: {treeItem.Name}");
}
}
}
finally
{
_isProcessingSelection = false;
}
}
/// <summary>

View File

@@ -59,6 +59,12 @@ namespace UVC.GLTF
return instantiator.SceneTransform != null ? instantiator.SceneTransform.gameObject : null;
}
/// <summary>
/// 여러 LOD 레벨의 glTF/glb 파일을 로드하고 LODGroup으로 설정합니다. 사용않함
/// </summary>
/// <param name="paths"></param>
/// <param name="parentTransform"></param>
/// <returns></returns>
public static async UniTask<GameObject?> ImportWithLOD(List<string> paths, Transform parentTransform)
{
if (paths == null || paths.Count == 0) return null;

View File

@@ -189,6 +189,16 @@ namespace UVC.UI.Window
treeList.DeleteItem(data);
}
/// <summary>
/// 아이템의 이름을 변경합니다.
/// </summary>
/// <param name="data">변경할 아이템 데이터</param>
/// <param name="newName">새 이름</param>
public void SetItemName(TreeListItemData data, string newName)
{
treeList.SetItemName(data, newName);
}
/// <summary>
/// 이름으로 아이템 선택
/// </summary>

View File

@@ -27,6 +27,7 @@ namespace UVC.UI.Window.PropertyWindow
FloatRange,
DateRange,
DateTimeRange,
ColorState,
}
/// <summary>
@@ -331,5 +332,13 @@ namespace UVC.UI.Window.PropertyWindow
}
}
// --- 복합 타입 속성 ---
public class ColorStateProperty : PropertyItem<Tuple<string, Color?>>
{
public override PropertyType PropertyType => PropertyType.ColorState;
public ColorStateProperty(string id, string name, Tuple<string, Color?> initialValue) : base(id, name, initialValue) { }
}
#endregion
}

View File

@@ -35,6 +35,7 @@ namespace UVC.UI.Window.PropertyWindow
[SerializeField] private GameObject _numberRangePropertyPrefab;
[SerializeField] private GameObject _dateRangePropertyPrefab;
[SerializeField] private GameObject _dateTimeRangePropertyPrefab;
[SerializeField] private GameObject _colorStatePropertyPrefab;
/// <summary>
/// View가 상호작용할 Controller 인스턴스입니다.
@@ -291,6 +292,8 @@ namespace UVC.UI.Window.PropertyWindow
return _dateRangePropertyPrefab;
case PropertyType.DateTimeRange:
return _dateTimeRangePropertyPrefab;
case PropertyType.ColorState:
return _colorStatePropertyPrefab;
default:
Debug.LogWarning($"'{type}' 타입에 대한 프리팹이 정의되지 않았습니다.");
return null;

View File

@@ -402,6 +402,9 @@ namespace UVC.UI.Window.PropertyWindow
_groupIndex.Clear();
_itemIndex.Clear();
EntriesCleared?.Invoke(this, EventArgs.Empty);
// View 갱신하여 UI에서도 항목 제거
Refresh();
}
/// <summary>

View File

@@ -0,0 +1,251 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using UVC.Core;
using UVC.Extention;
using UVC.Studio.Manager;
using UVC.UI.Modal.ColorPicker;
using UVC.UI.Tooltip;
using UVC.Util;
namespace UVC.UI.Window.PropertyWindow.UI
{
/// <summary>
/// ColorProperty를 위한 UI를 제어하는 스크립트입니다.
/// Image 컴포넌트로 색상을 표시하고, Button으로 색상 선택기를 엽니다.
/// </summary>
[RequireComponent(typeof(LayoutElement))]
public class ColorStatePropertyUI : MonoBehaviour, IPropertyUI
{
[Header("UI Components")]
[SerializeField]
private TextMeshProUGUI _nameLabel; // 속성 이름을 표시할 Text
[SerializeField]
private TextMeshProUGUI _descriptionLabel;
[SerializeField]
private TMP_InputField _stateLabel;
[SerializeField]
private LayoutGroup _colorLayoutGroup;
[SerializeField]
private Image _colorPreviewImage; // 현재 색상을 표시할 Image
[SerializeField]
private TMP_InputField _colorLabel;
[SerializeField]
private Button _colorPickerButton; // 색상 선택기를 열기 위한 Button
[SerializeField]
private Button _previewButton; // 컬러를 미리보기 위한 Button
private ColorStateProperty _propertyItem;
private PropertyWindow _controller;
private bool openningColorPickered = false;
/// <summary>
/// PropertyView에 의해 호출되어 UI를 초기화하고 데이터를 설정합니다.
/// </summary>
/// <param name="item">UI에 표시할 속성 데이터(IPropertyItem)</param>
/// <param name="controller">상호작용할 PropertyWindow</param>
public void Setup(IPropertyItem item, PropertyWindow controller)
{
if (!(item is ColorStateProperty typedItem))
{
Debug.LogError($"ColorStatePropertyUI에 잘못된 타입의 PropertyItem이 전달되었습니다. {item.GetType()}");
return;
}
_propertyItem = typedItem;
_controller = controller;
// --- 데이터 바인딩 ---
// 1. 속성 이름 설정
_nameLabel.text = _propertyItem.Name;
// 툴팁 설정 (TooltipHandler 컴포넌트가 있다면 연동)
TooltipHandler tooltipHandler = _nameLabel.GetComponent<TooltipHandler>();
if (tooltipHandler != null && !_propertyItem.Tooltip.IsNullOrEmpty())
{
tooltipHandler.Tooltip = _propertyItem.Tooltip;
}
if (_propertyItem.Description.IsNullOrEmpty())
{
_descriptionLabel.gameObject.SetActive(false);
}
else
{
_descriptionLabel.gameObject.SetActive(true);
_descriptionLabel.text = _propertyItem.Description;
}
_stateLabel.text = _propertyItem.Value.Item1;
// 2. 색상 미리보기 Image의 색상 설정
if (_propertyItem.Value.Item2 == null)
{
_colorLayoutGroup.gameObject.SetActive(false);
_previewButton.gameObject.SetActive(false);
}
else
{
_colorLayoutGroup.gameObject.SetActive(true);
_colorPreviewImage.color = _propertyItem.Value.Item2.Value;
_colorLabel.text = ColorUtil.ToHex(_colorPreviewImage.color, true, false);
_colorLabel.interactable = !_propertyItem.IsReadOnly;
// 3. 읽기 전용 상태에 따라 버튼 상호작용 여부 결정
_colorPickerButton.gameObject.SetActive(!_propertyItem.IsReadOnly);
_colorPickerButton.onClick.RemoveAllListeners();
if (!_propertyItem.IsReadOnly)
{
// --- 이벤트 리스너 등록 ---
_colorPickerButton.onClick.AddListener(OpenColorPicker);
}
_previewButton.gameObject.SetActive(true);
// PointerDown/PointerUp 핸들러 설정
SetupPreviewButtonEvents();
}
}
/// <summary>
/// PreviewButton에 PointerDown/PointerUp 이벤트를 설정합니다.
/// </summary>
private void SetupPreviewButtonEvents()
{
// 기존 PreviewButtonHandler가 있으면 제거
var existingHandler = _previewButton.GetComponent<PreviewButtonHandler>();
if (existingHandler != null)
{
Destroy(existingHandler);
}
// 새 핸들러 추가
var handler = _previewButton.gameObject.AddComponent<PreviewButtonHandler>();
handler.Initialize(OnPreviewMouseDown, OnPreViewMouseUp);
}
/// <summary>
/// 색상 선택기 버튼을 클릭했을 때 호출됩니다.
/// </summary>
private async void OpenColorPicker()
{
if (openningColorPickered == true) return;
openningColorPickered = true;
CursorManager.Instance.SetCursor(CursorType.Wait);
await ColorPicker.Create(_colorPreviewImage.color, "Color Picker", null, OnColorSelected, OnCloseColorPicker, true);
CursorManager.Instance.SetDefaultCursor();
Debug.LogWarning($"'{_propertyItem.Name}'의 색상 선택기 로직이 구현되지 않았습니다. 클릭 이벤트만 발생합니다.");
}
/// <summary>
/// 색상 선택기에서 새로운 색상이 선택되었을 때 호출되는 메서드입니다.
/// </summary>
/// <param name="newColor">선택된 새로운 색상</param>
public void OnColorSelected(Color newColor)
{
if (newColor == _propertyItem.Value.Item2.Value)
{
return; // 변경 사항이 없으므로 아무 작업도 하지 않음
}
// 1. UI의 색상 미리보기를 업데이트합니다.
_colorPreviewImage.color = newColor;
_colorLabel.text = ColorUtil.ToHex(_colorPreviewImage.color, true, false);
// 2. PropertyController를 통해 데이터 모델의 값을 업데이트합니다.
_controller.UpdatePropertyValue(_propertyItem.Id, _propertyItem.PropertyType, new Tuple<string, string, Color?>(_propertyItem.Name, _stateLabel.text, newColor));
}
private void OnPreviewMouseDown()
{
// 미리보기 버튼 클릭 시 동작 구현 (필요시)
SelectionManager selectionManager = InjectorAppContext.Instance.Get<SelectionManager>();
if (selectionManager != null) selectionManager.PreviewColor(_propertyItem.Value.Item2!.Value);
}
private void OnPreViewMouseUp()
{
// 미리보기 버튼 클릭 해제 시 동작 구현 (필요시)
SelectionManager selectionManager = InjectorAppContext.Instance.Get<SelectionManager>();
if (selectionManager != null) selectionManager.ClearPreviewColor();
}
private void OnCloseColorPicker()
{
openningColorPickered = false;
}
/// <summary>
/// UI의 읽기 전용 상태를 설정합니다.
/// </summary>
/// <param name="isReadOnly">읽기 전용 여부 (true: 비활성화, false: 활성화)</param>
public void SetReadOnly(bool isReadOnly)
{
if (_propertyItem != null)
{
_propertyItem.IsReadOnly = isReadOnly;
}
if (_colorLabel != null) _colorLabel.interactable = !isReadOnly;
if (_colorPickerButton != null) _colorPickerButton.gameObject.SetActive(!isReadOnly);
}
/// <summary>
/// 이 UI 오브젝트가 파괴될 때 Unity에 의해 호출됩니다.
/// </summary>
private void OnDestroy()
{
if (_colorPickerButton != null && _colorPickerButton.onClick != null)
{
_colorPickerButton.onClick.RemoveAllListeners();
}
if (_previewButton != null)
{
var existingHandler = _previewButton.GetComponent<PreviewButtonHandler>();
if (existingHandler != null)
{
Destroy(existingHandler);
}
}
}
}
/// <summary>
/// PreviewButton의 PointerDown/PointerUp 이벤트를 처리하는 핸들러
/// </summary>
public class PreviewButtonHandler : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
{
private Action _onPointerDown;
private Action _onPointerUp;
public void Initialize(Action onPointerDown, Action onPointerUp)
{
_onPointerDown = onPointerDown;
_onPointerUp = onPointerUp;
}
public void OnPointerDown(PointerEventData eventData)
{
_onPointerDown?.Invoke();
}
public void OnPointerUp(PointerEventData eventData)
{
_onPointerUp?.Invoke();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 508447e589a88f149934d68ac57195ff