diff --git a/Assets/Resources/Prefabs/UI/Window/HierarchyWindow.prefab b/Assets/Resources/Prefabs/UI/Window/HierarchyWindow.prefab index 5879fa3b..31afbd5b 100644 --- a/Assets/Resources/Prefabs/UI/Window/HierarchyWindow.prefab +++ b/Assets/Resources/Prefabs/UI/Window/HierarchyWindow.prefab @@ -389,8 +389,8 @@ RectTransform: m_ConstrainProportionsScale: 0 m_Children: - {fileID: 3512955993103789164} - - {fileID: 4162604190990658749} - {fileID: 6704318869740062506} + - {fileID: 4162604190990658749} m_Father: {fileID: 1574318677252675885} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} diff --git a/Assets/Resources/SHI/Prefabs/ShiHierarchyWindow.prefab b/Assets/Resources/SHI/Prefabs/ShiHierarchyWindow.prefab index 873a7691..4d850293 100644 --- a/Assets/Resources/SHI/Prefabs/ShiHierarchyWindow.prefab +++ b/Assets/Resources/SHI/Prefabs/ShiHierarchyWindow.prefab @@ -243,7 +243,7 @@ GameObject: m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 - m_IsActive: 0 + m_IsActive: 1 --- !u!224 &4162604190990658749 RectTransform: m_ObjectHideFlags: 0 @@ -344,19 +344,7 @@ MonoBehaviour: m_TargetGraphic: {fileID: 9209424671866392754} m_OnClick: m_PersistentCalls: - m_Calls: - - m_Target: {fileID: 0} - m_TargetAssemblyTypeName: UVC.UI.List.ComponentList.ComponentList, Assembly-CSharp - m_MethodName: OnClickClearText - m_Mode: 1 - m_Arguments: - m_ObjectArgument: {fileID: 0} - m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine - m_IntArgument: 0 - m_FloatArgument: 0 - m_StringArgument: - m_BoolArgument: 0 - m_CallState: 2 + m_Calls: [] --- !u!1 &1866422618894112705 GameObject: m_ObjectHideFlags: 0 @@ -390,8 +378,8 @@ RectTransform: m_Children: - {fileID: 1487765936564688321} - {fileID: 3512955993103789164} - - {fileID: 4162604190990658749} - {fileID: 6704318869740062506} + - {fileID: 4162604190990658749} m_Father: {fileID: 1574318677252675885} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} @@ -522,7 +510,7 @@ MonoBehaviour: m_Calls: [] m_CaretColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} m_CustomCaretColor: 0 - m_SelectionColor: {r: 0, g: 0, b: 0, a: 1} + m_SelectionColor: {r: 0.54509807, g: 0.7764706, b: 0.9843137, a: 1} m_Text: m_CaretBlinkRate: 0.85 m_CaretWidth: 1 @@ -635,7 +623,7 @@ MonoBehaviour: treeListSearch: {fileID: 7660962889100034149} inputField: {fileID: 7769071907284884258} clearTextButton: {fileID: 5364685758092017862} - loadingImage: {fileID: 1613511791166383164} + loadingImage: {fileID: 196444072060293216} loadingRotateSpeed: 360 loadingFillCycle: 0.5 --- !u!114 &5211500305101215241 @@ -1023,7 +1011,7 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0.5, y: 0.5} m_AnchorMax: {x: 0.5, y: 0.5} - m_AnchoredPosition: {x: 70, y: 0} + m_AnchoredPosition: {x: 55, y: 0} m_SizeDelta: {x: 10, y: 10} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &771756914789545659 @@ -1334,7 +1322,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 4856300785373777908, guid: a030935af8c62d748b6fe3d01bd4851f, type: 3} propertyPath: m_Size - value: 0.8752399 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6081086258189437538, guid: a030935af8c62d748b6fe3d01bd4851f, type: 3} propertyPath: m_AnchorMax.x @@ -1503,7 +1491,7 @@ PrefabInstance: objectReference: {fileID: 0} - target: {fileID: 4856300785373777908, guid: a030935af8c62d748b6fe3d01bd4851f, type: 3} propertyPath: m_Size - value: 0.8771593 + value: 1 objectReference: {fileID: 0} - target: {fileID: 6081086258189437538, guid: a030935af8c62d748b6fe3d01bd4851f, type: 3} propertyPath: m_AnchorMax.x diff --git a/Assets/Resources/SHI/Shader/BasicWireframe.mat b/Assets/Resources/SHI/Shader/BasicWireframe.mat index fab02d01..7cea09b0 100644 --- a/Assets/Resources/SHI/Shader/BasicWireframe.mat +++ b/Assets/Resources/SHI/Shader/BasicWireframe.mat @@ -33,7 +33,7 @@ Material: - _QUADS: 1 - _WIREFRAME: 1 m_Floats: - - _WireframeScale: 1 + - _WireframeScale: 0.5 m_Colors: - _WireframeColor: {r: 0.5333333, g: 0.5333333, b: 0.5333333, a: 1} - _WireframeColour: {r: 0.53333336, g: 0.53333336, b: 0.53333336, a: 1} diff --git a/Assets/Scenes/Sample/ShiPopupSample.cs b/Assets/Scenes/Sample/ShiPopupSample.cs index cf5c3f5b..49781f87 100644 --- a/Assets/Scenes/Sample/ShiPopupSample.cs +++ b/Assets/Scenes/Sample/ShiPopupSample.cs @@ -1,15 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; using Cysharp.Threading.Tasks; using SHI.modal; +using System; +using System.Globalization; +using System.IO; using UnityEngine; using UnityEngine.UI; +/// +/// 샘플 장면 드라이버: 버튼 클릭으로 SHI BlockDetail 모달을 생성/표시하고, +/// StreamingAssets에서 glb/간트 JSON을 읽어 모달에 전달합니다. +/// public class ShiPopupSample : MonoBehaviour { - [SerializeField] private GameObject blockDetailModalPrefab; @@ -18,14 +20,12 @@ public class ShiPopupSample : MonoBehaviour private BlockDetailModal blockDetailModal; - void Start() + private void Start() { - - if (openModalButton != null) { openModalButton.onClick.AddListener(() => - { + { if (blockDetailModal == null && blockDetailModalPrefab != null) { Canvas canvas = Canvas.FindFirstObjectByType(); @@ -39,7 +39,7 @@ public class ShiPopupSample : MonoBehaviour } [Serializable] - private class RawScheduleSegment + public class RawScheduleSegment { public string ItemId; public string Start; @@ -49,11 +49,14 @@ public class ShiPopupSample : MonoBehaviour } [Serializable] - private class RawGanttChartData + public class RawGanttChartData { public RawScheduleSegment[] Segments; } + /// + /// StreamingAssets에서 샘플 간트 JSON과 모델을 읽어 모달에 적용합니다. + /// private async UniTaskVoid SetupData() { if (blockDetailModal == null) @@ -66,7 +69,7 @@ public class ShiPopupSample : MonoBehaviour string glbPath = Path.Combine(sa, "block.glb"); string jsonPath = Path.Combine(sa, "sample_gantt_data.json"); - // Load JSON (editor/standalone path). For Android/WebGL, use UnityWebRequest if needed. + // 플랫폼에 따라 UnityWebRequest 사용이 필요할 수 있음(여기선 Editor/Standalone 가정) RawGanttChartData raw = null; try { diff --git a/Assets/Scripts/SHI/modal/BlockDetailModal.cs b/Assets/Scripts/SHI/modal/BlockDetailModal.cs index c202443e..0685ab89 100644 --- a/Assets/Scripts/SHI/modal/BlockDetailModal.cs +++ b/Assets/Scripts/SHI/modal/BlockDetailModal.cs @@ -7,6 +7,10 @@ using UnityEngine.UI; namespace SHI.modal { + /// + /// 모달 패널 내부에서 모델 뷰, 계층 리스트, 간트 차트를 조율하는 컨트롤러입니다. + /// 컴포넌트 간 선택 동기화, 레이아웃 분할 제어, 데이터 로딩을 담당합니다. + /// public class BlockDetailModal : MonoBehaviour { [Header("References")] @@ -21,7 +25,7 @@ namespace SHI.modal [SerializeField] private Button dragButton; [SerializeField] private Button showListButton; - // cached layout elements for split control + // split 제어용 캐시 private LayoutElement _modelLayout; private LayoutElement _chartLayout; @@ -36,95 +40,168 @@ namespace SHI.modal private CancellationTokenSource _cts; private bool _suppressSelection; - // key<->id 매핑(차트-리스트/모델 동기화를 위해 유지) + // key<->id 매핑(차트-리스트/모델 동기화) private readonly Dictionary _keyToId = new Dictionary(); private readonly Dictionary _idToKey = new Dictionary(); + /// + /// UI 이벤트를 연결하고 스플리터를 준비합니다. + /// public void Start() { // Close if (closeButton != null) { - closeButton.onClick.AddListener(() => gameObject.SetActive(false)); + closeButton.onClick.AddListener(OnCloseClicked); } - // list show 버튼 + // 리스트 표시 버튼 if (showListButton != null && listView != null) - showListButton.onClick.AddListener(() => - { - Debug.Log("BlockDetailModal: Show List View"); - listView.gameObject.SetActive(true); - showListButton.gameObject.SetActive(false); - if (_splitter != null) _splitter.RefreshPosition(); - }); + showListButton.onClick.AddListener(OnShowListClicked); if (showListButton != null) showListButton.gameObject.SetActive(false); - // Selection wiring: list -> model/chart + // 선택 동기화: 리스트 -> 모델/차트 if (listView != null) { - listView.OnItemSelected += data => - { - if (data == null) return; - HandleSelection(data.Id, "ListView"); - }; - - listView.OnItemDeselected += data => - { - HandleDeselection(data.Id, "ListView"); - }; - - - listView.OnClosed += () => - { - if (showListButton != null) showListButton.gameObject.SetActive(true); - if (_splitter != null) _splitter.RefreshPosition(); - }; - listView.OnVisibilityChanged += (id, vis) => - { - if (modelView != null) modelView.SetVisibility(id, vis); - }; + listView.OnItemSelected += OnListItemSelected; + listView.OnItemDeselected += OnListItemDeselected; + listView.OnClosed += OnListClosed; + listView.OnVisibilityChanged += OnListVisibilityChanged; } - // Selection wiring: model -> list/chart + // 선택 동기화: 모델 -> 리스트/차트 if (modelView != null) { - modelView.OnItemSelected += data => - { - if (data == null) return; - HandleSelection(data.Id, "ModelView"); - }; + modelView.OnItemSelected += OnModelItemSelected; } - // Chart -> list/model + // 선택 동기화: 차트 -> 리스트/모델 if (chartView != null) { - // key 기반 클릭 우선 사용 - chartView.OnRowClickedByKey += key => - { - if (string.IsNullOrEmpty(key)) return; - if (_keyToId.TryGetValue(key, out var id)) HandleSelection(id, "ChartView"); - }; - // 호환: Guid 이벤트도 유지 - chartView.OnRowClicked += id => - { - HandleSelection(id, "ChartView"); - }; + chartView.OnRowClickedByKey += OnChartRowClickedByKey; + chartView.OnRowClicked += OnChartRowClicked; } - // Expand buttons + // 확장 버튼 if (modelViewExpandButton != null) modelViewExpandButton.onClick.AddListener(ToggleExpandModel); if (chartViewExpandButton != null) chartViewExpandButton.onClick.AddListener(ToggleExpandChart); - // Drag splitter + // 드래그 스플리터 SetupSplitControls(); } + private void OnCloseClicked() + { + gameObject.SetActive(false); + } + + private void OnShowListClicked() + { + if (listView != null) listView.gameObject.SetActive(true); + if (showListButton != null) showListButton.gameObject.SetActive(false); + if (_splitter != null) _splitter.RefreshPosition(); + } + + private void OnListItemSelected(UVC.UI.List.Tree.TreeListItemData data) + { + if (data == null) return; + HandleSelection(data.Id, "ListView"); + } + + private void OnListItemDeselected(UVC.UI.List.Tree.TreeListItemData data) + { + HandleDeselection(data.Id, "ListView"); + } + + private void OnListClosed() + { + if (showListButton != null) showListButton.gameObject.SetActive(true); + if (_splitter != null) _splitter.RefreshPosition(); + } + + private void OnListVisibilityChanged(Guid id, bool vis) + { + if (modelView != null) modelView.SetVisibility(id, vis); + } + + private void OnModelItemSelected(UVC.UI.List.Tree.TreeListItemData data) + { + if (data == null) return; + HandleSelection(data.Id, "ModelView"); + } + + private void OnChartRowClickedByKey(string key) + { + if (string.IsNullOrEmpty(key)) return; + if (_keyToId.TryGetValue(key, out var id)) HandleSelection(id, "ChartView"); + } + + private void OnChartRowClicked(Guid id) + { + HandleSelection(id, "ChartView"); + } + + /// + /// LoadData 호출 시 리스트/모델/차트를 활성화하고 분할 비율을0.5/0.5로 초기화합니다. + /// + private void InitializePanelsForLoad() + { + // 활성화 상태 설정 + if (listView != null) + { + listView.gameObject.SetActive(true); + listView.Clear(); + } + if (showListButton != null) showListButton.gameObject.SetActive(false); + if (ModelRect != null) ModelRect.gameObject.SetActive(true); + if (chartView != null) chartView.gameObject.SetActive(true); + _expanded = ExpandedSide.None; + + // 레이아웃 요소 보장 및50/50 설정 + var modelRect = ModelRect; + var chartRect = ChartRect; + if (modelRect != null) + { + _modelLayout = modelRect.GetComponent() ?? modelRect.gameObject.AddComponent(); + _modelLayout.flexibleWidth = 1f; + } + if (chartRect != null) + { + _chartLayout = chartRect.GetComponent() ?? chartRect.gameObject.AddComponent(); + _chartLayout.flexibleWidth = 1f; + } + + // 스플리터 보장 및 동기화 + if (_splitter == null && dragButton != null && modelRect != null && chartRect != null) + { + _splitter = dragButton.gameObject.GetComponent(); + if (_splitter == null) _splitter = dragButton.gameObject.AddComponent(); + var leftFixed = listView != null ? listView.GetComponent() : null; + _splitter.Initialize(modelRect, chartRect, leftFixed); + } + if (_splitter != null) _splitter.gameObject.SetActive(true); + + Canvas.ForceUpdateCanvases(); + _splitter?.RefreshPosition(); + } + + + /// + /// glTF 모델과 간트 데이터를 하위 뷰에 로드하고, 선택 동기화를 위한 매핑을 구축합니다. + /// + /// glTF/glb 파일 경로. + /// 간트 데이터셋. + /// 외부 취소 토큰. public async UniTask LoadData(string gltfPath, GanttChartData gantt, CancellationToken externalCt = default) { Debug.Log($"BlockDetailModal: LoadData {gltfPath}"); - // cancel previous + + // 레이아웃/활성 상태 초기화 (리스트/모델/차트 활성,50/50 비율) + InitializePanelsForLoad(); + + // 이전 작업 취소 if (_cts != null) { try { _cts.Cancel(); } catch { } @@ -133,7 +210,7 @@ namespace SHI.modal _cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt); var ct = _cts.Token; - // load model and list + // 모델/리스트 로드 IEnumerable items = Array.Empty(); if (modelView != null) { @@ -143,21 +220,44 @@ namespace SHI.modal } catch (OperationCanceledException) { } } - // 매핑 초기화 - _keyToId.Clear(); - _idToKey.Clear(); - foreach (var it in items) - { - if (!string.IsNullOrEmpty(it.ExternalKey)) - { - _keyToId[it.ExternalKey] = it.Id; - _idToKey[it.Id] = it.ExternalKey; - } - } + + BuildKeyMaps(items); + if (listView != null) listView.SetupData(items); if (chartView != null) chartView.LoadData(gantt); } + private void BuildKeyMaps(IEnumerable items) + { + _keyToId.Clear(); + _idToKey.Clear(); + if (items == null) return; + + var stack = new Stack(); + foreach (var it in items) + { + if (it == null) continue; + stack.Push(it); + while (stack.Count > 0) + { + var cur = stack.Pop(); + if (!string.IsNullOrEmpty(cur.ExternalKey)) + { + _keyToId[cur.ExternalKey] = cur.Id; + _idToKey[cur.Id] = cur.ExternalKey; + } + if (cur.Children != null) + { + for (int i = 0; i < cur.Children.Count; i++) + { + var child = cur.Children[i] as ModelDetailListItemData; + if (child != null) stack.Push(child); + } + } + } + } + } + private void SetupSplitControls() { var modelRect = ModelRect; @@ -170,17 +270,18 @@ namespace SHI.modal _chartLayout = chartRect.GetComponent(); if (_chartLayout == null) _chartLayout = chartView.gameObject.AddComponent(); - // initial split50/50 - _modelLayout.flexibleWidth =1f; - _chartLayout.flexibleWidth =1f; + // 초기 분할50/50 + _modelLayout.flexibleWidth = 1f; + _chartLayout.flexibleWidth = 1f; - // attach drag handler + // 드래그 핸들 부착 _splitter = dragButton.gameObject.GetComponent(); if (_splitter == null) _splitter = dragButton.gameObject.AddComponent(); var leftFixed = listView != null ? listView.GetComponent() : null; _splitter.Initialize(modelRect, chartRect, leftFixed); - //시간이 좀 필요 함 - UniTask.DelayFrame(1).ContinueWith(() => _splitter.RefreshPosition()); + // 레이아웃 갱신 후 위치 보정 + Canvas.ForceUpdateCanvases(); + _splitter.RefreshPosition(); } private void HandleSelection(Guid itemId, string source) @@ -234,8 +335,8 @@ namespace SHI.modal _expanded = ExpandedSide.None; if (ModelRect != null) ModelRect.gameObject.SetActive(true); if (chartView != null) chartView.gameObject.SetActive(true); - if (_modelLayout != null) _modelLayout.flexibleWidth =1f; - if (_chartLayout != null) _chartLayout.flexibleWidth =1f; + if (_modelLayout != null) _modelLayout.flexibleWidth = 1f; + if (_chartLayout != null) _chartLayout.flexibleWidth = 1f; if (_splitter != null) { _splitter.gameObject.SetActive(true); @@ -254,5 +355,38 @@ namespace SHI.modal if (chartView != null) chartView.Dispose(); if (modelView != null) modelView.Dispose(); } + + private void OnDestroy() + { + // 이벤트 해제(메모리 누수 방지) + if (closeButton != null) closeButton.onClick.RemoveListener(OnCloseClicked); + if (showListButton != null && listView != null) showListButton.onClick.RemoveListener(OnShowListClicked); + if (modelViewExpandButton != null) modelViewExpandButton.onClick.RemoveListener(ToggleExpandModel); + if (chartViewExpandButton != null) chartViewExpandButton.onClick.RemoveListener(ToggleExpandChart); + + if (listView != null) + { + listView.OnItemSelected -= OnListItemSelected; + listView.OnItemDeselected -= OnListItemDeselected; + listView.OnClosed -= OnListClosed; + listView.OnVisibilityChanged -= OnListVisibilityChanged; + } + if (modelView != null) + { + modelView.OnItemSelected -= OnModelItemSelected; + } + if (chartView != null) + { + chartView.OnRowClickedByKey -= OnChartRowClickedByKey; + chartView.OnRowClicked -= OnChartRowClicked; + } + + if (_cts != null) + { + try { _cts.Cancel(); } catch { } + _cts.Dispose(); + _cts = null; + } + } } } diff --git a/Assets/Scripts/SHI/modal/GanttChartData.cs b/Assets/Scripts/SHI/modal/GanttChartData.cs index 4a6da6e1..25ca7a13 100644 --- a/Assets/Scripts/SHI/modal/GanttChartData.cs +++ b/Assets/Scripts/SHI/modal/GanttChartData.cs @@ -4,20 +4,31 @@ using System.Collections.Generic; namespace SHI.modal { + /// + /// 간트 차트의 한 구간(세그먼트) 정보를 나타냅니다. + /// public class ScheduleSegment { - // Stable external mapping key (preferred) + /// 외부 연동용 안정 키(권장). public string ItemKey { get; set; } = string.Empty; - // Backward compatibility GUID (optional) + /// 호환용 GUID(선택적). public Guid ItemId { get; set; } + /// 시작 시각(UTC 권장). public DateTime Start { get; set; } + /// 종료 시각(UTC 권장). public DateTime End { get; set; } + /// 진행률 값([0..1] 또는 [0..100] 등 상위 시스템 규약 따름). public float Progress { get; set; } + /// 유형/카테고리 등 사용자 지정 문자열. public string Type { get; set; } = string.Empty; } + /// + /// 간단한 간트 차트 데이터셋입니다. + /// public class GanttChartData { + /// 표시 순서대로의 세그먼트 컬렉션. public List Segments { get; set; } = new List(); } } diff --git a/Assets/Scripts/SHI/modal/HorizontalSplitDrag.cs b/Assets/Scripts/SHI/modal/HorizontalSplitDrag.cs index 26099fc9..ed2a5d12 100644 --- a/Assets/Scripts/SHI/modal/HorizontalSplitDrag.cs +++ b/Assets/Scripts/SHI/modal/HorizontalSplitDrag.cs @@ -6,11 +6,13 @@ using UnityEngine.UI; namespace SHI.modal { /// - /// 두 개의 RectTransform 가로 분할을 드래그 버튼으로 조절하는 간단한 스플리터. - /// 레이아웃 그룹(수평) + LayoutElement.flexibleWidth 기반으로 동작합니다. + /// 수평 레이아웃에서 두 패널의 가중치(LayoutElement.flexibleWidth)를 드래그 핸들로 조절하는 간단한 분할기입니다. /// public class HorizontalSplitDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { + [SerializeField, Range(0.0f, 0.5f)] private float minLeftWeight = 0.1f; + [SerializeField, Range(0.5f, 1.0f)] private float maxLeftWeight = 0.9f; + private RectTransform _left; private RectTransform _right; private LayoutElement _leftLayout; @@ -24,6 +26,9 @@ namespace SHI.modal private bool _lastLeftPanelActive; private float _lastParentWidth; + /// + /// 좌/우 패널을 지정하고(필요 시 좌측 고정 패널 포함) 분할기를 초기화합니다. + /// public void Initialize(RectTransform left, RectTransform right, RectTransform? leftFixedPanel = null) { _left = left; @@ -32,25 +37,27 @@ namespace SHI.modal _parent = left != null ? left.parent as RectTransform : null; _handleRect = transform as RectTransform; - _leftLayout = _left.GetComponent(); - if (_leftLayout == null) _leftLayout = _left.gameObject.AddComponent(); - _rightLayout = _right.GetComponent(); - if (_rightLayout == null) _rightLayout = _right.gameObject.AddComponent(); + _leftLayout = _left != null ? _left.GetComponent() : null; + if (_left != null && _leftLayout == null) _leftLayout = _left.gameObject.AddComponent(); + _rightLayout = _right != null ? _right.GetComponent() : null; + if (_right != null && _rightLayout == null) _rightLayout = _right.gameObject.AddComponent(); _lastLeftPanelActive = _leftFixedPanel != null && _leftFixedPanel.gameObject.activeInHierarchy; _lastParentWidth = _parent != null ? _parent.rect.width : 0f; RefreshPosition(); } + /// public void OnBeginDrag(PointerEventData eventData) { if (_parent != null) _parentWidth = _parent.rect.width; if (_handleRect != null) _handleY = _handleRect.anchoredPosition.y; } + /// public void OnDrag(PointerEventData eventData) { - if (_parent == null) return; + if (_parent == null || _leftLayout == null || _rightLayout == null) return; Vector2 local; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(_parent, eventData.position, eventData.pressEventCamera, out local)) return; @@ -58,34 +65,37 @@ namespace SHI.modal float width = _parent.rect.width; if (width <= 0f) return; - // 계산 범위: 좌측 고정 패널(보이는 경우)의 폭만큼 좌측 경계를 오른쪽으로 이동 + // 좌측 고정 패널(보이는 경우)의 폭만큼 좌측 경계를 오른쪽으로 이동 float leftOffset = 0f; if (_leftFixedPanel != null && _leftFixedPanel.gameObject.activeSelf) { leftOffset = _leftFixedPanel.rect.width; } - float minX = -width * 0.5f + leftOffset; // 사용 가능한 작업 영역의 좌측 경계 - float maxX = width * 0.5f; // 우측 경계 + float minX = -width * 0.5f + leftOffset; // 작업 영역 좌측 경계 + float maxX = width * 0.5f; // 작업 영역 우측 경계 // 현재 포인터 위치를 작업 영역 비율[0..1]로 변환 후 범위 제한 float t = Mathf.InverseLerp(minX, maxX, local.x); t = Mathf.Clamp01(t); - // LayoutElement 비율 (양 끝 과도값 방지하여10%~90% 사이 유지) - float leftWeight = Mathf.Clamp(t, 0.1f, 0.9f); - float rightWeight = 1f - leftWeight; + // LayoutElement 비율 (가변 범위) + float leftWeight = Mathf.Lerp(minLeftWeight, maxLeftWeight, t); + float rightWeight = Mathf.Max(0.0001f, 1f - leftWeight); _leftLayout.flexibleWidth = leftWeight; _rightLayout.flexibleWidth = rightWeight; // 스플리터 핸들도 같은 좌표계에서 이동 if (_handleRect != null) { - float clampedX = Mathf.Lerp(minX, maxX, leftWeight); + float clampedX = Mathf.Lerp(minX, maxX, Mathf.InverseLerp(minLeftWeight, maxLeftWeight, leftWeight)); _handleRect.anchoredPosition = new Vector2(clampedX, _handleY); } } - // 외부에서 강제로 현재 레이아웃 기준으로 핸들 위치를 동기화합니다. + /// + /// 현재 레이아웃 상태에 맞게 드래그 핸들의 위치를 동기화합니다. + /// (레이아웃/가시성 변경 이후 호출 권장) + /// public void RefreshPosition() { if (_parent == null || _handleRect == null || _leftLayout == null || _rightLayout == null) @@ -103,11 +113,11 @@ namespace SHI.modal float totalFlex = Mathf.Max(0.0001f, _leftLayout.flexibleWidth + _rightLayout.flexibleWidth); float leftWeight = Mathf.Clamp01(_leftLayout.flexibleWidth / totalFlex); - leftWeight = Mathf.Clamp(leftWeight, 0.1f, 0.9f); + leftWeight = Mathf.Clamp(leftWeight, minLeftWeight, maxLeftWeight); if (_handleRect != null) { - _handleRect.anchoredPosition = new Vector2(Mathf.Lerp(minX, maxX, leftWeight), _handleY); + _handleRect.anchoredPosition = new Vector2(Mathf.Lerp(minX, maxX, Mathf.InverseLerp(minLeftWeight, maxLeftWeight, leftWeight)), _handleY); } } @@ -123,6 +133,7 @@ namespace SHI.modal } } + /// public void OnEndDrag(PointerEventData eventData) { } } } diff --git a/Assets/Scripts/SHI/modal/ModelDetailChartView.cs b/Assets/Scripts/SHI/modal/ModelDetailChartView.cs index a15e78b9..3a617cb9 100644 --- a/Assets/Scripts/SHI/modal/ModelDetailChartView.cs +++ b/Assets/Scripts/SHI/modal/ModelDetailChartView.cs @@ -15,34 +15,47 @@ namespace SHI.modal private GanttChartData? _data; + /// + /// 간트 데이터를 바인딩합니다(스텁 구현). + /// public void LoadData(GanttChartData data) { _data = data; Debug.Log($"ModelDetailChartView.LoadData: segments={data?.Segments?.Count ??0}"); } + /// + /// 외부 키로 행을 하이라이트합니다. + /// public void SelectByItemKey(string key) { if (_data == null) { Debug.Log("ChartView.SelectByItemKey: no data"); return; } Debug.Log($"Chart highlight by key: {key}"); } + /// + /// Guid 식별자로 행을 하이라이트합니다. + /// public void SelectByItemId(Guid id) { if (_data == null) { Debug.Log("ChartView.SelectByItemId: no data"); return; } Debug.Log($"Chart highlight by id: {id}"); } - // Simulate UI callbacks + // UI 시뮬레이션 콜백 public void SimulateRowClickKey(string key) { OnRowClickedByKey?.Invoke(key); } + public void SimulateRowClick(string id) { if (Guid.TryParse(id, out var guid)) OnRowClicked?.Invoke(guid); } + /// + /// 바인딩된 데이터를 해제합니다. + /// public void Dispose() { _data = null; } } } diff --git a/Assets/Scripts/SHI/modal/ModelDetailListItem.cs b/Assets/Scripts/SHI/modal/ModelDetailListItem.cs index ee9e399d..9299273d 100644 --- a/Assets/Scripts/SHI/modal/ModelDetailListItem.cs +++ b/Assets/Scripts/SHI/modal/ModelDetailListItem.cs @@ -1,16 +1,11 @@ #nullable enable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using UnityEngine; using UVC.UI.Buttons; using UVC.UI.List.Tree; namespace SHI.modal { - public class ModelDetailListItem: TreeListItem + public class ModelDetailListItem : TreeListItem { /// /// 가시성 상태를 표시하는 배경 이미지. @@ -22,7 +17,11 @@ namespace SHI.modal { base.Init(data, control, dragDropManager); if (visibleToggle != null) - { + { + if (data is ModelDetailListItemData modelData) { + visibleToggle.isOn = modelData.IsVisible; + } + visibleToggle.OnValueChanged.AddListener(isOn => { if (data is ModelDetailListItemData modelData) @@ -34,6 +33,18 @@ namespace SHI.modal } } + protected override void OnDataChanged(ChangedType changedType, TreeListItemData changedData, int index) + { + if (changedType == ChangedType.TailButtons && changedData is ModelDetailListItemData modelData) + { + if (visibleToggle != null) + { + visibleToggle.isOn = modelData.IsVisible; + } + } + base.OnDataChanged(changedType, changedData, index); + } + protected override void OnDestroy() { if (visibleToggle != null) visibleToggle.OnValueChanged.RemoveAllListeners(); diff --git a/Assets/Scripts/SHI/modal/ModelDetailListItemData.cs b/Assets/Scripts/SHI/modal/ModelDetailListItemData.cs index c107469b..7a8c65b2 100644 --- a/Assets/Scripts/SHI/modal/ModelDetailListItemData.cs +++ b/Assets/Scripts/SHI/modal/ModelDetailListItemData.cs @@ -20,5 +20,23 @@ namespace SHI.modal /// 외부(간트/백엔드)와의 안정 매핑용 키. 예: GLTF 노드의 풀 경로("/Root/Level1/Beam023"). /// public string ExternalKey { get; set; } = string.Empty; + + + public override TreeListItemData Clone() + { + ModelDetailListItemData clone = new ModelDetailListItemData(); + clone._id = this.Id; + clone.Name = this.Name; + clone.Option = this.Option; + clone.IsExpanded = this.IsExpanded; + clone.IsSelected = this.IsSelected; + clone.OnClickAction = this.OnClickAction; + clone.OnSelectionChanged = this.OnSelectionChanged; + clone.OnDataChanged = this.OnDataChanged; + clone.IsVisible = this.IsVisible; + clone.ExternalKey = this.ExternalKey; + clone.OnClickVisibleAction = this.OnClickVisibleAction; + return clone; + } } } diff --git a/Assets/Scripts/SHI/modal/ModelDetailListView.cs b/Assets/Scripts/SHI/modal/ModelDetailListView.cs index 193a073a..c446669b 100644 --- a/Assets/Scripts/SHI/modal/ModelDetailListView.cs +++ b/Assets/Scripts/SHI/modal/ModelDetailListView.cs @@ -108,14 +108,10 @@ namespace SHI.modal } clearTextButton.onClick.AddListener(() => - { - inputField.text = string.Empty; + { // 취소 CancelSearch(); - - treeListSearch.gameObject.SetActive(false); - treeList.gameObject.SetActive(true); - + // 검색에서 선택한 항목이 있으면 원본 트리에서 동일 항목을 선택하고 펼침 if (selectedSearchItem != null && treeList != null) { @@ -123,6 +119,8 @@ namespace SHI.modal var target = treeList.AllItemDataFlattened.FirstOrDefault(i => i == selectedSearchItem); if (target != null) { + ClearSelection(); + // 부모 체인을 펼치고 선택 처리 treeList.RevealAndSelectItem(target, true); } @@ -132,6 +130,7 @@ namespace SHI.modal }); } + /// /// NEW: 데이터 주입 /// @@ -166,7 +165,7 @@ namespace SHI.modal } /// - /// NEW: Guid 기반 선택 + /// Guid 기반 선택 /// /// public void SelectByItemId(Guid id) @@ -174,6 +173,7 @@ namespace SHI.modal var target = treeList.AllItemDataFlattened.FirstOrDefault(t => t.Id == id); if (target != null) { + ClearSelection(); treeList.RevealAndSelectItem(target, true); } } @@ -232,8 +232,6 @@ namespace SHI.modal { //검색 중이면 취소 CancelSearch(); - treeListSearch.gameObject.SetActive(false); - treeList.gameObject.SetActive(true); treeList.SelectItem(name); } @@ -247,6 +245,24 @@ namespace SHI.modal treeList.DeselectItem(name); } + /// + /// 선택해제 및 검색 취소 + /// + public void Clear() + { + ClearSelection(); + CancelSearch(); + } + + /// + /// 선택 해제 + /// + public void ClearSelection() + { + treeListSearch.ClearSelection(); + treeList.ClearSelection(); + } + protected void StartLoadingAnimation() { if (loadingImage == null) return; @@ -295,8 +311,47 @@ namespace SHI.modal } } + protected void CancelSearch() { + // 검색 중이면 + if (treeListSearch?.gameObject.activeSelf == true) + { + //검색 시 데이터 변경 된 내용 원본 트리에 반영 + treeListSearch.AllItemDataFlattened.ToList().ForEach(searchItem => + { + var originalItem = treeList.AllItemDataFlattened.FirstOrDefault(i => i == searchItem); + if (originalItem != null) + { + bool changed = false; + //선택 상태 동기화 + if (originalItem.IsSelected != searchItem.IsSelected) + { + originalItem.IsSelected = searchItem.IsSelected; + changed = true; + } + //가시성 상태 동기화 (ModelDetailListItemData 전용) + if (originalItem is ModelDetailListItemData originalModelItem && + searchItem is ModelDetailListItemData searchModelItem && + originalModelItem.IsVisible != searchModelItem.IsVisible) + { + originalModelItem.IsVisible = searchModelItem.IsVisible; + changed = true; + } + if (changed) + { + //데이터 변경 알림 + originalItem.NotifyDataChanged(ChangedType.TailButtons, originalItem); + } + } + }); + } + + inputField.text = string.Empty; + + treeListSearch?.gameObject.SetActive(false); + treeList?.gameObject.SetActive(true); + if (searchCts != null) { try { searchCts.Cancel(); } catch { } @@ -337,12 +392,11 @@ namespace SHI.modal protected void OnInputFieldSubmit(string text) { - // 기존 검색 취소 - CancelSearch(); - // 검색어가 있으면 검색 결과 목록 표시 if (!string.IsNullOrEmpty(text)) { + treeListSearch.ClearSelection(); + treeListSearch.gameObject.SetActive(true); treeList.gameObject.SetActive(false); @@ -355,6 +409,9 @@ namespace SHI.modal } else { + // 기존 검색 취소 + CancelSearch(); + treeListSearch.gameObject.SetActive(false); treeList.gameObject.SetActive(true); } @@ -423,7 +480,13 @@ namespace SHI.modal treeListSearch.ClearItems(); foreach (var r in results) { - treeListSearch.AddItem(r.Clone()); + var cloned = r.Clone(); + treeListSearch.AddItem(cloned); + if(cloned.IsSelected) + { + //선택된 항목은 펼치기 + treeListSearch.SelectItem(cloned); + } } // 로딩 종료 diff --git a/Assets/Scripts/SHI/modal/ModelDetailView.cs b/Assets/Scripts/SHI/modal/ModelDetailView.cs index b76df501..d0d3426e 100644 --- a/Assets/Scripts/SHI/modal/ModelDetailView.cs +++ b/Assets/Scripts/SHI/modal/ModelDetailView.cs @@ -11,8 +11,15 @@ using UVC.Util; namespace SHI.modal { + /// + /// glTF 모델을 비동기로 로드해 전용 카메라로 오프스크린 렌더링하고, 결과를 에 출력하는 뷰입니다. + /// 마우스 조작(이동/확대/회전), 항목 하이라이트, 와이어프레임 토글 등을 제공합니다. + /// public class ModelDetailView : MonoBehaviour { + /// + /// 뷰 내부에서 항목이 선택될 때 발생합니다. + /// public Action? OnItemSelected; [Header("View Output (UI)")] @@ -57,11 +64,18 @@ namespace SHI.modal private Vector3 _rmbPivot; // RMB 회전의 피벗(모델 중심) private readonly Dictionary _idToObject = new Dictionary(); - private readonly Dictionary _matCache = new Dictionary(); + // 렌더러별 원본 sharedMaterials 캐시(서브트리 중복/충돌 방지) + private readonly Dictionary _originalSharedByRenderer = new Dictionary(); private GameObject? _root; private Guid? _focusedId; - + /// + /// 주어진 경로의 glTF 모델을 비동기로 로드하고, UI 트리에 사용할 계층 항목을 생성합니다. + /// 또한 오프스크린 카메라/RT를 준비하고 모델을 화면에 맞춰 프레이밍합니다. + /// + /// glTF/glb 파일 경로(절대/StreamingAssets 기반). + /// 취소 토큰. + /// 인스턴스화된 노드에 해당하는 항목 계층. public async UniTask> LoadModelAsync(string path, CancellationToken ct) { Debug.Log($"ModelDetailView.LoadModelAsync: {path}"); @@ -122,7 +136,8 @@ namespace SHI.modal var data = new ModelDetailListItemData { Name = node.name, - ExternalKey = BuildFullPath(node, root) + ExternalKey = BuildFullPath(node, root), + IsExpanded = true }; // map id -> object for selection/highlight and cache materials at this subtree root @@ -168,14 +183,12 @@ namespace SHI.modal private void RestoreAllOriginalMaterials() { - foreach (var kv in _matCache) + foreach (var kv in _originalSharedByRenderer) { - var (rends, originals) = kv.Value; - for (int i = 0; i < rends.Length; i++) - { - if (rends[i] == null) continue; - rends[i].sharedMaterials = originals[i]; - } + var r = kv.Key; + if (r == null) continue; + var originals = kv.Value; + r.sharedMaterials = originals; } _wireframeApplied = false; } @@ -187,7 +200,7 @@ namespace SHI.modal if (_viewCamera == null) { - // Create a world-space rig not parented to UI to avoid layout side effects + // UI와 분리된 월드 공간 리그 생성(레이아웃 영향 차단) var rig = new GameObject("ModelDetailViewRig"); rig.layer = modelLayer; rig.transform.SetParent(null, false); @@ -201,12 +214,12 @@ namespace SHI.modal _viewCamera.nearClipPlane = 0.01f; _viewCamera.farClipPlane = 5000f; _viewCamera.cullingMask = (modelLayer >= 0 && modelLayer <= 31) ? (1 << modelLayer) : ~0; - // Prevent drawing to main display when no RT assigned + // RT 미할당 시 메인 디스플레이 출력 방지 _viewCamera.targetDisplay = 1; // secondary display - _viewCamera.enabled = false; // enable only when RT is bound + _viewCamera.enabled = false; // RT가 바인딩될 때만 활성화 } - // optional default light (independent of UI) + // 기본 조명(옵션, UI와 독립) if (createDefaultLight && _viewCamera.transform.parent != null && _viewCamera.transform.parent.Find("ModelDetailViewLight") == null) { var lightGo = new GameObject("ModelDetailViewLight"); @@ -256,7 +269,7 @@ namespace SHI.modal } if (outputImage == null) { - // No output target; keep camera disabled to avoid drawing to display + // 출력 타겟이 없으면 메인 디스플레이 출력 방지 _viewCamera.enabled = false; return; } @@ -280,7 +293,7 @@ namespace SHI.modal }; _viewCamera.targetTexture = _rt; outputImage.texture = _rt; - // enable camera only when RT is assigned + // RT 바인딩 시에만 카메라 활성화 _viewCamera.enabled = true; } else @@ -296,11 +309,14 @@ namespace SHI.modal protected void OnRectTransformDimensionsChange() { - // Keep RT in sync with UI size changes (e.g., parent resized) + // UI 리사이즈에 따라 RT 크기 동기화 EnsureRenderTargetSize(); } - // Allow external toggle + /// + /// 와이어프레임 모드를 토글합니다. + /// + /// true면 와이어프레임 적용, false면 원본 머티리얼 복원. public void SetWireframe(bool on) { wireframeMode = on; @@ -315,76 +331,18 @@ namespace SHI.modal } } - private static void SetLayerRecursive(GameObject go, int layer) - { - if (layer < 0 || layer > 31) return; - go.layer = layer; - foreach (Transform c in go.transform) SetLayerRecursive(c.gameObject, layer); - } - - private static Bounds CalculateBounds(GameObject root) - { - var rends = root.GetComponentsInChildren(true); - var has = false; - var bounds = new Bounds(root.transform.position, Vector3.zero); - foreach (var r in rends) - { - if (r == null) continue; - if (!has) { bounds = r.bounds; has = true; } - else bounds.Encapsulate(r.bounds); - } - if (!has) bounds = new Bounds(root.transform.position, Vector3.one); - return bounds; - } - - private void FrameToBounds(Bounds b) - { - if (_viewCamera == null) return; - var center = b.center; - var extents = b.extents; - float radius = Mathf.Max(extents.x, Mathf.Max(extents.y, Mathf.Max(extents.z, 0.001f))); - float fovRad = _viewCamera.fieldOfView * Mathf.Deg2Rad; - float dist = radius / Mathf.Sin(fovRad * 0.5f); - dist = Mathf.Clamp(dist, 1f, 1e4f); - _viewCamera.transform.position = center + new Vector3(1, 0.5f, 1).normalized * dist; - _viewCamera.transform.LookAt(center); - //_viewCamera.nearClipPlane = Mathf.Max(0.01f, dist - radius * 2f); - //_viewCamera.farClipPlane = dist + radius * 4f; - - _orbitTarget = center; - _orbitDistance = Vector3.Distance(_viewCamera.transform.position, _orbitTarget); - var dir = (_viewCamera.transform.position - _orbitTarget).normalized; - _yaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; - _pitch = Mathf.Asin(dir.y) * Mathf.Rad2Deg; - } - - private string BuildFullPath(Transform node, Transform root) - { - var stack = new Stack(); - var current = node; - while (current != null && current != root) - { - stack.Push(current.name); - current = current.parent; - } - return "/" + string.Join('/', stack); - } - - private void CacheOriginalMaterials(GameObject go) - { - var rends = go.GetComponentsInChildren(true); - if (rends.Length == 0) return; - var originals = new UnityEngine.Material[rends.Length][]; - for (int i = 0; i < rends.Length; i++) originals[i] = rends[i].sharedMaterials; - _matCache[go] = (rends, originals); - } - + /// + /// 지정한 항목을 포커스(하이라이트)합니다. + /// public void FocusItem(TreeListItemData data) { if (data == null) return; FocusItemById(data.Id); } + /// + /// Guid 식별자로 항목을 포커스(하이라이트)합니다. + /// public void FocusItemById(Guid id) { _focusedId = id; @@ -396,6 +354,9 @@ namespace SHI.modal } } + /// + /// 포커스를 해제하고 하이라이트를 제거합니다. + /// public void UnfocusItem() { if (_focusedId.HasValue && _idToObject.TryGetValue(_focusedId.Value, out var go)) @@ -405,6 +366,9 @@ namespace SHI.modal _focusedId = null; } + /// + /// 인스턴스화된 노드의 활성 상태(가시성)를 변경합니다. + /// public void SetVisibility(Guid id, bool on) { Debug.Log($"ModelDetailView.SetVisibility: id={id} on={on}"); @@ -416,9 +380,9 @@ namespace SHI.modal // Common color property names across pipelines/shaders private static readonly string[] ColorProps = new[] { - "_WireframeColor", "_WireColor", // wireframe shader variants - "_BaseColor", "_Color", "_LineColor", "_TintColor" - }; + "_WireframeColor", "_WireColor", // wireframe shader variants + "_BaseColor", "_Color", "_LineColor", "_TintColor" + }; private bool TrySetColor(Material mat, Color c) { @@ -451,26 +415,24 @@ namespace SHI.modal private void Highlight(GameObject go, bool on) { - if (!_matCache.TryGetValue(go, out var tuple)) return; - var (rends, originals) = tuple; + // 런타임 인스턴스 머티리얼을 사용하고, 복원 시 렌더러별 캐시된 원본 색을 참고 + var rends = go.GetComponentsInChildren(true); + if (rends == null || rends.Length == 0) return; for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null) continue; - // Work on instantiated materials to avoid touching shared assets - var mats = r.materials; + var mats = r.materials; // instanced for (int m = 0; m < mats.Length; m++) { if (on) { - // set to highlight color regardless of shader TrySetColor(mats[m], ColorUtil.FromHex("#888814")); } else { - // When in wireframe mode (material replaced) restore to default wireframe color. bool matIsWire = mats[m] != null && (mats[m].HasProperty("_WireframeColor") || mats[m].HasProperty("_WireColor")); if (_wireframeApplied || matIsWire) { @@ -482,12 +444,19 @@ namespace SHI.modal } else { - // restore to original color if known; otherwise white + // 캐시된 원본 shared material에서 색 복원 시도 Color orig; - if (i < originals.Length && m < originals[i].Length && originals[i][m] != null && TryGetColor(originals[i][m], out orig)) + UnityEngine.Material[] originals; + if (_originalSharedByRenderer.TryGetValue(r, out originals) + && m < originals.Length && originals[m] != null + && TryGetColor(originals[m], out orig)) + { TrySetColor(mats[m], orig); + } else + { TrySetColor(mats[m], ColorUtil.FromHex("#888888")); + } } } } @@ -496,6 +465,9 @@ namespace SHI.modal } + /// + /// 외부 리스너에게 선택 이벤트를 전달합니다. + /// public void RaiseSelected(TreeListItemData data) { OnItemSelected?.Invoke(data); @@ -589,33 +561,27 @@ namespace SHI.modal } } - private void UpdateOrbitPose() - { - if (_viewCamera == null) return; - _orbitDistance = Mathf.Max(0.01f, _orbitDistance); - var rot = Quaternion.Euler(_pitch, _yaw, 0f); - var offset = rot * new Vector3(0, 0, -_orbitDistance); - _viewCamera.transform.position = _orbitTarget + offset; - _viewCamera.transform.LookAt(_orbitTarget); - } + /// + /// 이 뷰가 보유한 런타임 리소스(RT, 인스턴스 머티리얼, 루트 GO)를 해제합니다. + /// 여러 번 호출해도 안전합니다. + /// public void Dispose() { - foreach (var kv in _matCache) + // Destroy instanced materials and restore original shared materials + foreach (var kv in _originalSharedByRenderer) { - var (rends, originals) = kv.Value; - for (int i = 0; i < rends.Length; i++) + var r = kv.Key; + if (r == null) continue; + var originals = kv.Value; + var mats = r.materials; + for (int m = 0; m < mats.Length; m++) { - if (rends[i] == null) continue; - var mats = rends[i].materials; - for (int m = 0; m < mats.Length; m++) - { - if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]); - } - rends[i].materials = originals[i]; + if (mats[m] != null) UnityEngine.Object.Destroy(mats[m]); } + r.materials = originals; // restore to originals in one go } - _matCache.Clear(); + _originalSharedByRenderer.Clear(); _idToObject.Clear(); if (_root != null) UnityEngine.Object.Destroy(_root); _root = null; @@ -624,7 +590,7 @@ namespace SHI.modal if (_viewCamera != null) { - // detach RT and keep camera disabled to avoid replacing main display + // RT 바인딩 해제하고 메인 디스플레이로 대체 출력되지 않도록 비활성화 if (_rt != null && _viewCamera.targetTexture == _rt) { _viewCamera.targetTexture = null; @@ -639,5 +605,93 @@ namespace SHI.modal } } + private void OnDestroy() + { + // 컴포넌트 파괴 시 리소스 정리 보장 + Dispose(); + } + + // ===== 유틸리티 ===== + + /// + /// 지정한 GameObject와 모든 하위 오브젝트의 레이어를 일괄 설정합니다. + /// + private static void SetLayerRecursive(GameObject go, int layer) + { + if (layer < 0 || layer > 31) return; + go.layer = layer; + foreach (Transform c in go.transform) SetLayerRecursive(c.gameObject, layer); + } + + /// + /// 루트 아래 모든 렌더러의 경계(bounds)를 계산합니다. + /// + private static Bounds CalculateBounds(GameObject root) + { + var rends = root.GetComponentsInChildren(true); + var has = false; + var bounds = new Bounds(root.transform.position, Vector3.zero); + foreach (var r in rends) + { + if (r == null) continue; + if (!has) { bounds = r.bounds; has = true; } + else bounds.Encapsulate(r.bounds); + } + if (!has) bounds = new Bounds(root.transform.position, Vector3.one); + return bounds; + } + + /// + /// 카메라를 주어진 경계에 맞게 배치하고 오빗 타깃을 갱신합니다. + /// + private void FrameToBounds(Bounds b) + { + if (_viewCamera == null) return; + var center = b.center; + var extents = b.extents; + float radius = Mathf.Max(extents.x, Mathf.Max(extents.y, Mathf.Max(extents.z, 0.001f))); + float fovRad = _viewCamera.fieldOfView * Mathf.Deg2Rad; + float dist = radius / Mathf.Sin(fovRad * 0.5f); + dist = Mathf.Clamp(dist, 1f, 1e4f); + _viewCamera.transform.position = center + new Vector3(1, 0.5f, 1).normalized * dist; + _viewCamera.transform.LookAt(center); + + _orbitTarget = center; + _orbitDistance = Vector3.Distance(_viewCamera.transform.position, _orbitTarget); + var dir = (_viewCamera.transform.position - _orbitTarget).normalized; + _yaw = Mathf.Atan2(dir.x, dir.z) * Mathf.Rad2Deg; + _pitch = Mathf.Asin(dir.y) * Mathf.Rad2Deg; + } + + /// + /// 루트 기준으로 트랜스폼의 전체 경로 문자열을 생성합니다. 예: "/Root/PartA/Bolt01" + /// + private string BuildFullPath(Transform node, Transform root) + { + var stack = new Stack(); + var current = node; + while (current != null && current != root) + { + stack.Push(current.name); + current = current.parent; + } + return "/" + string.Join('/', stack); + } + + /// + /// 서브트리의 모든 렌더러에 대해 원본 sharedMaterials를 캐싱합니다. + /// + private void CacheOriginalMaterials(GameObject go) + { + var rends = go.GetComponentsInChildren(true); + if (rends.Length == 0) return; + for (int i = 0; i < rends.Length; i++) + { + var r = rends[i]; + if (r == null) continue; + if (_originalSharedByRenderer.ContainsKey(r)) continue; // 이미 상위에서 캐시됨 + _originalSharedByRenderer[r] = r.sharedMaterials; + } + } } } diff --git a/Assets/Scripts/UVC/UI/List/Tree/TreeList.cs b/Assets/Scripts/UVC/UI/List/Tree/TreeList.cs index dc0051f7..7c2e3877 100644 --- a/Assets/Scripts/UVC/UI/List/Tree/TreeList.cs +++ b/Assets/Scripts/UVC/UI/List/Tree/TreeList.cs @@ -974,7 +974,7 @@ namespace UVC.UI.List.Tree public void SelectItem(TreeListItemData data) { // 이미 선택되어 있으면 중복 선택 방지 - if (data.IsSelected) + if (data.IsSelected && selectedItems.Contains(data)) { return; } @@ -983,8 +983,8 @@ namespace UVC.UI.List.Tree // (새 선택만 유지) if (!allowMultipleSelection && selectedItems.Count > 0) { - // 첫 번째(유일한) 선택 항목 해제 - DeselectItem(selectedItems[0]); + //선택 항목 해제 + ClearSelection(); } // 아이템의 선택 상태를 true로 설정 diff --git a/Assets/Scripts/UVC/UI/List/Tree/TreeListItem.cs b/Assets/Scripts/UVC/UI/List/Tree/TreeListItem.cs index dd35e4ec..0a95abcb 100644 --- a/Assets/Scripts/UVC/UI/List/Tree/TreeListItem.cs +++ b/Assets/Scripts/UVC/UI/List/Tree/TreeListItem.cs @@ -201,7 +201,7 @@ namespace UVC.UI.List.Tree /// - changedData: 변경 대상 데이터(자식 추가 등일 때 유효) /// - index: 삽입/이동 시 기준 인덱스(해당되는 경우) /// - protected void OnDataChanged(ChangedType changedType, TreeListItemData changedData, int index) + protected virtual void OnDataChanged(ChangedType changedType, TreeListItemData changedData, int index) { if (data == null) return; diff --git a/Assets/Scripts/UVC/UI/List/Tree/TreeListItemData.cs b/Assets/Scripts/UVC/UI/List/Tree/TreeListItemData.cs index 3c84285c..7d663ad1 100644 --- a/Assets/Scripts/UVC/UI/List/Tree/TreeListItemData.cs +++ b/Assets/Scripts/UVC/UI/List/Tree/TreeListItemData.cs @@ -56,28 +56,28 @@ namespace UVC.UI.List.Tree #endregion - #region 내부 필드 (Private Fields) + #region 내부 필드 (protected Fields) /// 고유 식별자(Id). - private readonly Guid _id = Guid.NewGuid(); + protected Guid _id = Guid.NewGuid(); /// 아이템 이름. - private string _name = string.Empty; + protected string _name = string.Empty; /// 추가 옵션 문자열. - private string _option = string.Empty; + protected string _option = string.Empty; /// 자식 펼침 여부. - private bool _isExpanded = false; + protected bool _isExpanded = false; /// 선택 여부. - private bool _isSelected = false; + protected bool _isSelected = false; /// 부모 - private TreeListItemData? _parent; + protected TreeListItemData? _parent; /// 자식 리스트. - private List _children = new List(); + protected List _children = new List(); #endregion @@ -121,7 +121,7 @@ namespace UVC.UI.List.Tree /// 펼침 상태(같은 어셈블리 내 전용). /// 변경 시 OnDataChanged(Expanded) 발생. /// - internal bool IsExpanded + public bool IsExpanded { get => _isExpanded; set @@ -399,10 +399,14 @@ namespace UVC.UI.List.Tree public TreeListItemData CloneWithChild() { TreeListItemData clone = new TreeListItemData(); + clone._id = this.Id; clone.Name = this.Name; clone.Option = this.Option; clone.IsExpanded = this.IsExpanded; clone.IsSelected = this.IsSelected; + clone.OnClickAction = this.OnClickAction; + clone.OnSelectionChanged = this.OnSelectionChanged; + clone.OnDataChanged = this.OnDataChanged; foreach (var child in this.Children) { clone.AddChild(child.CloneWithChild()); @@ -414,13 +418,17 @@ namespace UVC.UI.List.Tree /// 현재 인스턴스의 복사본인 의 새 인스턴스를 생성합니다. /// /// 현재 인스턴스와 동일한 속성 값을 가진 새 객체를 생성합니다. - public TreeListItemData Clone() + public virtual TreeListItemData Clone() { TreeListItemData clone = new TreeListItemData(); + clone._id = this.Id; clone.Name = this.Name; clone.Option = this.Option; clone.IsExpanded = this.IsExpanded; clone.IsSelected = this.IsSelected; + clone.OnClickAction = this.OnClickAction; + clone.OnSelectionChanged = this.OnSelectionChanged; + clone.OnDataChanged = this.OnDataChanged; return clone; } @@ -442,5 +450,6 @@ namespace UVC.UI.List.Tree AddCloneChild, AddCloneAtChild, SwapChild, + TailButtons,// 향후 버튼 관련 변경 추가 가능 } } \ No newline at end of file diff --git a/Assets/Scripts/UVC/UI/Window/HierarchyWindow.cs b/Assets/Scripts/UVC/UI/Window/HierarchyWindow.cs index 432f5dcb..7509680d 100644 --- a/Assets/Scripts/UVC/UI/Window/HierarchyWindow.cs +++ b/Assets/Scripts/UVC/UI/Window/HierarchyWindow.cs @@ -1,6 +1,8 @@ #nullable enable using Cysharp.Threading.Tasks; using DG.Tweening; +using SHI.modal; +using System; using System.Linq; using System.Threading; using TMPro; @@ -100,13 +102,9 @@ namespace UVC.UI.Window clearTextButton.onClick.AddListener(() => { - inputField.text = string.Empty; // 취소 CancelSearch(); - treeListSearch.gameObject.SetActive(false); - treeList.gameObject.SetActive(true); - // 검색에서 선택한 항목이 있으면 원본 트리에서 동일 항목을 선택하고 펼침 if (selectedSearchItem != null && treeList != null) { @@ -114,6 +112,8 @@ namespace UVC.UI.Window var target = treeList.AllItemDataFlattened.FirstOrDefault(i => i == selectedSearchItem); if (target != null) { + ClearSelection(); + // 부모 체인을 펼치고 선택 처리 treeList.RevealAndSelectItem(target, true); } @@ -123,6 +123,20 @@ namespace UVC.UI.Window }); } + /// + /// Guid 기반 선택 + /// + /// + public void SelectByItemId(Guid id) + { + var target = treeList.AllItemDataFlattened.FirstOrDefault(t => t.Id == id); + if (target != null) + { + ClearSelection(); + treeList.RevealAndSelectItem(target, true); + } + } + /// /// 메인 트리에 항목을 추가합니다. /// @@ -178,8 +192,6 @@ namespace UVC.UI.Window //검색 중이면 취소 CancelSearch(); - treeListSearch.gameObject.SetActive(false); - treeList.gameObject.SetActive(true); treeList.SelectItem(name); } @@ -193,6 +205,24 @@ namespace UVC.UI.Window treeList.DeselectItem(name); } + /// + /// 선택해제 및 검색 취소 + /// + public void Clear() + { + ClearSelection(); + CancelSearch(); + } + + /// + /// 선택 해제 + /// + public void ClearSelection() + { + treeListSearch.ClearSelection(); + treeList.ClearSelection(); + } + protected void StartLoadingAnimation() { if (loadingImage == null) return; @@ -243,6 +273,36 @@ namespace UVC.UI.Window protected void CancelSearch() { + // 검색 중이면 + if (treeListSearch?.gameObject.activeSelf == true) + { + //검색 시 데이터 변경 된 내용 원본 트리에 반영 + treeListSearch.AllItemDataFlattened.ToList().ForEach(searchItem => + { + var originalItem = treeList.AllItemDataFlattened.FirstOrDefault(i => i == searchItem); + if (originalItem != null) + { + bool changed = false; + //선택 상태 동기화 + if (originalItem.IsSelected != searchItem.IsSelected) + { + originalItem.IsSelected = searchItem.IsSelected; + changed = true; + } + if (changed) + { + //데이터 변경 알림 + originalItem.NotifyDataChanged(ChangedType.TailButtons, originalItem); + } + } + }); + } + + inputField.text = string.Empty; + + treeListSearch?.gameObject.SetActive(false); + treeList?.gameObject.SetActive(true); + if (searchCts != null) { try { searchCts.Cancel(); } catch { } @@ -283,12 +343,11 @@ namespace UVC.UI.Window protected void OnInputFieldSubmit(string text) { - // 기존 검색 취소 - CancelSearch(); - // 검색어가 있으면 검색 결과 목록 표시 if (!string.IsNullOrEmpty(text)) { + treeListSearch.ClearSelection(); + treeListSearch.gameObject.SetActive(true); treeList.gameObject.SetActive(false); @@ -301,6 +360,9 @@ namespace UVC.UI.Window } else { + // 기존 검색 취소 + CancelSearch(); + treeListSearch.gameObject.SetActive(false); treeList.gameObject.SetActive(true); } @@ -369,7 +431,13 @@ namespace UVC.UI.Window treeListSearch.ClearItems(); foreach (var r in results) { - treeListSearch.AddItem(r.Clone()); + var cloned = r.Clone(); + treeListSearch.AddItem(cloned); + if (cloned.IsSelected) + { + //선택된 항목은 펼치기 + treeListSearch.SelectItem(cloned); + } } // 로딩 종료 diff --git a/Assets/StreamingAssets/block2.glb b/Assets/StreamingAssets/block2.glb deleted file mode 100644 index 9c5723a0..00000000 Binary files a/Assets/StreamingAssets/block2.glb and /dev/null differ diff --git a/Assets/StreamingAssets/block2.glb.meta b/Assets/StreamingAssets/block2.glb.meta deleted file mode 100644 index 5d563d68..00000000 --- a/Assets/StreamingAssets/block2.glb.meta +++ /dev/null @@ -1,7 +0,0 @@ -fileFormatVersion: 2 -guid: 9a7772b617c3413428e185c2e5e08f49 -DefaultImporter: - externalObjects: {} - userData: - assetBundleName: - assetBundleVariant: